From 4b07199ab74ef39d4f4058b718243a31ff631c6f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 30 Sep 2026 00:07:00 -0700 Subject: [PATCH 1/2] fix(deploy): use the cloud workspace id for cloud API calls resolveWorkspaceToken returned the relaycast workspace id as `workspace`, so after `agentworkforce login` every cloud call built from it (deployments list, destroy, deploy, runs, trigger, integrations, runtime-credentials, local-surface) hit /api/v1/workspaces//... and got 403 Forbidden. - Return descriptor.cloudWorkspaceId as `workspace`; expose the relaycast id separately as `relaycastWorkspaceId`. - Drop the silent cloudWorkspaceId -> relaycastWorkspaceId fallback in the resolve normalizer. A missing cloud id (or one that is really a relay id, as @agent-relay/cloud's own normalizer falls back to) now fails with an actionable error. - local-surface looks up fleet node enrollments by the relaycast id (they are keyed by relayWorkspaceId) and uses the cloud id elsewhere. - Reject a relaycast workspace key (rk_...) in WORKFORCE_WORKSPACE_TOKEN up front; cloud APIs only accept cloud bearers and it 401s later. - `agentworkforce login` prints the cloud workspace id, warns when absent. - Docs: explain cloud vs relaycast ids and the correct token source for the WORKFORCE_WORKSPACE_ID/TOKEN override. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 17 ++ docs/customers/proactive-agents-onboarding.md | 2 +- examples/weekly-digest/README.md | 9 +- packages/cli/README.md | 41 +++++ packages/cli/src/deploy-command.test.ts | 6 + packages/cli/src/deploy-command.ts | 11 ++ .../cli/src/local-surface-command.test.ts | 49 ++++++ packages/cli/src/local-surface-command.ts | 8 +- packages/deploy/src/login.test.ts | 162 +++++++++++++++++- packages/deploy/src/login.ts | 85 ++++++++- packages/mcp-workforce/README.md | 2 +- 11 files changed, 383 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d10f8d4f..017ff7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `deploy`, `deployments list`, `destroy`, `runs`, `trigger` and `local-surface` + now address cloud APIs with the cloud workspace id instead of the relaycast + workspace id, which cloud rejected with `403 Forbidden` after + `agentworkforce login`. When the cloud returns no cloud workspace id for a + workspace, the CLI now stops with an actionable error instead of silently + falling back to the relaycast id. +- `WORKFORCE_WORKSPACE_TOKEN` set to a relaycast workspace key (`rk_…` from + `workspaces.json`) is rejected up front with guidance instead of failing + later with `401`. + +### Added + +- `agentworkforce login` prints the cloud workspace id, and warns when the + chosen workspace has none. + ## [4.1.51] - 2026-08-31 ### Added diff --git a/docs/customers/proactive-agents-onboarding.md b/docs/customers/proactive-agents-onboarding.md index 8b46645f..37eee0b7 100644 --- a/docs/customers/proactive-agents-onboarding.md +++ b/docs/customers/proactive-agents-onboarding.md @@ -20,7 +20,7 @@ Then sign in once: agentworkforce login ``` -The login command opens a browser, completes PKCE auth, lets you choose a workspace, and stores the workspace-scoped deploy token in keychain-backed storage. +The login command opens a browser, signs you in to Agent Relay cloud, lets you choose a workspace, and prints its cloud workspace id. Later `deploy`, `deployments list` and `destroy` commands reuse that session automatically, so you do not need to set `WORKFORCE_WORKSPACE_ID` or `WORKFORCE_WORKSPACE_TOKEN`. If you do set them (for CI), the id must be the cloud workspace id (UUID), not the `rw_…` relaycast id, and the token must be a cloud access token, not the `rk_live_…` key from `workspaces.json` — see the CLI README's "Login and workspace ids" section. ## Configure The Persona diff --git a/examples/weekly-digest/README.md b/examples/weekly-digest/README.md index 62395bf7..3ddbd228 100644 --- a/examples/weekly-digest/README.md +++ b/examples/weekly-digest/README.md @@ -25,9 +25,12 @@ export WEEKLY_DIGEST_TOPICS="agentworkforce,relayfile,proactive-agents" export WEEKLY_DIGEST_REPO="YourOrg/weekly-digest" export BRAVE_API_KEY="brave_..." -# Workspace (only needed when actually launching, not for --dry-run): -export WORKFORCE_WORKSPACE_ID="ws_demo" -export WORKFORCE_WORKSPACE_TOKEN="ws_token_..." +# Workspace auth (only needed when actually launching, not for --dry-run). +# Locally, just run `agentworkforce login`. For CI, set both: +# WORKFORCE_WORKSPACE_ID = cloud workspace id (UUID printed by login), not the rw_… id +# WORKFORCE_WORKSPACE_TOKEN = cloud access token, not the rk_live_… workspaces.json key +# export WORKFORCE_WORKSPACE_ID="" +# export WORKFORCE_WORKSPACE_TOKEN="" # Relayfile mount root the handler writes into. The workforce runtime # sets this automatically when it spawns the handler. Only set it diff --git a/packages/cli/README.md b/packages/cli/README.md index 5f7c6885..4fd94878 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,47 @@ corepack pnpm -r build corepack pnpm --filter agentworkforce link --global ``` +## Login and workspace ids + +```sh +agentworkforce login +``` + +`login` signs you in to Agent Relay cloud, lets you pick a workspace, and +prints its **cloud workspace id** (a UUID). After that, `deploy`, +`deployments list`, `destroy`, `runs`, `trigger`, `env` and `integrations` use +the login session automatically — no environment variables are needed. + +A workspace has two different ids, and they are not interchangeable: + +| Id | Looks like | Used for | +|----|------------|----------| +| Cloud workspace id | `0b6c2d4e-…` (UUID) | Workforce cloud APIs (`/api/v1/workspaces//…`): deployments, integrations, env, runtime credentials | +| Relaycast workspace id | `rw_…` | Relaycast messaging and fleet nodes only | + +Using the relaycast id against cloud APIs returns `403 Forbidden`. If the cloud +cannot resolve a cloud workspace id for your workspace, the CLI stops with an +error naming the relaycast id instead of guessing — re-run +`agentworkforce login`, or pass `--workspace `. + +### Non-interactive / CI override + +`WORKFORCE_WORKSPACE_ID` + `WORKFORCE_WORKSPACE_TOKEN` override the login +session when **both** are set (`--workspace` may stand in for the id): + +- `WORKFORCE_WORKSPACE_ID` — the **cloud workspace id** printed by + `agentworkforce login` (not the `rw_…` relaycast id). +- `WORKFORCE_WORKSPACE_TOKEN` — a **cloud API bearer token**, e.g. the + `accessToken` from `~/.agentworkforce/relay/cloud-auth.json` after + `agentworkforce login`. This access token expires; re-run + `agentworkforce login` to refresh it. + +Do **not** use the workspace `key` (`rk_live_…`) from +`~/.agentworkforce/relay/workspaces.json` as `WORKFORCE_WORKSPACE_TOKEN`: that +is a relaycast key, cloud APIs reject it with `401`, and the CLI refuses it up +front. On your own machine, prefer unsetting both variables and relying on +`agentworkforce login`. + ## Discover integrations and triggers ```sh diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index f81d9f30..868ce163 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -118,6 +118,7 @@ test('runLogin uses cloud SDK auth, picks a workspace, and pins the canonical re return new Response(JSON.stringify({ key: 'rk_live_acme', workspaceId: 'rw_1234abcd', + cloudWorkspaceId: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e', relaycastWorkspaceId: 'rw_1234abcd', relayfileWorkspaceId: 'rf_acme', relayauthWorkspaceId: 'ra_acme', @@ -150,6 +151,8 @@ test('runLogin uses cloud SDK auth, picks a workspace, and pins the canonical re ]); assert.deepEqual(pinned, [{ name: 'Acme', key: 'rk_live_acme' }]); assert.match(trap.stdout, /logged in: Acme/); + assert.match(trap.stdout, /cloud workspace id: 0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e/); + assert.doesNotMatch(trap.stderr, /no linked cloud workspace/); } finally { trap.restore(); restoreDeps(); @@ -215,6 +218,9 @@ test('runLogin with --workspace skips the workspaces list and pins the resolved key: 'rk_live_direct' }]); assert.match(trap.stdout, /logged in: 50587328-441d-4acb-b8f3-dbe1b3c5de99/); + // The resolve payload above has no cloudWorkspaceId: warn instead of + // letting later cloud calls fail with an opaque 403. + assert.match(trap.stderr, /workspace rw_5678abcd has no linked cloud workspace/); } finally { trap.restore(); restoreDeps(); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index 06bb60af..f085ecc0 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -217,6 +217,14 @@ export async function runLogin(args: readonly string[]): Promise { const workspaceName = descriptor.name ?? descriptor.slug ?? match?.slug ?? match?.name ?? chosen; await deployCommandDeps.setWorkspaceKey(workspaceName, descriptor.key); process.stdout.write(`\nlogged in: ${workspaceName}\n`); + if (descriptor.cloudWorkspaceId) { + process.stdout.write(`cloud workspace id: ${descriptor.cloudWorkspaceId}\n`); + } else { + process.stderr.write( + `warn: workspace ${descriptor.relaycastWorkspaceId} has no linked cloud workspace; ` + + 'deploy, deployments list and destroy will fail until it is linked. Contact support with this id.\n' + ); + } process.exit(0); } catch (err) { process.stderr.write( @@ -546,6 +554,7 @@ type LoginWorkspace = { type LoginWorkspaceDescriptor = { key: string; relaycastWorkspaceId: string; + cloudWorkspaceId?: string; name?: string; slug?: string; }; @@ -593,9 +602,11 @@ async function resolveWorkspaceForLogin( if (!key || !relaycastWorkspaceId) { throw new Error('workspace resolve returned an incomplete descriptor'); } + const cloudWorkspaceId = readString(record, 'cloudWorkspaceId'); return { key, relaycastWorkspaceId, + ...(cloudWorkspaceId ? { cloudWorkspaceId } : {}), ...(readString(record, 'name') ? { name: readString(record, 'name') } : {}), ...(readString(record, 'slug') ? { slug: readString(record, 'slug') } : {}) }; diff --git a/packages/cli/src/local-surface-command.test.ts b/packages/cli/src/local-surface-command.test.ts index 16a55eed..aa5e836f 100644 --- a/packages/cli/src/local-surface-command.test.ts +++ b/packages/cli/src/local-surface-command.test.ts @@ -167,6 +167,55 @@ test('runLocalSurface resolves the deployed persona UUID, reuses a persisted enr } }); +test('runLocalSurface uses the cloud workspace id for cloud APIs and the relaycast id for fleet enrollment', async () => { + const enrollmentLookups: unknown[] = []; + const deploymentWorkspaces: string[] = []; + let capturedLocalSurfaceBody: unknown; + const { writes, restore } = withMockedDeps({ + resolveWorkspaceToken: (async () => ({ + token: 'tok_workspace', + workspace: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e', + relaycastWorkspaceId: 'rw_1234abcd' + })) as never, + fetchDeployments: (async (args: { workspace: string }) => { + deploymentWorkspaces.push(args.workspace); + return [deployedAgent()]; + }) as never, + resolveActiveFleetNodeEnrollment: ((input: unknown) => { + enrollmentLookups.push(input); + return { + nodeId: 'node_1', + nodeName: 'my-laptop', + nodeToken: 'nt_live_abc', + relayWorkspaceId: 'rw_1234abcd', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'wss://relaycast.example.com/v1/node/ws', + enrolledAt: '2026-07-01T00:00:00.000Z' + }; + }) as never, + fetch: (async (_url: string, init: { body?: string }) => { + capturedLocalSurfaceBody = JSON.parse(init.body ?? '{}'); + return fakeResponse({ ok: true, json: { channel: 'local-surface-demo-persona' } }); + }) as never + }); + + try { + process.exitCode = undefined; + await runLocalSurface(['/personas/demo.json']); + assert.equal(process.exitCode, 0); + assert.deepEqual(deploymentWorkspaces, ['0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e']); + assert.deepEqual(enrollmentLookups, [{ workspaceId: 'rw_1234abcd' }]); + assert.deepEqual(capturedLocalSurfaceBody, { + workspaceId: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e', + personaId: 'persona-uuid-1' + }); + assert.ok(writes[0]!.contents.includes('"0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e"')); + } finally { + restore(); + process.exitCode = undefined; + } +}); + test('runLocalSurface fails loud (does not proceed) when the persona has no active cloud deployment', async () => { const { errors, logs, restore } = withMockedDeps({ fetchDeployments: (async () => []) as never diff --git a/packages/cli/src/local-surface-command.ts b/packages/cli/src/local-surface-command.ts index 1007c0b4..3972e635 100644 --- a/packages/cli/src/local-surface-command.ts +++ b/packages/cli/src/local-surface-command.ts @@ -255,7 +255,13 @@ async function runLocalSurfaceWithOptions(opts: LocalSurfaceOptions): Promise = {}): Record { + return { + relaycastApiKey: 'rk_live_descriptorkey', + workspaceId: 'rw_1234abcd', + cloudWorkspaceId: CLOUD_WORKSPACE_ID, + relaycastWorkspaceId: 'rw_1234abcd', + relayfileWorkspaceId: 'rf_1234abcd', + relayauthWorkspaceId: 'ra_1234abcd', + name: 'Acme', + urls: { + relaycastUrl: 'https://relaycast.example.test', + relayfileUrl: 'https://relayfile.example.test', + relayauthUrl: 'https://relayauth.example.test' + }, + ...overrides + }; +} + +function withResolveFetch(descriptor: Record, requests: string[]): () => void { + return withTrappedFetch(async (input) => { + const url = input instanceof Request ? input.url : String(input); + requests.push(url); + if (/\/api\/v1\/workspaces\/[^/]+\/resolve$/.test(new URL(url).pathname)) { + return Response.json(descriptor); + } + return new Response('unexpected request', { status: 500 }); + }); +} + +async function withActiveWorkspaceStore(key: string, fn: () => Promise): Promise { + const home = mkdtempSync(path.join(os.tmpdir(), 'wf-active-workspace-')); + const previous = process.env.AGENT_RELAY_HOME; + mkdirSync(home, { recursive: true }); + writeFileSync( + path.join(home, 'workspaces.json'), + JSON.stringify({ active: 'acme', workspaces: { acme: { key } } }) + ); + process.env.AGENT_RELAY_HOME = home; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previous; + rmSync(home, { recursive: true, force: true }); + } +} + +test('resolveWorkspaceToken returns the cloud workspace id (not the relaycast id) for an explicit workspace', async () => { + await withCloudSessionEnv(async () => { + const requests: string[] = []; + const restoreFetch = withResolveFetch(resolveDescriptor(), requests); + try { + const auth = await resolveWorkspaceToken({ + workspace: 'acme', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }); + assert.equal(auth.workspace, CLOUD_WORKSPACE_ID); + assert.equal(auth.relaycastWorkspaceId, 'rw_1234abcd'); + assert.equal(auth.relayfileWorkspaceId, 'rf_1234abcd'); + assert.equal(auth.token, 'cloud-access'); + assert.equal(auth.authSource, 'cloud-session'); + assert.ok(requests.some((url) => url.endsWith('/api/v1/workspaces/acme/resolve'))); + } finally { + restoreFetch(); + } + }); +}); + +test('resolveWorkspaceToken returns the cloud workspace id for the active workspace', async () => { + await withCloudSessionEnv(() => withActiveWorkspaceStore('rk_live_activekey', async () => { + const requests: string[] = []; + const restoreFetch = withResolveFetch(resolveDescriptor(), requests); + try { + const auth = await resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }); + assert.equal(auth.workspace, CLOUD_WORKSPACE_ID); + assert.equal(auth.relaycastWorkspaceId, 'rw_1234abcd'); + assert.equal(auth.token, 'cloud-access'); + } finally { + restoreFetch(); + } + })); +}); + +test('resolveWorkspaceToken fails loudly when resolve omits the cloud workspace id', async () => { + await withCloudSessionEnv(async () => { + const restoreFetch = withResolveFetch(resolveDescriptor({ cloudWorkspaceId: null }), []); + try { + await assert.rejects( + resolveWorkspaceToken({ + workspace: 'acme', + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + (error: Error) => { + assert.match(error.message, /workspace "acme" resolved to relaycast workspace rw_1234abcd/); + assert.match(error.message, /cloud workspace id/); + assert.match(error.message, /agentworkforce login/); + assert.match(error.message, /--workspace /); + return true; + } + ); + } finally { + restoreFetch(); + } + }); +}); + +test('resolveWorkspaceToken rejects an active descriptor whose cloud id fell back to the relay id', async () => { + // @agent-relay/cloud's normalizer falls back to `workspaceId` (the relay + // workspace id) when the cloud returns `cloudWorkspaceId: null`. + await withCloudSessionEnv(() => withActiveWorkspaceStore('rk_live_activekey', async () => { + const restoreFetch = withResolveFetch( + resolveDescriptor({ cloudWorkspaceId: null, workspaceId: 'rw_9999ffff' }), + [] + ); + try { + await assert.rejects( + resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + /the active workspace "Acme" resolved to relaycast workspace rw_1234abcd.*cloud workspace id/s + ); + } finally { + restoreFetch(); + } + })); +}); + +test('resolveWorkspaceToken rejects a relaycast workspace key in WORKFORCE_WORKSPACE_TOKEN', async () => { + await withWorkspaceEnv({ + workspaceId: CLOUD_WORKSPACE_ID, + workspaceToken: 'rk_live_notacloudtoken' + }, async () => { + await assert.rejects( + resolveWorkspaceToken({ + cloudUrl: 'https://cloud.example.test', + io: createBufferedIO(), + noPrompt: true + }), + (error: Error) => { + assert.match(error.message, /relaycast workspace key/); + assert.match(error.message, /cloud-auth\.json/); + assert.doesNotMatch(error.message, /rk_live_notacloudtoken/); + return true; + } + ); + }); +}); + test('resolveWorkspaceToken without a cloud session fails with login guidance', () => { const home = mkdtempSync(path.join(os.tmpdir(), 'wf-no-cloud-session-')); const env: NodeJS.ProcessEnv = { diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index b0d4229b..a4ce49e9 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -24,7 +24,17 @@ export interface WorkspaceAuth { export interface WorkspaceAuthToken { token: string; + /** + * Workspace id for workforce cloud API paths + * (`/api/v1/workspaces//...`). When resolved from the cloud + * session this is the canonical cloud workspace id, never the relaycast id. + */ workspace?: string; + /** + * Relaycast workspace id, for relaycast/fleet lookups only (e.g. fleet node + * enrollments keyed by `relayWorkspaceId`). Never use it in cloud API paths. + */ + relaycastWorkspaceId?: string; relayfileWorkspaceId?: string; workspaceDescriptor?: ActiveWorkspaceDescriptor; authSource?: 'env' | 'cloud-session'; @@ -107,6 +117,7 @@ export function resolveWorkspaceTokenFromEnv(workspace: string): WorkspaceAuthTo `no workspace token resolved for ${workspace}: run \`agent-relay login\` or set WORKFORCE_WORKSPACE_TOKEN` ); } + assertCloudBearerToken(token); return { token }; } @@ -122,6 +133,7 @@ export async function resolveWorkspaceToken(args: { const requestedWorkspace = (args.workspace ?? '').trim(); if (envToken && (requestedWorkspace || envWorkspace)) { + assertCloudBearerToken(envToken); return { token: envToken, workspace: requestedWorkspace || envWorkspace, @@ -140,15 +152,82 @@ export async function resolveWorkspaceToken(args: { apiUrl: session.auth.apiUrl || cloudUrl }); + // Cloud API routes (`/api/v1/workspaces//deployments`, integrations, + // runtime-credentials, ...) are scoped by the cloud workspace id. Sending + // the relaycast id there is rejected with 403 Forbidden. + const cloudWorkspaceId = requireCloudWorkspaceId(descriptor, requestedWorkspace || envWorkspace); + return { token: session.auth.accessToken, - workspace: descriptor.relaycastWorkspaceId, + workspace: cloudWorkspaceId, + relaycastWorkspaceId: descriptor.relaycastWorkspaceId, relayfileWorkspaceId: descriptor.relayfileWorkspaceId, workspaceDescriptor: descriptor, authSource: "cloud-session" }; } +const RELAYCAST_WORKSPACE_KEY_PATTERN = /^rk_[a-z]+_/; + +/** + * WORKFORCE_WORKSPACE_TOKEN must be a cloud API bearer (the `accessToken` of + * the `agentworkforce login` / `agent-relay login` session). The relaycast + * workspace key stored in workspaces.json (`rk_live_...`) is not accepted by + * cloud APIs and only produces a 401 later, so reject it up front. + */ +function assertCloudBearerToken(token: string): void { + if (!RELAYCAST_WORKSPACE_KEY_PATTERN.test(token)) return; + throw new Error( + 'WORKFORCE_WORKSPACE_TOKEN is a relaycast workspace key (rk_...), which workforce cloud APIs reject. ' + + 'Unset WORKFORCE_WORKSPACE_TOKEN and WORKFORCE_WORKSPACE_ID to use your `agentworkforce login` session, ' + + 'or set WORKFORCE_WORKSPACE_TOKEN to a cloud access token (e.g. the `accessToken` in ' + + '~/.agentworkforce/relay/cloud-auth.json) and WORKFORCE_WORKSPACE_ID to the cloud workspace id.' + ); +} + +const RELAY_WORKSPACE_ID_PATTERN = /^rw_[A-Za-z0-9]+$/; + +/** + * Return the descriptor's cloud workspace id, or fail with an actionable + * error. The cloud resolve endpoint returns `cloudWorkspaceId: null` when a + * relay workspace has no cloud workspace binding, and older descriptor + * normalizers (including @agent-relay/cloud's) then fall back to the + * relaycast/relay workspace id. Treat that fallback as missing rather than + * silently calling cloud APIs with an id they reject. + */ +function requireCloudWorkspaceId( + descriptor: ActiveWorkspaceDescriptor, + requestedWorkspace: string +): string { + const cloudWorkspaceId = descriptor.cloudWorkspaceId?.trim() ?? ''; + const relayIds = new Set( + [ + descriptor.relaycastWorkspaceId, + descriptor.relayfileWorkspaceId, + descriptor.relayauthWorkspaceId + ] + .map((id) => id?.trim()) + .filter((id): id is string => Boolean(id)) + ); + if ( + cloudWorkspaceId + && !relayIds.has(cloudWorkspaceId) + && !RELAY_WORKSPACE_ID_PATTERN.test(cloudWorkspaceId) + ) { + return cloudWorkspaceId; + } + const label = requestedWorkspace + ? `workspace "${requestedWorkspace}"` + : `the active workspace${descriptor.name ? ` "${descriptor.name}"` : ''}`; + throw new Error( + `${label} resolved to relaycast workspace ${descriptor.relaycastWorkspaceId} but the cloud did not return ` + + 'its Agent Workforce cloud workspace id, which cloud APIs (deployments, integrations, env) require. ' + + 'Run `agentworkforce login` and pick the workspace again (it prints the cloud workspace id), or pass ' + + '`--workspace `. If this persists, the relay workspace is not linked to a cloud ' + + 'workspace — contact support with the relaycast workspace id above.' + ); +} + async function resolveWorkspaceDescriptor(args: { requestedWorkspace?: string; apiUrl: string; @@ -224,7 +303,9 @@ function normalizeWorkspaceDescriptor(payload: unknown, apiUrl: string): ActiveW } return { key, - cloudWorkspaceId: readString(record, 'cloudWorkspaceId') ?? relaycastWorkspaceId, + // No fallback to the relaycast id: an absent cloudWorkspaceId is surfaced + // by requireCloudWorkspaceId() with an actionable error. + cloudWorkspaceId: readString(record, 'cloudWorkspaceId') ?? '', relaycastWorkspaceId, ...(readString(record, 'relaycastApiKey') ? { relaycastApiKey: readString(record, 'relaycastApiKey') } diff --git a/packages/mcp-workforce/README.md b/packages/mcp-workforce/README.md index b6356a72..288db2ff 100644 --- a/packages/mcp-workforce/README.md +++ b/packages/mcp-workforce/README.md @@ -52,7 +52,7 @@ spawns a harness. Personas that want to declare it manually can use: | Env var | Purpose | Required when | |---|---|---| -| `WORKFORCE_WORKSPACE_ID` | Workspace this server is bound to | always | +| `WORKFORCE_WORKSPACE_ID` | Cloud workspace id (UUID printed by `agentworkforce login`, not the `rw_…` relaycast id) this server is bound to | always | | `WORKFORCE_PERSONA_ID` | Persona id (logged for audit) | optional | | `WORKFORCE_RUNTIME_TOKEN` | Workspace-scoped token for cloud API calls | `workflow.*` | | `WORKFORCE_CLOUD_URL` | Override cloud base URL | optional | From 04a4659217aa5f0e37879c34af23bee9666ec320 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Wed, 30 Sep 2026 00:20:06 -0700 Subject: [PATCH 2/2] fix(cli): resolve the relaycast id for env-auth local-surface enrollment lookup Env-override auth (WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN) carries only the cloud workspace id; fleet enrollments are keyed by the relaycast id. Resolve it via /api/v1/workspaces//resolve before the lookup, falling back to the given id. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/src/local-surface-command.test.ts | 44 +++++++++++++++++++ packages/cli/src/local-surface-command.ts | 35 ++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/local-surface-command.test.ts b/packages/cli/src/local-surface-command.test.ts index aa5e836f..c5f9ff34 100644 --- a/packages/cli/src/local-surface-command.test.ts +++ b/packages/cli/src/local-surface-command.test.ts @@ -216,6 +216,50 @@ test('runLocalSurface uses the cloud workspace id for cloud APIs and the relayca } }); +test('runLocalSurface resolves the relaycast id for env-override auth before the fleet enrollment lookup', async () => { + const enrollmentLookups: unknown[] = []; + const requests: Array<{ url: string; method?: string }> = []; + const { restore } = withMockedDeps({ + // WORKFORCE_WORKSPACE_ID + WORKFORCE_WORKSPACE_TOKEN: no relaycast id. + resolveWorkspaceToken: (async () => ({ + token: 'tok_workspace', + workspace: '0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e', + authSource: 'env' + })) as never, + resolveActiveFleetNodeEnrollment: ((input: unknown) => { + enrollmentLookups.push(input); + return { + nodeId: 'node_1', + nodeName: 'my-laptop', + nodeToken: 'nt_live_abc', + relayWorkspaceId: 'rw_1234abcd', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'wss://relaycast.example.com/v1/node/ws', + enrolledAt: '2026-07-01T00:00:00.000Z' + }; + }) as never, + fetch: (async (url: string, init: { method?: string }) => { + requests.push({ url, method: init.method }); + if (url.endsWith('/resolve')) { + return fakeResponse({ ok: true, json: { relaycastWorkspaceId: 'rw_1234abcd' } }); + } + return fakeResponse({ ok: true, json: { channel: 'local-surface-demo-persona' } }); + }) as never + }); + + try { + process.exitCode = undefined; + await runLocalSurface(['/personas/demo.json']); + assert.equal(process.exitCode, 0); + assert.ok(requests.some((r) => + r.url.endsWith('/api/v1/workspaces/0b6c2d4e-1f3a-4b5c-8d7e-9f0a1b2c3d4e/resolve') && r.method === 'GET')); + assert.deepEqual(enrollmentLookups, [{ workspaceId: 'rw_1234abcd' }]); + } finally { + restore(); + process.exitCode = undefined; + } +}); + test('runLocalSurface fails loud (does not proceed) when the persona has no active cloud deployment', async () => { const { errors, logs, restore } = withMockedDeps({ fetchDeployments: (async () => []) as never diff --git a/packages/cli/src/local-surface-command.ts b/packages/cli/src/local-surface-command.ts index 3972e635..ee08f757 100644 --- a/packages/cli/src/local-surface-command.ts +++ b/packages/cli/src/local-surface-command.ts @@ -257,8 +257,10 @@ async function runLocalSurfaceWithOptions(opts: LocalSurfaceOptions): Promise { + const url = `${input.cloudUrl.replace(/\/+$/, '')}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/resolve`; + try { + const response = await deps.fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${input.token}`, + 'user-agent': 'workforce-local-surface' + } + }); + if (!response.ok) return input.workspace; + const body = (await response.json().catch(() => null)) as { relaycastWorkspaceId?: unknown } | null; + const relaycastWorkspaceId = body?.relaycastWorkspaceId; + return typeof relaycastWorkspaceId === 'string' && relaycastWorkspaceId.trim() + ? relaycastWorkspaceId.trim() + : input.workspace; + } catch { + return input.workspace; + } +} + async function callLocalSurfaceApi(input: { cloudUrl: string; token: string;