diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 7ed6201a6..4467da956 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -8,7 +8,7 @@ vi.mock('@agent-relay/cloud', () => ({ switchWorkspace: vi.fn(), })); -import { resolveActiveWorkspace } from '@agent-relay/cloud'; +import { resolveActiveWorkspace, setWorkspaceKey } from '@agent-relay/cloud'; import { registerWorkspaceCommands, type WorkspaceCommandDependencies } from './workspace.js'; @@ -83,4 +83,47 @@ describe('registerWorkspaceCommands', () => { apiUrl: 'https://cloud.test', }); }); + + // Regression coverage for AgentWorkforce/relay#1260: `workspace create` must + // store the Cloud-issued `relaycastApiKey` (which has the Postgres row + // `resolveActiveWorkspace` looks up), not silently store nothing / a key + // from a direct-Relaycast create that would later 404 on `workspace active`. + it('stores the Cloud-issued relaycastApiKey after create and prints it', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ + workspaceId: 'rw_new', + name: 'new-ws', + relaycastApiKey: 'rk_live_new', + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'workspace', + 'create', + 'new-ws', + '--api-url', + 'https://cloud.test', + ]); + + expect(deps.createWorkspace).toHaveBeenCalledWith('new-ws', 'https://cloud.test'); + expect(vi.mocked(setWorkspaceKey)).toHaveBeenCalledWith('new-ws', 'rk_live_new', undefined, 'rw_new'); + expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toEqual({ + name: 'new-ws', + workspaceId: 'rw_new', + workspaceKey: 'rk_live_new', + }); + }); + + it('fails loudly instead of storing an unusable workspace when relaycastApiKey is missing', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ workspaceId: 'rw_new' }); + + await expect( + program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'new-ws']) + ).rejects.toThrow('exit:1'); + + expect(vi.mocked(setWorkspaceKey)).not.toHaveBeenCalled(); + expect(vi.mocked(deps.error)).toHaveBeenCalledWith(expect.stringContaining('missing relaycastApiKey')); + }); }); diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index ded73d2ad..99669c861 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -54,16 +54,27 @@ export function registerWorkspaceCommands( group .command('create') - .description('Create a new workspace and store its key') + .description('Create a new workspace (via Cloud) and store its key') .argument('', 'Workspace name') - .option('--base-url ', 'Override the API base URL') + .option('--api-url ', 'Cloud API base URL') .action(async (name: string, o: Record) => { await runSdk(deps, async () => { - const relay = await deps.createWorkspace(name, o.baseUrl as string | undefined); - if (relay.workspaceKey) { - setWorkspaceKey(name, relay.workspaceKey); + const workspace = await deps.createWorkspace(name, o.apiUrl as string | undefined); + if (!workspace.relaycastApiKey) { + throw new Error( + 'Workspace create response is missing relaycastApiKey — cannot store a usable workspace key. ' + + 'Make sure you are logged in to Cloud (`agent-relay login`).' + ); } - printJson(deps, { name, workspaceKey: relay.workspaceKey }); + // Cache cloudWorkspaceId immediately — don't wait for the first + // resolve — so self-heal is bound to the workspace just created from + // the start, not left unset until a later successful resolve caches it. + setWorkspaceKey(name, workspace.relaycastApiKey, undefined, workspace.workspaceId); + printJson(deps, { + name, + workspaceId: workspace.workspaceId, + workspaceKey: workspace.relaycastApiKey, + }); }); }); diff --git a/packages/cli/src/cli/lib/sdk-command.test.ts b/packages/cli/src/cli/lib/sdk-command.test.ts new file mode 100644 index 000000000..cfa5df380 --- /dev/null +++ b/packages/cli/src/cli/lib/sdk-command.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { createCloudWorkspaceMock, relaySdkCreateWorkspaceMock } = vi.hoisted(() => ({ + createCloudWorkspaceMock: vi.fn(), + relaySdkCreateWorkspaceMock: vi.fn(), +})); + +vi.mock('@agent-relay/cloud', () => ({ + createWorkspace: createCloudWorkspaceMock, +})); + +vi.mock('@agent-relay/sdk', () => ({ + AgentRelay: { createWorkspace: relaySdkCreateWorkspaceMock }, +})); + +vi.mock('./sdk-client.js', () => ({ + createAgentRelay: vi.fn(), + createWorkspaceRelay: vi.fn(), +})); + +import { withSdkDefaults } from './sdk-command.js'; + +// Regression coverage for AgentWorkforce/relay#1260: `workspace create` must go +// through Cloud's unified `/api/v1/workspaces` endpoint (which persists the +// Postgres row `workspace active` looks up), never the direct-Relaycast +// `AgentRelay.createWorkspace` path — a key minted that way is a permanent +// orphan that 404s on every future `workspace active` / `agentworkforce login`. +describe('withSdkDefaults createWorkspace', () => { + it('creates a workspace through @agent-relay/cloud, not the direct-Relaycast SDK', async () => { + createCloudWorkspaceMock.mockResolvedValueOnce({ + workspaceId: 'rw_new', + relaycastApiKey: 'rk_live_new', + }); + + const deps = withSdkDefaults(); + const result = await deps.createWorkspace('new-ws', 'https://cloud.test'); + + expect(createCloudWorkspaceMock).toHaveBeenCalledWith('new-ws', { apiUrl: 'https://cloud.test' }); + expect(relaySdkCreateWorkspaceMock).not.toHaveBeenCalled(); + expect(result).toEqual({ workspaceId: 'rw_new', relaycastApiKey: 'rk_live_new' }); + }); +}); diff --git a/packages/cli/src/cli/lib/sdk-command.ts b/packages/cli/src/cli/lib/sdk-command.ts index f0ad22732..ec93c80bd 100644 --- a/packages/cli/src/cli/lib/sdk-command.ts +++ b/packages/cli/src/cli/lib/sdk-command.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander'; -import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk'; +import type { AgentRelayAgent } from '@agent-relay/sdk'; +import { createWorkspace as createCloudWorkspace, type WorkspaceCreateResponse } from '@agent-relay/cloud'; import { defaultExit } from './exit.js'; import { createAgentRelay, createWorkspaceRelay, type SdkClientOptions } from './sdk-client.js'; @@ -11,7 +12,16 @@ type ExitFn = (code: number) => never; export interface SdkCommandDeps { createAgentRelay: (options?: SdkClientOptions) => AgentRelayAgent; createWorkspaceRelay: (options?: SdkClientOptions) => AgentRelayAgent; - createWorkspace: (name: string, baseUrl?: string) => Promise; + /** + * Creates a workspace through Cloud's unified `/api/v1/workspaces` endpoint + * (NOT the direct-Relaycast `AgentRelay.createWorkspace`/`RelayCast.createWorkspace` + * path) so the returned `relaycastApiKey` has the Cloud Postgres row that + * `workspace active` / `resolveActiveWorkspace` requires. A key minted + * directly against Relaycast is valid there but is an orphan to Cloud — + * `workspace active` on it 404s with "Workspace not found" even though the + * key itself works. See AgentWorkforce/relay#1260. + */ + createWorkspace: (name: string, apiUrl?: string) => Promise; log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; @@ -21,7 +31,7 @@ export function withSdkDefaults(overrides: Partial = {}): SdkCom return { createAgentRelay, createWorkspaceRelay, - createWorkspace: (name, baseUrl) => AgentRelay.createWorkspace({ name, baseUrl }), + createWorkspace: (name, apiUrl) => createCloudWorkspace(name, { apiUrl }), log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index 64561d326..b9018e33e 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -81,6 +81,13 @@ export type AuthSessionResponse = { export type WorkspaceCreateResponse = { workspaceId: string; name?: string; + /** + * The Relaycast `rk_live_` key for this workspace, minted as part of the + * unified Cloud create so it resolves via `resolveActiveWorkspace` (unlike + * a key from a direct Relaycast-only create, which has no Cloud Postgres + * row and 404s on resolve). + */ + relaycastApiKey?: string; relayfileUrl?: string; relaycronUrl?: string; relaycastUrl?: string; @@ -89,6 +96,17 @@ export type WorkspaceCreateResponse = { createdAt?: string; }; +/** + * Response from `POST /api/v1/workspaces/{workspaceId}/join`: session-authenticated + * access to a workspace the caller already owns/belongs to, keyed by its canonical + * Cloud workspace id (NOT a Relaycast-only `workspace create`). Used by + * `joinWorkspace` to self-heal a `relaycastApiKey` that stops resolving. + */ +export type WorkspaceJoinResponse = { + workspaceId: string; + relaycastApiKey: string; +}; + export type WorkspaceTokenRecord = { workspaceId: string; kind: string; diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 03c0262c1..2c8453428 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { readWorkspaceStore, + resolveActiveWorkspaceEntry, resolveActiveWorkspaceKey, setActiveWorkspace, setWorkspaceKey, @@ -52,4 +53,31 @@ describe('workspace store', () => { expect(() => setWorkspaceKey('__proto__', 'rk_bad')).toThrow(/Invalid workspace name/); expect(({} as Record).polluted).toBeUndefined(); }); + + it('caches cloudWorkspaceId only when explicitly supplied, and clears it on a plain key update', () => { + setWorkspaceKey('ops', 'rk_ops', undefined, 'rw_ops'); + expect(resolveActiveWorkspaceEntry()).toEqual({ + name: 'ops', + entry: { key: 'rk_ops', cloudWorkspaceId: 'rw_ops' }, + }); + + // A plain key update with no explicit cloudWorkspaceId must NOT carry the + // old id forward. If it did, a user repointing this alias at a different + // workspace via `workspace set_key`/`workspace join` could have self-heal + // silently rejoin the OLD workspace if the new key transiently fails to + // resolve, instead of surfacing the real failure. + setWorkspaceKey('ops', 'rk_ops_rotated'); + expect(resolveActiveWorkspaceEntry()).toEqual({ + name: 'ops', + entry: { key: 'rk_ops_rotated' }, + }); + + // An explicit cloudWorkspaceId is stored as given. + setWorkspaceKey('ops', 'rk_ops_rotated', undefined, 'rw_ops_new'); + expect(resolveActiveWorkspaceEntry()?.entry.cloudWorkspaceId).toBe('rw_ops_new'); + }); + + it('resolveActiveWorkspaceEntry returns undefined when there is no active workspace', () => { + expect(resolveActiveWorkspaceEntry()).toBeUndefined(); + }); }); diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index e6977ff92..aa7b7adab 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -7,9 +7,22 @@ import path from 'node:path'; * canonical Agent Relay workspace pin consumed by cloud, workforce, and * relayfile integrations. */ +export interface WorkspaceStoreEntry { + key: string; + /** + * The canonical Cloud workspace id this key was last resolved against + * (e.g. `rw_7ccfea89`), cached opportunistically on a successful resolve. + * Lets `resolveActiveWorkspace` self-heal via `joinWorkspace` if this key + * later stops resolving (rotated/orphaned) without the caller needing to + * remember or re-supply the workspace id. Absent for a key that has never + * successfully resolved. + */ + cloudWorkspaceId?: string; +} + export interface WorkspaceStore { active?: string; - workspaces: Record; + workspaces: Record; } const RESERVED_WORKSPACE_NAMES = new Set(['__proto__', 'prototype', 'constructor']); @@ -57,11 +70,22 @@ export function writeWorkspaceStore(store: WorkspaceStore, env: NodeJS.ProcessEn export function setWorkspaceKey( name: string, key: string, - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + cloudWorkspaceId?: string ): WorkspaceStore { const workspaceName = validateWorkspaceName(name); const store = readWorkspaceStore(env); - store.workspaces[workspaceName] = { key }; + // Only cache cloudWorkspaceId when the caller explicitly supplies it — NOT + // carried forward from a prior entry. A plain key update (e.g. `workspace + // set_key ` repointing an alias at a different workspace) must + // not silently retain the OLD cached id: if the new key transiently fails + // to resolve, self-heal would otherwise rejoin the old workspace instead of + // surfacing the failure. Callers that know the id (a fresh resolve, a + // fresh create, a successful join) always pass it explicitly. + store.workspaces[workspaceName] = { + key, + ...(cloudWorkspaceId ? { cloudWorkspaceId } : {}), + }; store.active ??= workspaceName; writeWorkspaceStore(store, env); return store; @@ -88,3 +112,13 @@ export function resolveActiveWorkspaceKey(env: NodeJS.ProcessEnv = process.env): } export const activeWorkspaceKey = resolveActiveWorkspaceKey; + +/** The active workspace's name plus its full stored entry (key + cached cloudWorkspaceId hint). */ +export function resolveActiveWorkspaceEntry( + env: NodeJS.ProcessEnv = process.env +): { name: string; entry: WorkspaceStoreEntry } | undefined { + const store = readWorkspaceStore(env); + if (!store.active) return undefined; + const entry = store.workspaces[store.active]; + return entry ? { name: store.active, entry } : undefined; +} diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 038435168..3ce5aa018 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setWorkspaceKey } from './workspace-store.js'; -import { resolveActiveWorkspace } from './workspaces.js'; +import { readWorkspaceStore, resolveActiveWorkspaceEntry, setWorkspaceKey } from './workspace-store.js'; +import { joinWorkspace, resolveActiveWorkspace } from './workspaces.js'; let dir: string; const originalEnv = { ...process.env }; @@ -80,4 +80,261 @@ describe('resolveActiveWorkspace', () => { const init = fetchSpy.mock.calls[0][1] as RequestInit; expect(new Headers(init.headers).get('authorization')).toBe('Bearer access-token'); }); + + it('caches the resolved cloudWorkspaceId locally so a future orphaned key can self-heal', async () => { + setWorkspaceKey('ops', 'rk_live_ops'); + expect(resolveActiveWorkspaceEntry()?.entry.cloudWorkspaceId).toBeUndefined(); + + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + workspace: { + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rc_ops', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ) + ); + + await resolveActiveWorkspace(); + + expect(resolveActiveWorkspaceEntry()?.entry).toEqual({ key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops' }); + }); + + it('self-heals an orphaned/rotated key via joinWorkspace when a cloudWorkspaceId is cached', async () => { + // This alias previously resolved successfully to rw_ops (cloudWorkspaceId + // cached), but the stored key no longer resolves — e.g. rotated server-side, + // or orphaned by the AgentWorkforce/relay#1260 `workspace create` bug. + setWorkspaceKey('ops', 'rk_live_stale', undefined, 'rw_ops'); + + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (method === 'GET' && url.includes('rk_live_stale')) { + return new Response(JSON.stringify({ error: 'Workspace not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + + if (method === 'POST' && url === 'https://cloud.example.test/api/v1/workspaces/rw_ops/join') { + return new Response(JSON.stringify({ workspaceId: 'rw_ops', relaycastApiKey: 'rk_live_healed' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + if (method === 'GET' && url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve') { + return new Response( + JSON.stringify({ + workspace: { + key: 'rk_live_healed', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rc_ops', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchSpy); + + const descriptor = await resolveActiveWorkspace(); + + expect(descriptor.key).toBe('rk_live_healed'); + expect(descriptor.cloudWorkspaceId).toBe('rw_ops'); + // The healed key is persisted locally so subsequent calls don't self-heal + // again on every resolve. + expect(readWorkspaceStore().workspaces.ops).toEqual({ + key: 'rk_live_healed', + cloudWorkspaceId: 'rw_ops', + }); + + const joinCall = fetchSpy.mock.calls.find( + ([, init]) => (init as RequestInit | undefined)?.method === 'POST' + ); + expect(joinCall).toBeDefined(); + expect(JSON.parse(String((joinCall![1] as RequestInit).body))).toEqual({ + agentName: 'cli-workspace-self-heal', + }); + }); + + it('self-heals even when the primary resolve throws a non-404 error (e.g. 403 from a rotated key)', async () => { + // Regression test: resolveWorkspaceDescriptor throws (doesn't return + // `{ lastUnsupported }`) for any non-404/405 response. Before wrapping the + // primary resolve in a catch, this propagated straight out of + // resolveActiveWorkspace and skipped the self-heal block entirely, even + // though a cached cloudWorkspaceId was available to retry with. + // (403, not 401: a 401 makes authorizedApiFetch attempt a token refresh + + // retry first, which is a separate concern from this test.) + setWorkspaceKey('ops', 'rk_live_stale', undefined, 'rw_ops'); + + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (method === 'GET' && url.includes('rk_live_stale')) { + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }); + } + + if (method === 'POST' && url === 'https://cloud.example.test/api/v1/workspaces/rw_ops/join') { + return new Response(JSON.stringify({ workspaceId: 'rw_ops', relaycastApiKey: 'rk_live_healed' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + if (method === 'GET' && url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve') { + return new Response( + JSON.stringify({ + workspace: { + key: 'rk_live_healed', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rc_ops', + relayfileWorkspaceId: 'rw_ops', + relayauthWorkspaceId: 'rw_ops', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal('fetch', fetchSpy); + + const descriptor = await resolveActiveWorkspace(); + + expect(descriptor.key).toBe('rk_live_healed'); + expect(readWorkspaceStore().workspaces.ops).toEqual({ + key: 'rk_live_healed', + cloudWorkspaceId: 'rw_ops', + }); + }); + + it('rethrows a transient transport/network error unchanged, without self-healing or touching the stored key', async () => { + // A network failure hitting the resolve endpoint is NOT a "this key + // doesn't resolve" signal — only a genuine non-2xx HTTP response from + // resolve itself (WorkspaceResolveHttpError) is self-healable. Treating a + // transient blip as self-healable would mint a new key and overwrite the + // local store even though the original key may still be perfectly valid. + setWorkspaceKey('ops', 'rk_live_ops', undefined, 'rw_ops'); + + const fetchSpy = vi.fn(async () => { + throw new TypeError('fetch failed: network error'); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect(resolveActiveWorkspace()).rejects.toThrow('fetch failed: network error'); + + // Never attempted to self-heal: no POST /join call, and the stored key is untouched. + expect(fetchSpy.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'POST')).toBe( + false + ); + expect(readWorkspaceStore().workspaces.ops).toEqual({ key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops' }); + }); + + it('falls through to the original resolve error when there is no cached cloudWorkspaceId to self-heal from', async () => { + // A pure `workspace create` orphan (AgentWorkforce/relay#1260): never + // successfully resolved, so there is nothing for self-heal to retry with. + setWorkspaceKey('ops', 'rk_live_orphan'); + + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ error: 'Workspace not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + + await expect(resolveActiveWorkspace()).rejects.toThrow(/Workspace not found/); + }); + + it('falls through to the original resolve error when self-heal itself fails (e.g. no longer a member)', async () => { + setWorkspaceKey('ops', 'rk_live_stale', undefined, 'rw_ops'); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (method === 'POST') { + return new Response(JSON.stringify({ error: 'Workspace not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ error: 'Workspace not found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }); + }) + ); + + await expect(resolveActiveWorkspace()).rejects.toThrow(/Workspace not found/); + }); +}); + +describe('joinWorkspace', () => { + it('joins an existing workspace by id and returns its relaycastApiKey', async () => { + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify({ workspaceId: 'rw_ops', relaycastApiKey: 'rk_live_joined' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + + await expect(joinWorkspace('rw_ops')).resolves.toEqual({ + workspaceId: 'rw_ops', + relaycastApiKey: 'rk_live_joined', + }); + + expect(String(fetchSpy.mock.calls[0][0])).toBe( + 'https://cloud.example.test/api/v1/workspaces/rw_ops/join' + ); + expect((fetchSpy.mock.calls[0][1] as RequestInit).method).toBe('POST'); + }); + + it('throws when the response is missing relaycastApiKey', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ workspaceId: 'rw_ops' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ) + ); + + await expect(joinWorkspace('rw_ops')).rejects.toThrow(/missing relaycastApiKey/); + }); + + it('rejects an empty workspace id without making a request', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + await expect(joinWorkspace(' ')).rejects.toThrow(/Workspace id is required/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index cc4f157b2..f49f72940 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -4,15 +4,45 @@ import { type ActiveWorkspaceUrls, defaultApiUrl, type WorkspaceCreateResponse, + type WorkspaceJoinResponse, type WorkspaceTokenIssueResponse, type WorkspaceTokenRecord, } from './types.js'; -import { resolveActiveWorkspaceKey } from './workspace-store.js'; +import { resolveActiveWorkspaceEntry, setWorkspaceKey } from './workspace-store.js'; type WorkspaceClientOptions = { apiUrl?: string; }; +type InteractiveRequestOptions = WorkspaceClientOptions & { + interactive?: boolean; + refreshTimeoutMs?: number; +}; + +/** + * Stable synthetic agent identity used only to mint a `relaycastApiKey` for an + * already-owned workspace during self-heal (see `joinWorkspace` / the + * resolve fallback below) — not a real running agent, so it's fine for this + * to be shared across a machine's self-heal attempts. + */ +const SELF_HEAL_AGENT_NAME = 'cli-workspace-self-heal'; + +/** + * Thrown only for a genuine non-2xx HTTP response from the workspace resolve + * endpoint itself (e.g. 401/403 on a rotated/invalid key) — distinguishes + * "the key doesn't resolve" (self-healable) from a transient transport, + * authentication, or response-schema error (NOT self-healable: minting a new + * key and overwriting the local store on a network blip or malformed + * response could churn a perfectly valid key). Only this error type is + * treated as self-healable in `resolveActiveWorkspace`. + */ +class WorkspaceResolveHttpError extends Error { + constructor(message: string) { + super(message); + this.name = 'WorkspaceResolveHttpError'; + } +} + type WorkspaceTokenIssueOptions = WorkspaceClientOptions & { name?: string; }; @@ -81,6 +111,9 @@ function normalizeWorkspaceCreateResponse(payload: unknown): WorkspaceCreateResp return { workspaceId, ...(readString(payload, 'name') ? { name: readString(payload, 'name') } : {}), + ...(readString(payload, 'relaycastApiKey') + ? { relaycastApiKey: readString(payload, 'relaycastApiKey') } + : {}), ...(readString(payload, 'relayfileUrl') ? { relayfileUrl: readString(payload, 'relayfileUrl') } : {}), ...(readString(payload, 'relaycronUrl') ? { relaycronUrl: readString(payload, 'relaycronUrl') } : {}), ...(readString(payload, 'relaycastUrl') ? { relaycastUrl: readString(payload, 'relaycastUrl') } : {}), @@ -219,14 +252,25 @@ function normalizeActiveWorkspaceDescriptor( async function tryPostJson( endpoint: string, body: Record, - options: WorkspaceClientOptions + options: InteractiveRequestOptions ): Promise<{ response: Response; payload: unknown }> { const apiUrl = options.apiUrl || defaultApiUrl(); - const auth = await ensureAuthenticated(apiUrl); - const { response } = await authorizedApiFetch(auth, endpoint, { - method: 'POST', - body: JSON.stringify(body), + const auth = await ensureAuthenticated(apiUrl, { + interactive: options.interactive, + refreshTimeoutMs: options.refreshTimeoutMs, }); + const { response } = await authorizedApiFetch( + auth, + endpoint, + { + method: 'POST', + body: JSON.stringify(body), + }, + { + interactive: options.interactive, + refreshTimeoutMs: options.refreshTimeoutMs, + } + ); return { response, @@ -339,16 +383,10 @@ export async function issueWorkspaceToken( ); } -export async function resolveActiveWorkspace( - options: ResolveActiveWorkspaceOptions = {} -): Promise { - const key = resolveActiveWorkspaceKey(options.env); - if (!key) { - throw new Error( - 'No active Agent Relay workspace found. Run `agent-relay workspace set_key ` or `agent-relay workspace join `.' - ); - } - +async function resolveWorkspaceDescriptor( + key: string, + options: ResolveActiveWorkspaceOptions +): Promise<{ descriptor: ActiveWorkspaceDescriptor } | { lastUnsupported: Error | null }> { const encodedKey = encodeURIComponent(key); const endpoints = [ `/api/v1/workspaces/${encodedKey}/resolve`, @@ -370,11 +408,119 @@ export async function resolveActiveWorkspace( } if (!response.ok) { - throw buildEndpointError('Workspace resolve', endpoint, response, payload); + throw new WorkspaceResolveHttpError( + buildEndpointError('Workspace resolve', endpoint, response, payload).message + ); + } + + return { descriptor: normalizeActiveWorkspaceDescriptor(payload, key, apiUrl) }; + } + + return { lastUnsupported }; +} + +/** + * Join a workspace the caller already owns/belongs to (by its canonical Cloud + * workspace id) via the session-authenticated `/join` endpoint, returning a + * fresh `relaycastApiKey` for that SAME workspace. Unlike `createWorkspace`, + * this never mints a new workspace — see AgentWorkforce/relay#1260. + */ +export async function joinWorkspace( + workspaceId: string, + options: InteractiveRequestOptions = {} +): Promise { + const trimmedId = workspaceId.trim(); + if (!trimmedId) { + throw new Error('Workspace id is required.'); + } + + const endpoint = `/api/v1/workspaces/${encodeURIComponent(trimmedId)}/join`; + const { response, payload } = await tryPostJson(endpoint, { agentName: SELF_HEAL_AGENT_NAME }, options); + + if (!response.ok) { + throw buildEndpointError('Workspace join', endpoint, response, payload); + } + if (!isObject(payload)) { + throw new Error('Workspace join response was not valid JSON.'); + } + + const relaycastApiKey = readString(payload, 'relaycastApiKey'); + if (!relaycastApiKey) { + throw new Error('Workspace join response is missing relaycastApiKey.'); + } + + return { + workspaceId: readString(payload, 'workspaceId') ?? trimmedId, + relaycastApiKey, + }; +} + +export async function resolveActiveWorkspace( + options: ResolveActiveWorkspaceOptions = {} +): Promise { + const active = resolveActiveWorkspaceEntry(options.env); + if (!active) { + throw new Error( + 'No active Agent Relay workspace found. Run `agent-relay workspace set_key ` or `agent-relay workspace join `.' + ); + } + const { name, entry } = active; + + // A non-404/405 HTTP failure from the resolve endpoint itself (e.g. 401/403 + // from a rotated/invalid key) throws a WorkspaceResolveHttpError out of + // resolveWorkspaceDescriptor instead of returning `{ lastUnsupported }` — + // catch ONLY that typed error and fold it into the same shape so self-heal + // below still gets a chance. Any other error (network/transport failure, + // ensureAuthenticated/session failure, malformed-response schema error from + // normalizeActiveWorkspaceDescriptor) is NOT a "this key doesn't resolve" + // signal — rethrow it unchanged so a transient blip can't mint a new key + // and overwrite a perfectly valid one. + const primary = await resolveWorkspaceDescriptor(entry.key, options).catch( + (err: unknown): { lastUnsupported: Error } => { + if (err instanceof WorkspaceResolveHttpError) { + return { lastUnsupported: err }; + } + throw err; + } + ); + if ('descriptor' in primary) { + // Opportunistically cache the resolved cloud workspace id (if new/changed) + // so a future rotated/orphaned key can self-heal below without the caller + // ever having to remember or re-supply it. + if ( + primary.descriptor.cloudWorkspaceId && + primary.descriptor.cloudWorkspaceId !== entry.cloudWorkspaceId + ) { + setWorkspaceKey(name, entry.key, options.env, primary.descriptor.cloudWorkspaceId); } + return primary.descriptor; + } - return normalizeActiveWorkspaceDescriptor(payload, key, apiUrl); + // Self-heal: the stored key no longer resolves (rotated server-side, or + // orphaned by the AgentWorkforce/relay#1260 `workspace create` bug), but this + // alias previously resolved to a known Cloud workspace id. Re-mint a working + // key for that SAME workspace via the session-authenticated join endpoint + // (this never creates a new workspace) and retry once. Best-effort: any + // failure here (no session, no longer a member, etc.) falls through to the + // original resolve error so the failure mode is unchanged from before. + if (entry.cloudWorkspaceId) { + try { + const joined = await joinWorkspace(entry.cloudWorkspaceId, { + apiUrl: options.apiUrl, + interactive: options.interactive ?? false, + refreshTimeoutMs: options.refreshTimeoutMs, + }); + setWorkspaceKey(name, joined.relaycastApiKey, options.env, joined.workspaceId); + const healed = await resolveWorkspaceDescriptor(joined.relaycastApiKey, options); + if ('descriptor' in healed) { + return healed.descriptor; + } + } catch { + // fall through + } } - throw lastUnsupported ?? new Error('Workspace resolution is not supported by the configured cloud API.'); + throw ( + primary.lastUnsupported ?? new Error('Workspace resolution is not supported by the configured cloud API.') + ); }