From d979c359ca71b0753fb44a44da18d26b89a3a156 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 14 Jul 2026 11:54:24 +0200 Subject: [PATCH 1/6] fix(cli): stop workspace create from orphaning its key, self-heal active resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-relay workspace create` minted a workspace directly against Relaycast (AgentRelay.createWorkspace) and stored only that key locally. `workspace active`/`agentworkforce login`/`deploy` resolve through Cloud's Postgres `relay_workspaces` row, which the direct-Relaycast create path never inserts — so every key from `workspace create` permanently 404s with "Workspace not found" on its very first resolve, even though the key itself is valid in Relaycast. Root-caused via live reproduction in AgentWorkforce/relay#1260. - workspace create now goes through @agent-relay/cloud's unified `/api/v1/workspaces` create endpoint (same path relayfile setup already uses), which persists the missing Postgres row and returns a relaycastApiKey that actually resolves. Renamed --base-url to --api-url on the command to match `workspace active`'s existing flag. - resolveActiveWorkspace now self-heals: it opportunistically caches the resolved cloudWorkspaceId per local workspace alias, and if a key later stops resolving (rotated server-side, or an existing pre-fix orphan) it re-mints a working key for that SAME workspace via the session-authenticated `/{workspaceId}/join` endpoint and retries once — no new workspace is ever created, and any failure falls through to the original resolve error so the failure mode is unchanged when self-heal isn't possible (e.g. a key that never successfully resolved has no cached id to heal from). Co-Authored-By: Claude Sonnet 5 --- .../cli/src/cli/commands/workspace.test.ts | 37 +++- packages/cli/src/cli/commands/workspace.ts | 20 +- packages/cli/src/cli/lib/sdk-command.test.ts | 42 ++++ packages/cli/src/cli/lib/sdk-command.ts | 16 +- packages/cloud/src/types.ts | 18 ++ packages/cloud/src/workspace-store.test.ts | 25 +++ packages/cloud/src/workspace-store.ts | 35 +++- packages/cloud/src/workspaces.test.ts | 179 +++++++++++++++++- packages/cloud/src/workspaces.ts | 127 +++++++++++-- 9 files changed, 469 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/cli/lib/sdk-command.test.ts diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 7ed6201a6..274b5dd68 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,39 @@ 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'); + 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..4773f6b35 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -54,16 +54,24 @@ 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 }); + setWorkspaceKey(name, workspace.relaycastApiKey); + 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..f97f999f5 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,28 @@ describe('workspace store', () => { expect(() => setWorkspaceKey('__proto__', 'rk_bad')).toThrow(/Invalid workspace name/); expect(({} as Record).polluted).toBeUndefined(); }); + + it('caches cloudWorkspaceId alongside the key and preserves it across a plain key update', () => { + setWorkspaceKey('ops', 'rk_ops', undefined, 'rw_ops'); + expect(resolveActiveWorkspaceEntry()).toEqual({ + name: 'ops', + entry: { key: 'rk_ops', cloudWorkspaceId: 'rw_ops' }, + }); + + // A later call that only updates the key (e.g. self-heal) keeps the + // previously-cached cloudWorkspaceId instead of dropping it. + setWorkspaceKey('ops', 'rk_ops_rotated'); + expect(resolveActiveWorkspaceEntry()).toEqual({ + name: 'ops', + entry: { key: 'rk_ops_rotated', cloudWorkspaceId: 'rw_ops' }, + }); + + // An explicit new cloudWorkspaceId overrides the cached one. + 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..3a67215a7 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,17 @@ 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 }; + const existing = store.workspaces[workspaceName]; + const resolvedCloudWorkspaceId = cloudWorkspaceId ?? existing?.cloudWorkspaceId; + store.workspaces[workspaceName] = { + key, + ...(resolvedCloudWorkspaceId ? { cloudWorkspaceId: resolvedCloudWorkspaceId } : {}), + }; store.active ??= workspaceName; writeWorkspaceStore(store, env); return store; @@ -88,3 +107,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..424b952f1 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,179 @@ 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('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..1aba91fff 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -4,15 +4,29 @@ 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'; + type WorkspaceTokenIssueOptions = WorkspaceClientOptions & { name?: string; }; @@ -81,6 +95,7 @@ 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,10 +234,13 @@ 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 auth = await ensureAuthenticated(apiUrl, { + interactive: options.interactive, + refreshTimeoutMs: options.refreshTimeoutMs, + }); const { response } = await authorizedApiFetch(auth, endpoint, { method: 'POST', body: JSON.stringify(body), @@ -339,16 +357,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`, @@ -373,8 +385,93 @@ export async function resolveActiveWorkspace( throw buildEndpointError('Workspace resolve', endpoint, response, payload); } - return normalizeActiveWorkspaceDescriptor(payload, key, apiUrl); + 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; + + const primary = await resolveWorkspaceDescriptor(entry.key, options); + 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; + } + + // 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.'); } From f76102d875623ae95bbc018d858ef8eb5fefb762 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 09:55:39 +0000 Subject: [PATCH 2/6] style: auto-format with Prettier --- .../cli/src/cli/commands/workspace.test.ts | 10 +++++++++- packages/cloud/src/workspaces.test.ts | 18 +++++++++++------- packages/cloud/src/workspaces.ts | 13 ++++++++++--- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 274b5dd68..70a74e39a 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -96,7 +96,15 @@ describe('registerWorkspaceCommands', () => { relaycastApiKey: 'rk_live_new', }); - await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'new-ws', '--api-url', 'https://cloud.test']); + 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'); diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 424b952f1..af803888e 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -133,10 +133,7 @@ describe('resolveActiveWorkspace', () => { }); } - if ( - method === 'GET' && - url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve' - ) { + if (method === 'GET' && url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve') { return new Response( JSON.stringify({ workspace: { @@ -161,9 +158,14 @@ describe('resolveActiveWorkspace', () => { 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' }); + 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'); + 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', @@ -229,7 +231,9 @@ describe('joinWorkspace', () => { relaycastApiKey: 'rk_live_joined', }); - expect(String(fetchSpy.mock.calls[0][0])).toBe('https://cloud.example.test/api/v1/workspaces/rw_ops/join'); + 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'); }); diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 1aba91fff..33559de22 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -95,7 +95,9 @@ function normalizeWorkspaceCreateResponse(payload: unknown): WorkspaceCreateResp return { workspaceId, ...(readString(payload, 'name') ? { name: readString(payload, 'name') } : {}), - ...(readString(payload, 'relaycastApiKey') ? { relaycastApiKey: readString(payload, 'relaycastApiKey') } : {}), + ...(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') } : {}), @@ -443,7 +445,10 @@ export async function resolveActiveWorkspace( // 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) { + if ( + primary.descriptor.cloudWorkspaceId && + primary.descriptor.cloudWorkspaceId !== entry.cloudWorkspaceId + ) { setWorkspaceKey(name, entry.key, options.env, primary.descriptor.cloudWorkspaceId); } return primary.descriptor; @@ -473,5 +478,7 @@ export async function resolveActiveWorkspace( } } - throw primary.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.') + ); } From 26be7b33d6a8f0b4dfdfa074b487e28bf28a345f Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 14 Jul 2026 12:28:11 +0200 Subject: [PATCH 3/6] fix: address PR review feedback on workspace self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 3 real issues found by cubic-dev-ai and gemini-code-assist on #1261: - resolveActiveWorkspace now catches a thrown non-404/405 resolve error (e.g. 401/403 from a rotated/invalid key) and folds it into the same `{ lastUnsupported }` shape as an exhausted-candidates failure, so self-heal still gets a chance whenever a cloudWorkspaceId is cached. Previously such an error propagated straight past the self-heal block (gemini-code-assist + cubic-dev-ai both flagged this independently). - setWorkspaceKey no longer carries a previously-cached cloudWorkspaceId forward on a plain key update — only an explicitly-supplied id is stored. Carrying it forward meant a user repointing an 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 failed to resolve (cubic-dev-ai). - tryPostJson now forwards interactive/refreshTimeoutMs into authorizedApiFetch (matching tryGetJson's existing pattern), so a 401 mid-request during self-heal's join call can't trigger an unwanted interactive browser re-auth in a non-interactive context (cubic-dev-ai). - `workspace create` now caches cloudWorkspaceId immediately from the create response instead of waiting for the first successful resolve (coderabbitai nitpick, same underlying issue as the setWorkspaceKey fix). Co-Authored-By: Claude Sonnet 5 --- .../cli/src/cli/commands/workspace.test.ts | 2 +- packages/cli/src/cli/commands/workspace.ts | 5 +- packages/cloud/src/workspace-store.test.ts | 13 +++-- packages/cloud/src/workspace-store.ts | 11 ++-- packages/cloud/src/workspaces.test.ts | 54 +++++++++++++++++++ packages/cloud/src/workspaces.ts | 27 ++++++++-- 6 files changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 70a74e39a..4467da956 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -107,7 +107,7 @@ describe('registerWorkspaceCommands', () => { ]); expect(deps.createWorkspace).toHaveBeenCalledWith('new-ws', 'https://cloud.test'); - expect(vi.mocked(setWorkspaceKey)).toHaveBeenCalledWith('new-ws', 'rk_live_new'); + 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', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 4773f6b35..99669c861 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -66,7 +66,10 @@ export function registerWorkspaceCommands( 'Make sure you are logged in to Cloud (`agent-relay login`).' ); } - setWorkspaceKey(name, workspace.relaycastApiKey); + // 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, diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index f97f999f5..2c8453428 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -54,22 +54,25 @@ describe('workspace store', () => { expect(({} as Record).polluted).toBeUndefined(); }); - it('caches cloudWorkspaceId alongside the key and preserves it across a plain key update', () => { + 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 later call that only updates the key (e.g. self-heal) keeps the - // previously-cached cloudWorkspaceId instead of dropping it. + // 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', cloudWorkspaceId: 'rw_ops' }, + entry: { key: 'rk_ops_rotated' }, }); - // An explicit new cloudWorkspaceId overrides the cached one. + // An explicit cloudWorkspaceId is stored as given. setWorkspaceKey('ops', 'rk_ops_rotated', undefined, 'rw_ops_new'); expect(resolveActiveWorkspaceEntry()?.entry.cloudWorkspaceId).toBe('rw_ops_new'); }); diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 3a67215a7..aa7b7adab 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -75,11 +75,16 @@ export function setWorkspaceKey( ): WorkspaceStore { const workspaceName = validateWorkspaceName(name); const store = readWorkspaceStore(env); - const existing = store.workspaces[workspaceName]; - const resolvedCloudWorkspaceId = cloudWorkspaceId ?? existing?.cloudWorkspaceId; + // 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, - ...(resolvedCloudWorkspaceId ? { cloudWorkspaceId: resolvedCloudWorkspaceId } : {}), + ...(cloudWorkspaceId ? { cloudWorkspaceId } : {}), }; store.active ??= workspaceName; writeWorkspaceStore(store, env); diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index af803888e..435cb7c98 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -172,6 +172,60 @@ describe('resolveActiveWorkspace', () => { }); }); + it('self-heals even when the primary resolve throws a non-404 error (e.g. 401 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. + 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: 'Unauthorized' }), { + status: 401, + 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('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. diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 33559de22..369cfa56b 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -243,10 +243,18 @@ async function tryPostJson( interactive: options.interactive, refreshTimeoutMs: options.refreshTimeoutMs, }); - const { response } = await authorizedApiFetch(auth, endpoint, { - method: 'POST', - body: JSON.stringify(body), - }); + const { response } = await authorizedApiFetch( + auth, + endpoint, + { + method: 'POST', + body: JSON.stringify(body), + }, + { + interactive: options.interactive, + refreshTimeoutMs: options.refreshTimeoutMs, + } + ); return { response, @@ -440,7 +448,16 @@ export async function resolveActiveWorkspace( } const { name, entry } = active; - const primary = await resolveWorkspaceDescriptor(entry.key, options); + // A non-404/405 failure (e.g. 401/403 from a rotated/invalid key) throws + // out of resolveWorkspaceDescriptor instead of returning `{ lastUnsupported }` + // — catch it here and fold it into the same shape so self-heal below still + // gets a chance whenever a cached cloudWorkspaceId is available, instead of + // this propagating straight past the self-heal block. + const primary = await resolveWorkspaceDescriptor(entry.key, options).catch( + (err: unknown): { lastUnsupported: Error } => ({ + lastUnsupported: err instanceof Error ? err : new Error(String(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 From d2c7d5530fe126016af4c763072ba88a7f3154db Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 10:31:18 +0000 Subject: [PATCH 4/6] style: auto-format with Prettier --- packages/cloud/src/workspaces.test.ts | 10 +++++----- packages/cloud/src/workspaces.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 435cb7c98..da8e2c52d 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -198,10 +198,7 @@ describe('resolveActiveWorkspace', () => { }); } - if ( - method === 'GET' && - url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve' - ) { + if (method === 'GET' && url === 'https://cloud.example.test/api/v1/workspaces/rk_live_healed/resolve') { return new Response( JSON.stringify({ workspace: { @@ -223,7 +220,10 @@ describe('resolveActiveWorkspace', () => { const descriptor = await resolveActiveWorkspace(); expect(descriptor.key).toBe('rk_live_healed'); - expect(readWorkspaceStore().workspaces.ops).toEqual({ key: 'rk_live_healed', cloudWorkspaceId: 'rw_ops' }); + expect(readWorkspaceStore().workspaces.ops).toEqual({ + key: 'rk_live_healed', + cloudWorkspaceId: 'rw_ops', + }); }); it('falls through to the original resolve error when there is no cached cloudWorkspaceId to self-heal from', async () => { diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 369cfa56b..3aa31e14d 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -455,7 +455,7 @@ export async function resolveActiveWorkspace( // this propagating straight past the self-heal block. const primary = await resolveWorkspaceDescriptor(entry.key, options).catch( (err: unknown): { lastUnsupported: Error } => ({ - lastUnsupported: err instanceof Error ? err : new Error(String(err)) + lastUnsupported: err instanceof Error ? err : new Error(String(err)), }) ); if ('descriptor' in primary) { From 5731c2a06c411565530a2e50e438d96f8744c1a2 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 14 Jul 2026 13:35:01 +0200 Subject: [PATCH 5/6] fix: only self-heal on a genuine workspace-resolve HTTP error, not transport/auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic-dev-ai (PR #1261 review, comment on workspaces.ts:456): the prior catch around resolveWorkspaceDescriptor's primary resolve call was too broad — it treated ANY thrown error (a network/transport failure, an ensureAuthenticated/session error, a malformed-response schema error from normalizeActiveWorkspaceDescriptor) as "this key doesn't resolve" and proceeded into self-heal, which mints a new key via /join and overwrites the locally-stored one. A transient 500, timeout, or auth blip could therefore churn a perfectly valid key. Adds a WorkspaceResolveHttpError, thrown only for a genuine non-2xx HTTP response from the resolve endpoint itself (401/403/etc., matching the 401-bypasses-self-heal fix from the prior commit). resolveActiveWorkspace's catch now narrows to only that type via instanceof, rethrowing everything else unchanged so self-heal can't fire on a transport/auth/schema failure. Regression test added: a network-level fetch failure now rejects with the original error, never calls /join, and never touches the stored key. Updated the existing 401 self-heal test to use 403 instead — a 401 makes authorizedApiFetch attempt a token refresh + retry first, which is a separate concern from what that test is verifying. Co-Authored-By: Claude Sonnet 5 --- packages/cloud/src/workspaces.test.ts | 30 ++++++++++++++++++-- packages/cloud/src/workspaces.ts | 41 +++++++++++++++++++++------ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index da8e2c52d..a6291e989 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -172,12 +172,14 @@ describe('resolveActiveWorkspace', () => { }); }); - it('self-heals even when the primary resolve throws a non-404 error (e.g. 401 from a rotated key)', async () => { + 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) => { @@ -185,8 +187,8 @@ describe('resolveActiveWorkspace', () => { const method = init?.method ?? 'GET'; if (method === 'GET' && url.includes('rk_live_stale')) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, + return new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, headers: { 'content-type': 'application/json' }, }); } @@ -226,6 +228,28 @@ describe('resolveActiveWorkspace', () => { }); }); + 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. diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 3aa31e14d..b3d1f2da4 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -27,6 +27,22 @@ type InteractiveRequestOptions = WorkspaceClientOptions & { */ 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; }; @@ -392,7 +408,7 @@ async function resolveWorkspaceDescriptor( } 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) }; @@ -448,15 +464,22 @@ export async function resolveActiveWorkspace( } const { name, entry } = active; - // A non-404/405 failure (e.g. 401/403 from a rotated/invalid key) throws - // out of resolveWorkspaceDescriptor instead of returning `{ lastUnsupported }` - // — catch it here and fold it into the same shape so self-heal below still - // gets a chance whenever a cached cloudWorkspaceId is available, instead of - // this propagating straight past the self-heal block. + // 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 } => ({ - lastUnsupported: err instanceof Error ? err : new Error(String(err)), - }) + (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) From c98bccedf88d72e344a61058273f10fd53f62fae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Jul 2026 11:35:56 +0000 Subject: [PATCH 6/6] style: auto-format with Prettier --- packages/cloud/src/workspaces.test.ts | 6 +++--- packages/cloud/src/workspaces.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index a6291e989..3ce5aa018 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -244,9 +244,9 @@ describe('resolveActiveWorkspace', () => { 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(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' }); }); diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index b3d1f2da4..f49f72940 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -408,7 +408,9 @@ async function resolveWorkspaceDescriptor( } if (!response.ok) { - throw new WorkspaceResolveHttpError(buildEndpointError('Workspace resolve', endpoint, response, payload).message); + throw new WorkspaceResolveHttpError( + buildEndpointError('Workspace resolve', endpoint, response, payload).message + ); } return { descriptor: normalizeActiveWorkspaceDescriptor(payload, key, apiUrl) };