Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Oktasso SSO auth config for scripts/oktassoAuth.ts.
# Copy to .env (gitignored) and fill in the provider values.
# Local-only configuration template.
# Copy to .env (gitignored) and fill in the values for your deployment.

# Provider endpoints + SSO client for your deployment.
# OAuth/PKCE provider endpoints + client for scripts/oktassoAuth.ts.
OKTASSO_OAUTH_ENDPOINT_BASE=
OKTASSO_TOKEN_EXCHANGE_URL=
OKTASSO_CLIENT_ID=
Expand All @@ -13,5 +13,15 @@ OKTASSO_CALLBACK_PORT=3001
# Token cache lifetime in ms (55 minutes).
OKTASSO_REFRESH_AFTER_MS=3300000

# Server runtime config. Deployment systems should inject these as environment
# variables or secrets rather than baking private values into the image or repo.
TANGLE_API_URL=https://api.example.com
TANGLE_BASE_URL=https://api.example.com
TANGLE_TOKEN=
PI_PROXY_URL=https://proxy.example.com
PI_PROXY_API_KEY=
AUTH_JWT_TOKEN_COOKIE_NAME=
INSTANCE_PROXY_URL=

# Default bundle used by the Sessions page New session button.
VITE_DEFAULT_SESSION_BUNDLE_ID=tangle
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ pnpm dev # runs server + web via Turbo
`pnpm dev` sets the session/bundle/memory paths to local gitignored folders (`.sessions`,
`.agent-bundles`, `.memory`) so a checkout runs without extra setup.

Copy `.env.example` to `.env` for local-only provider values. Keep real deployment
URLs, cookies, proxy credentials, and auth endpoints out of committed files; inject them
through your process manager, container platform, or secret manager.

To load the example agent bundles into your local marketplace:

```bash
Expand All @@ -115,17 +119,25 @@ pnpm seed # installs bundles from examples
Key environment variables (see [`apps/server/src/config.ts`](apps/server/src/config.ts) for
the full list and defaults):

| Variable | Purpose |
| ------------------------------------------ | ------------------------------------------------------ |
| `PORT` | HTTP server port |
| `SESSIONS_ROOT` | Root directory for per-session workspaces |
| `SESSIONS_DB` | SQLite metadata database file |
| `AGENT_BUNDLES_ROOT` | Bundle marketplace storage |
| `GLOBAL_MEMORY_DIR` | Global (cross-session) memory store |
| `PI_BIN` | Path to the `pi` agent executable |
| `PI_PROVIDER` / `PI_MODEL` / `PI_THINKING` | Default LLM provider, model, and thinking level |
| `PI_PROXY_URL` / `PI_PROXY_API_KEY` | LLM proxy endpoint and credential |
| `TANGLE_API_URL` / `TANGLE_TOKEN` | Tangle pipeline API for bundles that integrate with it |
| Variable | Purpose |
| ------------------------------------------------- | -------------------------------------------------------------- |
| `PORT` | HTTP server port |
| `SESSIONS_ROOT` | Root directory for per-session workspaces |
| `SESSIONS_DB` | SQLite metadata database file |
| `AGENT_BUNDLES_ROOT` | Bundle marketplace storage |
| `GLOBAL_MEMORY_DIR` | Global (cross-session) memory store |
| `PI_BIN` | Path to the `pi` agent executable |
| `PI_PROVIDER` / `PI_MODEL` / `PI_THINKING` | Default LLM provider, model, and thinking level |
| `PI_PROXY_URL` / `PI_PROXY_API_KEY` | LLM proxy endpoint and credential |
| `TANGLE_API_URL` | Tangle API base URL used by server-side egress targets |
| `TANGLE_TOKEN` | Optional cookie string injected server-side for egress |
| `AUTH_JWT_TOKEN_COOKIE_NAME` | Optional auth cookie name for `/api/me` |
| `TANGENT_INTERNAL_URL` / `TANGENT_INTERNAL_TOKEN` | Internal agent API URL and bearer token |
| `INSTANCE_PROXY_URL` | Optional deployment proxy URL consumed by `docker/instance.sh` |

Bundle UI extensions should call logical egress targets such as
`{ target: "tangle", path: "/api/..." }`; the server resolves the actual base URL from
`TANGLE_API_URL` at runtime.

### Common commands

Expand Down
72 changes: 72 additions & 0 deletions apps/server/src/bundleUi/egressAllowlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { afterEach, mock, test } from "node:test";

import { TANGLE_API_URL } from "../config.ts";
import {
EgressDeniedError,
resolveEgress,
resolveTargetUrl,
} from "./egressAllowlist.ts";

afterEach(() => {
delete process.env.TANGLE_TOKEN;
mock.reset();
});

test("resolves a logical Tangle target and injects server-side credentials", async () => {
process.env.TANGLE_TOKEN = "SESSION=abc";
const expectedBase = new URL(TANGLE_API_URL);
const fetchMock = mock.method(
globalThis,
"fetch",
async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input));
assert.equal(url.origin, expectedBase.origin);
assert.equal(url.pathname, "/api/executions/run-1/state");
assert.equal(url.searchParams.get("include"), "true");
assert.equal(
(init?.headers as Record<string, string> | undefined)?.cookie,
"SESSION=abc",
);

return new Response(JSON.stringify({ ok: true }), {
headers: {
"content-type": "application/json",
"x-internal": "hidden",
},
});
},
);

const result = await resolveEgress(
{ target: "tangle", path: "/api/executions/run-1/state" },
{ query: { include: true } },
);

assert.equal(fetchMock.mock.calls.length, 1);
assert.equal(result.ok, true);
assert.equal(result.status, 200);
assert.deepEqual(result.headers, { "content-type": "application/json" });
assert.deepEqual(result.json, { ok: true });
});

test("rejects a logical target path that is not allowlisted", async () => {
await assert.rejects(
resolveEgress({ target: "tangle", path: "/api/private" }),
EgressDeniedError,
);
});

test("rejects malformed logical target paths", async () => {
assert.equal(
resolveTargetUrl({ target: "tangle", path: "api/executions/run-1/state" }),
undefined,
);
assert.equal(
resolveTargetUrl({
target: "tangle",
path: "//evil.example/api/executions/run-1/state",
}),
undefined,
);
});
81 changes: 50 additions & 31 deletions apps/server/src/bundleUi/egressAllowlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@
* Server-side egress allowlist for the bundle-UI `host.fetch` bridge (Phase 5+7).
*
* A sandboxed component can only reach destinations registered here; anything
* else is denied before a network call is made. Unlike the earlier alias-based
* stub, components now name a **real, full URL** (e.g. the Tangle executions API)
* and the proxy validates it against the allowlist of host/path patterns, then
* performs the actual `fetch` server-side, injecting any credentials and
* stripping host internals out of the response.
* else is denied before a network call is made. Components name a logical target
* and path; the proxy resolves that target from server config, validates it
* against the allowlist, performs the actual `fetch` server-side, injects any
* credentials, and strips host internals out of the response.
*
* See `docs/bundle-ui/host-bridge.md` for the contract.
*/

import { TANGLE_API_URL } from "../config.ts";

export interface EgressTargetRequest {
target: "tangle";
path: string;
}

export type EgressInput = string | EgressTargetRequest;

/** A tiny subset of `RequestInit` that crosses the bridge. */
export interface EgressRequestInit {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
Expand All @@ -31,9 +37,9 @@ export interface EgressResponse {
}

/**
* One allowlisted destination. The component supplies the full URL; a request
* is permitted only when its method and parsed URL match a rule. `headers()`
* lets the server inject credentials the worker never sees.
* One allowlisted destination. A request is permitted only when its method and
* resolved URL match a rule. `headers()` lets the server inject credentials the
* worker never sees.
*/
interface EgressRule {
method: NonNullable<EgressRequestInit["method"]>;
Expand Down Expand Up @@ -95,18 +101,7 @@ const TANGLE_RULES: EgressRule[] = TANGLE_PATH_RULES.map((rule) => ({
}));

/** Registered destinations the bridge may reach. */
const EGRESS_RULES: EgressRule[] = [
{
// Oasis execution state, e.g.
// https://oasis.shopify.io/api/executions/019ea56d72cd5f4d75f6/state
method: "GET",
matches: (url) =>
url.origin === "https://oasis.shopify.io" &&
/^\/api\/executions\/[^/]+\/state$/.test(url.pathname),
headers: tangleAuthHeaders,
},
...TANGLE_RULES,
];
const EGRESS_RULES: EgressRule[] = [...TANGLE_RULES];

/** Response headers we are willing to surface back across the bridge. */
const ALLOWED_RESPONSE_HEADERS = new Set(["content-type"]);
Expand All @@ -122,7 +117,7 @@ export class EgressDeniedError extends Error {
}
}

/** Parses `input` as an absolute http(s) URL, or returns `undefined`. */
/** Parses a string as an absolute http(s) URL, or returns `undefined`. */
function parseHttpUrl(input: string): URL | undefined {
let url: URL;
try {
Expand All @@ -134,6 +129,14 @@ function parseHttpUrl(input: string): URL | undefined {
return url;
}

function isEgressTargetRequest(
input: EgressInput,
): input is EgressTargetRequest {
return (
typeof input === "object" && input !== null && input.target === "tangle"
);
}

/** Picks the allowlisted subset of response headers as a plain object. */
function sanitizeResponseHeaders(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
Expand All @@ -144,12 +147,17 @@ function sanitizeResponseHeaders(headers: Headers): Record<string, string> {
}

/**
* Resolves an absolute http(s) URL with merged query params, or throws
* {@link EgressDeniedError} for anything that isn't an http(s) URL.
* Resolves a logical target or absolute http(s) URL with merged query params, or
* throws {@link EgressDeniedError} for anything that cannot be safely resolved.
*/
function resolveUrl(input: string, query: EgressRequestInit["query"]): URL {
const url = parseHttpUrl(input);
if (!url) throw new EgressDeniedError(input);
function resolveUrl(
input: EgressInput,
query: EgressRequestInit["query"],
): URL {
const url = isEgressTargetRequest(input)
? resolveTargetUrl(input)
: parseHttpUrl(input);
if (!url) throw new EgressDeniedError(formatDeniedInput(input));
if (query) {
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, String(value));
Expand All @@ -158,6 +166,17 @@ function resolveUrl(input: string, query: EgressRequestInit["query"]): URL {
return url;
}

export function resolveTargetUrl(input: EgressTargetRequest): URL | undefined {
if (!input.path.startsWith("/") || input.path.startsWith("//")) {
return undefined;
}
return new URL(input.path, TANGLE_API_URL);
}

function formatDeniedInput(input: EgressInput): string {
return typeof input === "string" ? input : `${input.target}:${input.path}`;
}

/** Whether this request carries a JSON body (anything but a bodyless GET). */
function isBodyRequest(
method: NonNullable<EgressRequestInit["method"]>,
Expand Down Expand Up @@ -240,20 +259,20 @@ async function toEgressResponse(response: Response): Promise<EgressResponse> {

/**
* Resolves an allowlisted egress request by performing the real network call
* server-side. Throws {@link EgressDeniedError} when `input` is not an absolute
* http(s) URL matching a registered destination. Network/transport failures
* propagate as generic errors (the route maps them to a 502).
* server-side. Throws {@link EgressDeniedError} when `input` cannot resolve to a
* registered destination. Network/transport failures propagate as generic
* errors (the route maps them to a 502).
*/
export async function resolveEgress(
input: string,
input: EgressInput,
init: EgressRequestInit = {},
): Promise<EgressResponse> {
const method = init.method ?? "GET";
const url = resolveUrl(input, init.query);
const rule = EGRESS_RULES.find(
(entry) => entry.method === method && entry.matches(url),
);
if (!rule) throw new EgressDeniedError(input);
if (!rule) throw new EgressDeniedError(formatDeniedInput(input));

const response = await performFetch(url, method, init, rule);
return toEgressResponse(response);
Expand Down
34 changes: 23 additions & 11 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import {
DEFAULT_THINKING_LEVEL,
} from "@tangent/shared/contracts.ts";

function readAbsoluteUrl(name: string, fallback: string): string {
const value = process.env[name] ?? fallback;
try {
new URL(value);
} catch {
throw new Error(`${name} must be an absolute URL`);
}
return value;
}

/** Port the dev server listens on. Vite proxies /api and /socket.io here. */
export const PORT = Number(process.env.PORT ?? 8787);

Expand Down Expand Up @@ -81,12 +91,13 @@ export const PI_BIN = process.env.PI_BIN ?? "pi";

/**
* Base URL of the LLM proxy the bundled proxy-provider extension points Pi at.
* Defaults to the Shopify proxy so local behavior matches the auto-discovered
* `~/.pi/agent` extension; override in deployments (e.g. Cloud Run) to target a
* different proxy. Mirrored here for logging; the extension reads it directly.
* Override in deployments to target the proxy available in that environment.
* Mirrored here for logging; the extension reads it directly.
*/
export const PI_PROXY_URL =
process.env.PI_PROXY_URL ?? "https://proxy.shopify.ai";
export const PI_PROXY_URL = readAbsoluteUrl(
"PI_PROXY_URL",
"https://proxy.example.com",
);

/**
* Provider/model Pi is pinned to when spawned. In the container there is no
Expand Down Expand Up @@ -136,10 +147,11 @@ export const AUTH_JWT_TOKEN_COOKIE_NAME =
process.env.AUTH_JWT_TOKEN_COOKIE_NAME ?? "";

/**
* Base URL of the Tangle (Cloud Pipelines) API reached by the bundle-UI/agent
* egress allowlist. The OpenAPI doc declares no `servers`, so this is supplied
* per environment: defaults to the local dev server and is overridden in
* production. Only the origin is used when matching egress destinations.
* Base URL of the Tangle API reached by the bundle-UI/agent egress allowlist.
* The OpenAPI doc declares no `servers`, so this is supplied per environment.
* Only the origin is used when matching egress destinations.
*/
export const TANGLE_API_URL =
process.env.TANGLE_API_URL ?? "https://oasis.shopify.io";
export const TANGLE_API_URL = readAbsoluteUrl(
"TANGLE_API_URL",
"https://api.example.com",
);
Loading
Loading