diff --git a/docs/public-demo.md b/docs/public-demo.md index dd9efad..b4266cd 100644 --- a/docs/public-demo.md +++ b/docs/public-demo.md @@ -27,6 +27,9 @@ browser / tress attach / tress connect for the model calls. This is separate from the Harness key: Harness stores and shares the thread; the Tress host runs the agent and pays for inference. The current host expects `ANTHROPIC_API_KEY`. + If the key is not scoped to a workspace, also configure + `ANTHROPIC_WORKSPACE_ID` with the `wrkspc_...` ID from Anthropic's Workspaces + settings. This is separate from `HARNESS_WORKSPACE`. 3. Provision a PostgreSQL database that the site can reach. The site uses it for visitor/session ownership, access-token hashes, usage limits, hosted virtual files, and connected-device jobs. Harness—not PostgreSQL—stores the diff --git a/site/README.md b/site/README.md index 5a7fc34..0d5960e 100644 --- a/site/README.md +++ b/site/README.md @@ -29,6 +29,12 @@ a model; the default is `claude-sonnet-5`. Keys stay on the server. Without a ke the playground explains the setup and disables sending; it does not simulate a live agent. +For an Anthropic personal or service-account key that is not scoped to a +workspace, also set `ANTHROPIC_WORKSPACE_ID=wrkspc_...` on the host. Use the ID +from the Anthropic Console's Workspaces page. The host sends it on model and +diagnostic requests. Workspace-scoped keys can omit it. For Vercel, configure +both variables in the intended environment and redeploy to activate them. + Install the Rust `wasm32-unknown-unknown` target and `wasm-bindgen-cli` matching the `wasm-bindgen` version in `Cargo.lock`. Starting the site automatically builds the Rust engine and generates its bundler bindings: diff --git a/site/scripts/health-policy.test.mjs b/site/scripts/health-policy.test.mjs index 316f3b8..0ea8f45 100644 --- a/site/scripts/health-policy.test.mjs +++ b/site/scripts/health-policy.test.mjs @@ -40,6 +40,7 @@ const { } = await import(output.href); const keys = [ "ANTHROPIC_API_KEY", + "ANTHROPIC_WORKSPACE_ID", "TRESS_MODEL", "TRESS_API_URL", "HARNESS_API_KEY", @@ -80,6 +81,7 @@ const store = (name) => createFileThreadStore(join(root, name)); const request = () => new Request("https://host.example/api/health"); test("health uses host credentials, closes streams, and never generates a completion", async () => { + process.env.ANTHROPIC_WORKSPACE_ID = "wrkspc_health_fixture"; const calls = []; let closed = 0; const result = await checkHost( @@ -91,13 +93,16 @@ test("health uses host credentials, closes streams, and never generates a comple assert.equal(options.method, undefined); assert.equal(options.redirect, "error"); assert(options.signal); - if (url.hostname === "provider.example") + if (url.hostname === "provider.example") { assert.equal(options.headers["x-api-key"], "fake-model-secret"); - else + assert.equal(options.headers["anthropic-workspace-id"], "wrkspc_health_fixture"); + } else { + assert.equal(options.headers["anthropic-workspace-id"], undefined); assert.equal( options.headers.Authorization, "Bearer fake-harness-secret", ); + } return new Response( new ReadableStream({ cancel() { @@ -112,6 +117,45 @@ test("health uses host credentials, closes streams, and never generates a comple assert(calls.some((url) => url.endsWith("/v1/models/fixture-model"))); assert(calls.some((url) => url.includes("/threads/fixture~"))); assert(!JSON.stringify(result).includes("secret")); + assert(!JSON.stringify(result).includes("wrkspc_health_fixture")); +}); + +test("browser model requests and probes use only the host's optional Anthropic workspace", async (t) => { + const storeCache = globalThis[Symbol.for("tress.demo.thread-store.v4")]; + const previousStore = storeCache.store; + t.after(() => { storeCache.store = previousStore; }); + const routeOutput = new URL(`../.tress/anthropic-route-${process.pid}.mjs`, import.meta.url); + await build({ + entryPoints: [new URL("../src/app/api/messages/route.ts", import.meta.url).pathname], + outfile: routeOutput.pathname, bundle: true, packages: "external", platform: "node", format: "esm", + }); + t.after(() => rm(routeOutput, { force: true })); + const { POST } = await import(routeOutput.href); + process.env.TRESS_DEMO_SHARED = "1"; + const calls = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + calls.push({ url, headers: options.headers }); + return new Response("data: fixture\n\n", { headers: { "content-type": "text/event-stream" } }); + }); + for (const workspace of [undefined, " wrkspc_server_fixture ", " "]) { + if (workspace === undefined) delete process.env.ANTHROPIC_WORKSPACE_ID; + else process.env.ANTHROPIC_WORKSPACE_ID = workspace; + for (const probe of [false, true]) { + const response = await POST(new Request("https://host.example/api/messages", { + method: "POST", + headers: { "content-type": "application/json", "anthropic-workspace-id": "wrkspc_client_must_not_override" }, + body: JSON.stringify({ probe, messages: [{ role: "user", content: "fixture" }] }), + })); + assert.equal(response.status, 200); + const call = calls.at(-1); + assert.equal(call.url, "https://api.anthropic.com/v1/messages"); + assert.equal(call.headers["x-api-key"], "fake-model-secret"); + assert.equal(call.headers["anthropic-workspace-id"], workspace?.trim() || undefined); + assert.equal(response.headers.get("anthropic-workspace-id"), null); + assert(!((await response.text()).includes("fake-model-secret"))); + } + } + assert.equal(calls.length, 6); }); test("health distinguishes database failure, rejected Harness access, and unsupported model metadata", async () => { diff --git a/site/scripts/verify-health-policy.mjs b/site/scripts/verify-health-policy.mjs index c75bb69..74f146e 100644 --- a/site/scripts/verify-health-policy.mjs +++ b/site/scripts/verify-health-policy.mjs @@ -23,6 +23,7 @@ let generations = 0; let metadataChecks = 0; const model = createServer(async (request, response) => { assert.equal(request.headers["x-api-key"], "mock-host-key"); + assert.equal(request.headers["anthropic-workspace-id"], "wrkspc_host_fixture"); if (request.method === "GET" && request.url === "/v1/models/test-model") { metadataChecks++; response.writeHead(200, { "Content-Type": "application/json" }); @@ -73,6 +74,7 @@ try { PORT: String(port), HOST: "127.0.0.1", ANTHROPIC_API_KEY: "mock-host-key", + ANTHROPIC_WORKSPACE_ID: "wrkspc_host_fixture", TRESS_API_URL: `http://127.0.0.1:${model.address().port}/v1/messages`, TRESS_MODEL: "test-model", TRESS_THREAD_MODE: "local", diff --git a/site/src/app/api/messages/route.ts b/site/src/app/api/messages/route.ts index d196081..42f1e82 100644 --- a/site/src/app/api/messages/route.ts +++ b/site/src/app/api/messages/route.ts @@ -10,6 +10,7 @@ import { sessionResponse, } from "../../../server/demo-session"; import { admitRun } from "../../../server/demo-policy"; +import { anthropicHeaders } from "../../../server/anthropic"; type Block = | { kind: "text"; text: string } @@ -93,8 +94,7 @@ async function keyState(key: string): Promise<"live" | "rejected"> { method: "POST", headers: { "content-type": "application/json", - "x-api-key": key, - "anthropic-version": "2023-06-01", + ...anthropicHeaders(key), }, body: JSON.stringify({ model: "claude-sonnet-5", @@ -204,8 +204,7 @@ export async function POST(request: Request) { method: "POST", headers: { "content-type": "application/json", - "x-api-key": key, - "anthropic-version": "2023-06-01", + ...anthropicHeaders(key), }, body, }); diff --git a/site/src/server/agent.ts b/site/src/server/agent.ts index 0a08f8a..71f7aab 100644 --- a/site/src/server/agent.ts +++ b/site/src/server/agent.ts @@ -6,6 +6,7 @@ import { type WorkspaceToolsOptions, } from "@tress/workspaces"; import { workspaceConfig } from "./workspace"; +import { anthropicHeaders } from "./anthropic"; export const openSession = async ( workspace: Workspace, @@ -37,7 +38,7 @@ export const openSession = async ( workspace, url: process.env.TRESS_API_URL ?? "https://api.anthropic.com/v1/messages", model: process.env.TRESS_MODEL ?? "claude-sonnet-5", - headers: { "x-api-key": key, "anthropic-version": "2023-06-01" }, + headers: anthropicHeaders(key), system: `You are tress, a coding agent working in a shared workspace. Browser and terminal clients share this conversation and its files. Answer questions directly. Only inspect or change files when needed for the user's request. ${workspace.filesystemRoot ? `Paths may be absolute under filesystem root ${JSON.stringify(workspace.filesystemRoot)} or relative to that root.` : "Paths are relative to the workspace root."} ${connected ? "Files are served by a user-approved native folder. No shell or project test runner is available in this connection." : nativeSandboxShell ? "Commands run inside a sandbox, with a timeout. Installed runtimes and dependencies belong to that sandbox." : nativeDeviceShell ? "Commands run on the host device under its configured tool policy. Only claim a command ran when the tool confirms it." : "The shell is just-bash: simulated file and text commands, not a native operating system. Node, npm, git, and project test runners are not available. Never claim tests ran unless a tool actually ran them."} diff --git a/site/src/server/anthropic.ts b/site/src/server/anthropic.ts new file mode 100644 index 0000000..d645497 --- /dev/null +++ b/site/src/server/anthropic.ts @@ -0,0 +1,9 @@ +/** Server-owned authentication for both workspace keys and personal keys. */ +export const anthropicHeaders = (key: string): Record => { + const workspace = process.env.ANTHROPIC_WORKSPACE_ID?.trim(); + return { + "x-api-key": key, + "anthropic-version": "2023-06-01", + ...(workspace ? { "anthropic-workspace-id": workspace } : {}), + }; +}; diff --git a/site/src/server/health.ts b/site/src/server/health.ts index 9317bbd..562ad4e 100644 --- a/site/src/server/health.ts +++ b/site/src/server/health.ts @@ -7,6 +7,7 @@ import { import { threadStore, type ThreadStore } from "./thread-store"; import { managedBackendUrl, managedStreamHeaders } from "./managed-backend"; import { demoLimits } from "./demo-policy"; +import { anthropicHeaders } from "./anthropic"; type Check = { name: string; @@ -112,7 +113,7 @@ export const checkHost = async ( return await probe( "Model", url, - { "x-api-key": key, "anthropic-version": "2023-06-01" }, + anthropicHeaders(key), fetcher, ); } catch {