From 27494c8626bdee94fae1ed6fc6973755a81e3023 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Fri, 31 Jul 2026 00:55:30 +0200 Subject: [PATCH 1/2] fix(node): make workspace identity durable across node restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node started with no project-pinned workspace fell straight through to the broker, which mints a brand-new messaging-only workspace. Nothing errored: the node came up, the resident agent registered, and it looked healthy — but it was a stranger in a different workspace with a new address, so everything sent to its previous address went nowhere. `node up` now resolves the machine-global canonical workspace (`agent-relay workspace join|switch`) before letting the broker mint one. Explicit `--workspace-key`, workspace env vars, and an existing project pin all still win; the resolved key is pinned to the project afterwards, so later starts resume it directly. A machine with no canonical workspace set behaves exactly as before. Also make the invariant observable: - `workspace active` emits a `dataPlane` object proving Relaycast, Relayfile, and RelayAuth share one data-plane workspace ID, prints the Relaycast ID it used to omit, and gains `--require-unified` to turn a divergence into a non-zero exit. - `node status` prints the durable `Workspace:` ID next to the masked key, so an operator can compare it across a restart. Secrets stay out of both paths: the canonical-workspace fallback logs only its source, never the key. specs/workspace-identity.md documents the invariant, the resolution order, and migration behavior for existing local nodes. Refs AR-448 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 + packages/cli/src/cli/commands/core.test.ts | 104 +++++- .../cli/src/cli/commands/workspace.test.ts | 130 ++++++- packages/cli/src/cli/commands/workspace.ts | 40 +- packages/cli/src/cli/lib/broker-lifecycle.ts | 68 +++- .../lib/workspace-identity-restart.test.ts | 341 ++++++++++++++++++ packages/cloud/src/index.ts | 6 + .../cloud/src/workspace-convergence.test.ts | 58 +++ packages/cloud/src/workspace-convergence.ts | 74 ++++ packages/cloud/src/workspace-key.ts | 9 + specs/workspace-identity.md | 164 +++++++++ 11 files changed, 972 insertions(+), 29 deletions(-) create mode 100644 packages/cli/src/cli/lib/workspace-identity-restart.test.ts create mode 100644 packages/cloud/src/workspace-convergence.test.ts create mode 100644 packages/cloud/src/workspace-convergence.ts create mode 100644 specs/workspace-identity.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cee634c8..188d26bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Patch] +### Added + +- `agent-relay workspace active` now reports whether Relaycast, Relayfile, and RelayAuth resolve the workspace to one data-plane ID. `--json` gains a `dataPlane` object (`unified`, the shared `workspaceId`, per-plane IDs, and the names of any that diverge), the human output prints the Relaycast ID it used to omit, and `--require-unified` turns a divergence into a non-zero exit for setup doctors and supervisors. +- `agent-relay node status` prints the durable `Workspace:` ID alongside the masked workspace key, so an operator can confirm a restart preserved workspace identity. See `specs/workspace-identity.md`. + ### Fixed +- A node started with no project-pinned workspace no longer mints a throwaway workspace. `agent-relay node up` now falls back to the machine-global canonical workspace (`agent-relay workspace join|switch`) before letting the broker create one, so the node and its resident agents keep the same workspace — and the same delivery addresses — across a stop/start. Explicit `--workspace-key`, workspace env vars, and an existing project pin all still win; a machine with no canonical workspace set behaves as before. + - `agent-relay integration webhook create` now works. It took a `` argument and sent `{ url, event }`, but `POST /v1/webhooks` accepts `{ channel, name? }` and returns the URL — so every invocation failed with `channel is required`. It now takes `` with an optional `--name`, matching `create-inbound`, which posts to the same endpoint. - `@agent-relay/sdk` `RelayCreateWebhookInput` declared a required `url` and an `event`, neither of which the endpoint accepts. It is now `{ channel, name? }`. Code passing `url`/`event` was already failing at runtime. diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index e7bb634bb..68e59ba21 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import nodeFs from 'node:fs'; import os from 'node:os'; import nodePath from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readProjectWorkspaceKey, readProjectWorkspaceSession } from '../lib/project-workspace-key.js'; @@ -12,8 +12,10 @@ const sdkStatusClient = { async () => ({ workspace_key: '' }) as { workspace_key?: string; + default_workspace_id?: string; node_id?: string; node_name?: string; + node_token?: string; } ), disconnect: vi.fn(() => undefined), @@ -60,6 +62,12 @@ beforeEach(() => { telemetryMocks.track.mockClear(); }); +afterEach(() => { + for (const dir of relayHomes.splice(0)) { + nodeFs.rmSync(dir, { recursive: true, force: true }); + } +}); + import { registerCoreCommands, registerCoreMaintenance, @@ -127,6 +135,25 @@ function createFsMock(initialFiles: Record = {}): CoreFileSystem } // eslint-disable-next-line complexity +const relayHomes: string[] = []; + +/** An `AGENT_RELAY_HOME` with no workspace store — no canonical workspace exists. */ +function emptyRelayHome(): string { + const dir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'core-relay-home-')); + relayHomes.push(dir); + return dir; +} + +/** An `AGENT_RELAY_HOME` whose active workspace is `key`. */ +function relayHomeWithCanonicalWorkspace(key: string): string { + const dir = emptyRelayHome(); + nodeFs.writeFileSync( + nodePath.join(dir, 'workspaces.json'), + JSON.stringify({ active: 'default', workspaces: { default: { key } } }) + ); + return dir; +} + function createHarness(options?: { fs?: CoreFileSystem; relay?: CoreRelay; @@ -156,6 +183,10 @@ function createHarness(options?: { const spawnedProcess = options?.spawnedProcess ?? createSpawnedProcessMock(); const env = options?.env ?? {}; env.AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE ??= '1'; + // `up` now falls back to the machine-global workspace store. Point every + // harness at an isolated home by default so a test never picks up (or + // prints) whatever workspace the developer's own machine has active. + env.AGENT_RELAY_HOME ??= emptyRelayHome(); const exit = vi.fn((code: number) => { throw new ExitSignal(code); @@ -1196,6 +1227,35 @@ describe('registerCoreCommands', () => { expect(sdkStatusClient.disconnect).toHaveBeenCalled(); }); + it('status reports the durable workspace ID without leaking any credential', async () => { + // The workspace ID is what an operator compares before and after a restart + // to confirm identity held; everything credential-shaped stays masked. + const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json'; + const fs = createFsMock({ [connectionPath]: connectionFile(4242) }); + sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 1 }); + sdkStatusClient.getSession.mockResolvedValueOnce({ + workspace_key: 'rk_live_teststatus123', + default_workspace_id: 'rw_7ccfea89', + node_id: 'node_enrolled', + node_name: 'sf-mini', + node_token: 'nt_live_nodetoken456', + }); + + const { program, deps } = createHarness({ fs }); + + await runCommand(program, ['status']); + + expect(deps.log).toHaveBeenCalledWith('Workspace: rw_7ccfea89'); + const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls + .flat() + .join('\n'); + expect(output).not.toContain('rk_live_teststatus123'); + expect(output).not.toContain('nt_live_nodetoken456'); + // Observer URLs carry a scoped token in the query string; status never + // prints one at all, which is the only way to guarantee it can't leak. + expect(output).not.toMatch(/ot_live_|[?&](token|key|api_key)=/); + }); + it('status omits workspace key and observer when broker has no workspace_key', async () => { const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json'; const fs = createFsMock({ [connectionPath]: connectionFile(4242) }); @@ -1509,8 +1569,11 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…ag88'); }); - it('up without --workspace-key or a pinned session does not set workspace key env vars', async () => { - const env: NodeJS.ProcessEnv = {}; + it('up without --workspace-key, a pinned session, or a canonical workspace sets no key env vars', async () => { + // AGENT_RELAY_HOME points at an empty dir so the machine-global workspace + // store is genuinely absent rather than whatever the dev box happens to + // have active. + const env: NodeJS.ProcessEnv = { AGENT_RELAY_HOME: emptyRelayHome() }; const relay = createRelayMock(); const { program } = createHarness({ relay, env }); @@ -1521,6 +1584,41 @@ describe('registerCoreCommands', () => { expect(env.RELAY_API_KEY).toBeUndefined(); }); + it('up falls back to the machine-global canonical workspace when nothing else selects one', async () => { + const env: NodeJS.ProcessEnv = { + AGENT_RELAY_HOME: relayHomeWithCanonicalWorkspace('rk_live_canonicalstore01'), + }; + const relay = createRelayMock({ workspaceKey: 'rk_live_canonicalstore01' }); + const { program, deps } = createHarness({ relay, env }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_canonicalstore01'); + expect(env.RELAY_API_KEY).toBe('rk_live_canonicalstore01'); + // Only the source is named; the key itself is a live credential. + const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().join('\n'); + expect(output).toContain('machine-global canonical Agent Relay workspace'); + expect(output).not.toContain('rk_live_canonicalstore01'); + }); + + it('up prefers the project pin over the machine-global canonical workspace', async () => { + const env: NodeJS.ProcessEnv = { + AGENT_RELAY_HOME: relayHomeWithCanonicalWorkspace('rk_live_canonicalstore01'), + }; + const fs = createFsMock({ + '/tmp/project/.agentworkforce/relay/workspace-key.json': JSON.stringify({ + workspaceKey: 'rk_live_projectpin01', + }), + }); + const relay = createRelayMock({ workspaceKey: 'rk_live_projectpin01' }); + const { program } = createHarness({ relay, env, fs }); + + await runCommand(program, ['up']); + + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_projectpin01'); + }); + it('up resumes the workspace session pinned to the project', async () => { const env: NodeJS.ProcessEnv = {}; const fs = createFsMock({ diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 252904a8a..03d3b85f4 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -1,12 +1,20 @@ import { Command } from 'commander'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@agent-relay/cloud', () => ({ - readWorkspaceStore: vi.fn(() => ({ workspaces: {} })), - resolveActiveWorkspace: vi.fn(), - setWorkspaceKey: vi.fn(), - switchWorkspace: vi.fn(), -})); +vi.mock('@agent-relay/cloud', async (importOriginal) => { + // The convergence helpers are pure and are the thing under test here — keep + // the real implementations so the command's evidence output isn't asserted + // against a stub that could drift from it. + const actual = await importOriginal(); + return { + describeDataPlaneConvergence: actual.describeDataPlaneConvergence, + formatDataPlaneDivergence: actual.formatDataPlaneDivergence, + readWorkspaceStore: vi.fn(() => ({ workspaces: {} })), + resolveActiveWorkspace: vi.fn(), + setWorkspaceKey: vi.fn(), + switchWorkspace: vi.fn(), + }; +}); vi.mock('../lib/workspace-session.js', () => ({ persistWorkspaceSession: vi.fn(), @@ -59,7 +67,7 @@ describe('registerWorkspaceCommands', () => { name: 'Ops', key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + relaycastWorkspaceId: 'rw_ops', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', organizationId: 'org_1', @@ -89,23 +97,125 @@ describe('registerWorkspaceCommands', () => { name: 'Ops', key: 'rk_live_…', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + relaycastWorkspaceId: 'rw_ops', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', organizationId: 'org_1', slug: 'ops', urls: {}, apiUrl: 'https://cloud.test', + dataPlane: { + unified: true, + workspaceId: 'rw_ops', + planes: { relaycast: 'rw_ops', relayfile: 'rw_ops', relayauth: 'rw_ops' }, + divergent: [], + }, }); }); + it('workspace active --json proves the three data planes share one workspace ID', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + key: 'rk_live_ops', + cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + relaycastWorkspaceId: 'rw_7ccfea89', + relayfileWorkspaceId: 'rw_7ccfea89', + relayauthWorkspaceId: 'rw_7ccfea89', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'workspace', + 'active', + '--json', + '--require-unified', + ]); + + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed.dataPlane).toEqual({ + unified: true, + workspaceId: 'rw_7ccfea89', + planes: { relaycast: 'rw_7ccfea89', relayfile: 'rw_7ccfea89', relayauth: 'rw_7ccfea89' }, + divergent: [], + }); + expect(deps.error).not.toHaveBeenCalled(); + expect(deps.exit).not.toHaveBeenCalled(); + }); + + it('workspace active --require-unified exits 1 when the planes diverge', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rw_cast', + relayfileWorkspaceId: 'rw_file', + relayauthWorkspaceId: 'rw_cast', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await expect( + program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json', '--require-unified']) + ).rejects.toThrow('exit:1'); + + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed.dataPlane).toMatchObject({ unified: false, divergent: ['relayfile'] }); + expect(printed.dataPlane.workspaceId).toBeUndefined(); + expect(vi.mocked(deps.error).mock.calls.flat().join('\n')).toContain('not durable'); + }); + + it('workspace active warns but still succeeds on divergence without --require-unified', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + key: 'rk_live_ops', + cloudWorkspaceId: 'rw_ops', + relaycastWorkspaceId: 'rw_cast', + relayfileWorkspaceId: 'rw_file', + relayauthWorkspaceId: 'rw_cast', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json']); + + expect(deps.error).toHaveBeenCalled(); + expect(deps.exit).not.toHaveBeenCalled(); + }); + + it('workspace active prints every plane ID, including Relaycast, in human output', async () => { + const { program, deps } = createHarness(); + vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ + name: 'default', + key: 'rk_live_ops', + cloudWorkspaceId: 'cloud-uuid', + relaycastWorkspaceId: 'rw_7ccfea89', + relayfileWorkspaceId: 'rw_7ccfea89', + relayauthWorkspaceId: 'rw_7ccfea89', + urls: {}, + apiUrl: 'https://cloud.test', + }); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('Relaycast workspace ID: rw_7ccfea89'); + expect(output).toContain('Relayfile workspace ID: rw_7ccfea89'); + expect(output).toContain('Relayauth workspace ID: rw_7ccfea89'); + expect(output).toContain('Data-plane workspace ID: rw_7ccfea89 (unified)'); + // Human output must not carry the credential that unlocks the workspace. + expect(output).not.toContain('rk_live_ops'); + }); + it('workspace active --json includes raw keys only with --reveal-secrets', async () => { const { program, deps } = createHarness(); vi.mocked(resolveActiveWorkspace).mockResolvedValueOnce({ name: 'Ops', key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + relaycastWorkspaceId: 'rw_ops', relaycastApiKey: 'rk_live_castkey01', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', @@ -126,7 +236,7 @@ describe('registerWorkspaceCommands', () => { name: 'Ops', key: 'rk_live_ops', cloudWorkspaceId: 'rw_ops', - relaycastWorkspaceId: 'rc_ops', + relaycastWorkspaceId: 'rw_ops', relaycastApiKey: 'rk_live_castkey01', relayfileWorkspaceId: 'rw_ops', relayauthWorkspaceId: 'rw_ops', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 2e4a0f7c2..abc7192a4 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -1,6 +1,10 @@ import type { Command } from 'commander'; import { InvalidArgumentError } from 'commander'; -import { resolveActiveWorkspace } from '@agent-relay/cloud'; +import { + describeDataPlaneConvergence, + formatDataPlaneDivergence, + resolveActiveWorkspace, +} from '@agent-relay/cloud'; import { maskSecret } from '../lib/redact.js'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; @@ -30,6 +34,10 @@ export function registerWorkspaceCommands( .option('--api-url ', 'Cloud API base URL') .option('--json', 'Output the active workspace as JSON (keys masked unless --reveal-secrets)') .option('--reveal-secrets', 'Include raw workspace keys in --json output') + .option( + '--require-unified', + 'Exit non-zero when Relaycast, Relayfile, and RelayAuth do not share one data-plane workspace ID' + ) .option( '--refresh-timeout ', 'Timeout for refreshing the cloud session', @@ -40,6 +48,7 @@ export function registerWorkspaceCommands( apiUrl?: string; json?: boolean; revealSecrets?: boolean; + requireUnified?: boolean; refreshTimeout?: number; }) => { await runSdk(deps, async () => { @@ -48,27 +57,44 @@ export function registerWorkspaceCommands( interactive: false, refreshTimeoutMs: options.refreshTimeout, }); + // The evidence for the durability invariant. Emitted on every call so + // `workspace active --json` is self-sufficient proof, not something a + // caller has to recompute from the three per-plane ids. + const dataPlane = describeDataPlaneConvergence(workspace); if (options.json) { printJson( deps, options.revealSecrets - ? workspace + ? { ...workspace, dataPlane } : { ...workspace, key: maskSecret(workspace.key), ...(workspace.relaycastApiKey ? { relaycastApiKey: maskSecret(workspace.relaycastApiKey) } : {}), + dataPlane, } ); - return; + } else { + deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`); + deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`); + deps.log(`Relaycast workspace ID: ${workspace.relaycastWorkspaceId}`); + deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`); + deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`); + deps.log( + dataPlane.unified + ? `Data-plane workspace ID: ${dataPlane.workspaceId} (unified)` + : `Data-plane workspace ID: divergent across ${dataPlane.divergent.join(', ')}` + ); } - deps.log(`Workspace: ${workspace.name ?? workspace.cloudWorkspaceId}`); - deps.log(`Cloud workspace ID: ${workspace.cloudWorkspaceId}`); - deps.log(`Relayfile workspace ID: ${workspace.relayfileWorkspaceId}`); - deps.log(`Relayauth workspace ID: ${workspace.relayauthWorkspaceId}`); + if (!dataPlane.unified) { + deps.error(formatDataPlaneDivergence(dataPlane)); + if (options.requireUnified) { + deps.exit(1); + } + } }); } ); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index d598694e9..39f983b8d 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -25,6 +25,9 @@ import { maskSecret } from './redact.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; import { projectWorkspaceKeyPath, writeProjectWorkspaceKey } from './project-workspace-key.js'; import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; +// The narrow subpath, not the package barrel: broker startup must not drag the +// cloud package's HTTP/auth surface into its module graph. +import { resolveActiveWorkspaceKey } from '@agent-relay/cloud/workspace-key'; type UpOptions = { spawn?: boolean; @@ -1314,8 +1317,22 @@ function readPinnedProjectWorkspaceSession( } } -/** Resume the pinned project session unless explicit credentials override it. */ -function resumePinnedProjectWorkspace( +/** + * Resolve the workspace this broker start must join, and apply it to the env. + * + * Precedence, highest first: + * 1. an explicit `--workspace-key`/env key, or a node token that already + * implies a workspace — the operator chose; don't second-guess it; + * 2. the project pin written by a previous start in this checkout; + * 3. the machine-global canonical workspace (`agent-relay workspace switch`). + * + * Step 3 is what makes identity durable: without it a start with no project pin + * falls through to the broker, which mints a brand-new messaging-only workspace + * and hands every resident agent a new address. Resolving the canonical + * workspace here means `node up` needs no manual key copying, and the key is + * pinned to the project after startup so later starts take step 2. + */ +function resolveStartupWorkspace( options: UpOptions, deps: CoreDependencies, projectDataDir: string @@ -1327,13 +1344,41 @@ function resumePinnedProjectWorkspace( const session = readPinnedProjectWorkspaceSession(projectDataDir, deps); if (session) { - deps.env.RELAY_WORKSPACE_KEY = session.workspaceKey; - deps.env.RELAY_API_KEY = session.workspaceKey; - if (session.enrolledNodeId) { - deps.env.AGENT_RELAY_ENROLLED_NODE_ID = session.enrolledNodeId; - } + applyStartupWorkspace(session, deps); + return session; + } + + const canonicalKey = readCanonicalWorkspaceKey(deps); + if (!canonicalKey) { + return undefined; + } + const canonical: PinnedProjectWorkspaceSession = { workspaceKey: canonicalKey }; + applyStartupWorkspace(canonical, deps); + // Identifies the source, never the key — the key is a live credential. + deps.log('Using the machine-global canonical Agent Relay workspace for this node.'); + return canonical; +} + +/** Apply a resolved workspace session to the env the broker (and any detached child) inherits. */ +function applyStartupWorkspace(session: PinnedProjectWorkspaceSession, deps: CoreDependencies): void { + deps.env.RELAY_WORKSPACE_KEY = session.workspaceKey; + deps.env.RELAY_API_KEY = session.workspaceKey; + if (session.enrolledNodeId) { + deps.env.AGENT_RELAY_ENROLLED_NODE_ID = session.enrolledNodeId; + } +} + +/** + * Read the active workspace from the machine-global store. A malformed or + * unreadable store must not abort startup — the broker can still come up on a + * fresh workspace, which is strictly better than refusing to start. + */ +function readCanonicalWorkspaceKey(deps: CoreDependencies): string | undefined { + try { + return resolveActiveWorkspaceKey(deps.env)?.trim() || undefined; + } catch { + return undefined; } - return session; } export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise { @@ -1346,7 +1391,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): // --state-dir), so the key must be persisted here even when broker state is // redirected elsewhere. const projectWorkspaceKeyDataDir = paths.dataDir; - const resumedProjectSession = resumePinnedProjectWorkspace(options, deps, projectWorkspaceKeyDataDir); + const resumedProjectSession = resolveStartupWorkspace(options, deps, projectWorkspaceKeyDataDir); // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { const resolved = path.resolve(options.stateDir); @@ -1841,6 +1886,11 @@ export async function runStatusCommand( if (session?.node_id) { deps.log(`Node: ${session.node_name?.trim() || session.node_id} (${session.node_id})`); } + // The workspace ID is the durable identity an operator compares across a + // stop/start; the key that unlocks it is a credential and stays masked. + if (session?.default_workspace_id) { + deps.log(`Workspace: ${session.default_workspace_id}`); + } if (session?.workspace_key) { deps.log(`Workspace Key: ${maskSecret(session.workspace_key)}`); } diff --git a/packages/cli/src/cli/lib/workspace-identity-restart.test.ts b/packages/cli/src/cli/lib/workspace-identity-restart.test.ts new file mode 100644 index 000000000..c0cc5b276 --- /dev/null +++ b/packages/cli/src/cli/lib/workspace-identity-restart.test.ts @@ -0,0 +1,341 @@ +/** + * AR-448 regression: the canonical workspace, and therefore the resident + * agent's address, must survive a full node stop/start. + * + * The failure this guards against is quiet. A node started with no project pin + * used to fall through to the broker, which mints a brand-new messaging-only + * workspace. Everything still "works" — the broker comes up, the agent + * registers — but the agent is a stranger in a different workspace with a new + * address, so DMs sent to its old address go nowhere. + * + * The harness below models the two pieces of real behavior that make identity + * durable or not: + * - the broker joins `RELAY_WORKSPACE_KEY` when set, and otherwise mints a + * fresh workspace (see `startup_single_session_set_from_sources` in + * crates/broker/src/relaycast/auth.rs); + * - Relaycast returns the EXISTING agent when a name is re-registered in a + * workspace it already belongs to, and mints a new one otherwise. + */ + +import fsReal from 'node:fs'; +import os from 'node:os'; +import pathReal from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../telemetry/index.js', () => ({ track: vi.fn() })); +vi.mock('./reflex-capture.js', () => ({ + startReflexCapture: vi.fn(() => ({ stop: vi.fn(async () => undefined) })), +})); +vi.mock('@agent-relay/fleet', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startServeNode: vi.fn(() => ({ stop: vi.fn(async () => undefined), done: Promise.resolve() })), + }; +}); +vi.mock('@agent-relay/harness-driver', () => ({ + HarnessDriverClient: class { + async getSession() { + return { node_id: 'node_a', node_name: 'the-node', broker_version: 'test', protocol_version: 2 }; + } + async getStatus() { + return {}; + } + disconnect() {} + }, +})); + +import { runUpCommand } from './broker-lifecycle.js'; +import { readProjectWorkspaceSession } from './project-workspace-key.js'; +import type { CoreDependencies } from '../commands/core.js'; + +const RESIDENT_AGENT = 'khaliq-chief'; +const CANONICAL_KEY = 'rk_live_canonical0001'; + +const tmpRoots: string[] = []; + +function mkTmp(prefix: string): string { + const dir = fsReal.mkdtempSync(pathReal.join(os.tmpdir(), prefix)); + tmpRoots.push(dir); + return dir; +} + +/** + * A stand-in for Relaycast, shared by every checkout in a test the way the real + * cloud is shared by every node: an agent name registered twice in the same + * workspace keeps its address; the same name in a different workspace is a + * different agent. Workspace keys it mints are globally unique. + */ +const relaycast = (() => { + const addresses = new Map(); + let mintedWorkspaces = 0; + let mintedAgents = 0; + return { + mintWorkspaceKey: () => `rk_live_minted${(mintedWorkspaces += 1)}`, + register(workspaceKey: string, agentName: string): string { + const slot = `${workspaceKey}::${agentName}`; + const existing = addresses.get(slot); + if (existing) return existing; + const address = `agent_${(mintedAgents += 1)}@${workspaceKey}`; + addresses.set(slot, address); + return address; + }, + reset() { + addresses.clear(); + mintedWorkspaces = 0; + mintedAgents = 0; + }, + }; +})(); + +afterEach(() => { + relaycast.reset(); + for (const dir of tmpRoots.splice(0)) { + fsReal.rmSync(dir, { recursive: true, force: true }); + } +}); + +/** + * One machine across restarts: a stable project checkout and a stable + * `AGENT_RELAY_HOME`. Each `start()` is a fresh CLI process — new env, new + * deps — over that same persistent state, which is exactly what a stop/start + * looks like from the CLI's point of view. + */ +function createMachine(options: { canonicalWorkspaceKey?: string } = {}) { + const projectRoot = mkTmp('ar448-project-'); + const relayHome = mkTmp('ar448-home-'); + const dataDir = pathReal.join(projectRoot, '.agentworkforce', 'relay'); + fsReal.mkdirSync(dataDir, { recursive: true }); + + if (options.canonicalWorkspaceKey) { + fsReal.writeFileSync( + pathReal.join(relayHome, 'workspaces.json'), + JSON.stringify({ + active: 'default', + workspaces: { default: { key: options.canonicalWorkspaceKey } }, + }) + ); + } + + const connection = JSON.stringify({ + url: 'http://127.0.0.1:4999', + port: 4999, + api_key: 'test', + pid: 999999, + }); + + async function start(): Promise<{ + workspaceKey: string; + residentAddress: string; + log: string[]; + }> { + const env: NodeJS.ProcessEnv = { AGENT_RELAY_HOME: relayHome }; + const log: string[] = []; + + const deps = { + getProjectPaths: () => ({ projectRoot, dataDir, teamDir: projectRoot }), + loadTeamsConfig: () => null, + // Mirrors the broker: join the env-selected workspace, or mint a new one. + createRelay: vi.fn(async () => { + const workspaceKey = env.RELAY_WORKSPACE_KEY?.trim() || relaycast.mintWorkspaceKey(); + return { + spawn: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({})), + shutdown: vi.fn(async () => undefined), + workspaceKey, + }; + }), + spawnProcess: vi.fn(), + execCommand: vi.fn(async () => ({ stdout: '', stderr: '' })), + killProcess: vi.fn(() => { + throw new Error('not running'); + }), + fs: { + existsSync: fsReal.existsSync, + readFileSync: (file: string, encoding: BufferEncoding) => + file.endsWith('connection.json') ? connection : fsReal.readFileSync(file, encoding), + writeFileSync: fsReal.writeFileSync, + unlinkSync: fsReal.unlinkSync, + readdirSync: fsReal.readdirSync, + mkdirSync: fsReal.mkdirSync, + rmSync: fsReal.rmSync, + accessSync: fsReal.accessSync, + }, + generateAgentName: () => RESIDENT_AGENT, + checkForUpdates: vi.fn(async () => ({ updateAvailable: false })), + getVersion: () => 'test', + env, + argv: ['node', 'agent-relay', 'node', 'up'], + execPath: process.execPath, + cliScript: 'cli.js', + pid: process.pid, + isPortInUse: vi.fn(async () => false), + now: () => 0, + sleep: async () => undefined, + onSignal: vi.fn(), + holdOpen: async () => undefined, + log: (...args: unknown[]) => log.push(args.join(' ')), + warn: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as CoreDependencies; + + await runUpCommand({ discoverConfig: true }, deps); + + const relay = await vi.mocked(deps.createRelay).mock.results[0]!.value; + const workspaceKey = relay.workspaceKey as string; + return { + workspaceKey, + residentAddress: relaycast.register(workspaceKey, RESIDENT_AGENT), + log, + }; + } + + return { start, dataDir, projectRoot }; +} + +describe('workspace identity across a node stop/start', () => { + it('keeps one workspace and one resident address across a restart', async () => { + const machine = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + + const first = await machine.start(); + // Stopping the node is just process exit — nothing in the CLI's state is + // torn down, so the second start below is a genuine cold start. + const second = await machine.start(); + + expect(first.workspaceKey).toBe(CANONICAL_KEY); + expect(second.workspaceKey).toBe(CANONICAL_KEY); + expect(second.residentAddress).toBe(first.residentAddress); + }); + + it('joins the canonical workspace on a first start with no project pin', async () => { + const machine = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + + const first = await machine.start(); + + // No `--workspace-key`, no env, no pre-existing pin: the canonical + // workspace is picked up from the machine-global store, not minted. + expect(first.workspaceKey).toBe(CANONICAL_KEY); + expect(first.log.join('\n')).toContain('machine-global canonical Agent Relay workspace'); + }); + + it('pins the canonical workspace to the project so later starts resume it directly', async () => { + const machine = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + + await machine.start(); + + expect(readProjectWorkspaceSession(machine.dataDir)?.workspaceKey).toBe(CANONICAL_KEY); + }); + + it('never prints the workspace key that identifies the node', async () => { + const machine = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + + const first = await machine.start(); + + const output = first.log.join('\n'); + expect(output).toContain('Workspace Key:'); + expect(output).not.toContain(CANONICAL_KEY); + }); + + it('mints a throwaway workspace per start when no canonical workspace is set', async () => { + // The pre-AR-448 behavior, kept as the negative control: with nothing to + // anchor identity to, the project pin is the ONLY thing holding the node + // together — and a checkout that never pinned drifts on every start. + const machine = createMachine(); + + const first = await machine.start(); + const second = await machine.start(); + + // The pin written by the first start does hold this checkout steady... + expect(second.workspaceKey).toBe(first.workspaceKey); + expect(second.residentAddress).toBe(first.residentAddress); + + // ...but a second checkout on the same machine, with no canonical + // workspace to join, is a different node entirely. + const otherCheckout = createMachine(); + const elsewhere = await otherCheckout.start(); + expect(elsewhere.workspaceKey).not.toBe(first.workspaceKey); + expect(elsewhere.residentAddress).not.toBe(first.residentAddress); + }); + + it('lets a second checkout share the canonical workspace and the resident address', async () => { + // A Chief moved to a new clone, or a second Chief on the same company + // workspace, resolves it without anyone copying a key by hand. + const original = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + const clone = createMachine({ canonicalWorkspaceKey: CANONICAL_KEY }); + + const first = await original.start(); + const second = await clone.start(); + + expect(second.workspaceKey).toBe(first.workspaceKey); + expect(second.residentAddress).toBe(first.residentAddress); + }); +}); + +describe('explicit workspace selection still wins', () => { + it('does not override an explicit --workspace-key with the canonical store', async () => { + const projectRoot = mkTmp('ar448-explicit-'); + const relayHome = mkTmp('ar448-explicit-home-'); + fsReal.mkdirSync(pathReal.join(projectRoot, '.agentworkforce', 'relay'), { recursive: true }); + fsReal.writeFileSync( + pathReal.join(relayHome, 'workspaces.json'), + JSON.stringify({ active: 'default', workspaces: { default: { key: CANONICAL_KEY } } }) + ); + + const env: NodeJS.ProcessEnv = { AGENT_RELAY_HOME: relayHome }; + const deps = { + getProjectPaths: () => ({ + projectRoot, + dataDir: pathReal.join(projectRoot, '.agentworkforce', 'relay'), + teamDir: projectRoot, + }), + loadTeamsConfig: () => null, + createRelay: vi.fn(async () => ({ + spawn: vi.fn(async () => undefined), + getStatus: vi.fn(async () => ({})), + shutdown: vi.fn(async () => undefined), + workspaceKey: env.RELAY_WORKSPACE_KEY, + })), + spawnProcess: vi.fn(), + execCommand: vi.fn(async () => ({ stdout: '', stderr: '' })), + killProcess: vi.fn(() => { + throw new Error('not running'); + }), + fs: { + existsSync: fsReal.existsSync, + readFileSync: (file: string, encoding: BufferEncoding) => + file.endsWith('connection.json') + ? JSON.stringify({ url: 'http://127.0.0.1:4999', port: 4999, api_key: 't', pid: 999999 }) + : fsReal.readFileSync(file, encoding), + writeFileSync: fsReal.writeFileSync, + unlinkSync: fsReal.unlinkSync, + readdirSync: fsReal.readdirSync, + mkdirSync: fsReal.mkdirSync, + rmSync: fsReal.rmSync, + accessSync: fsReal.accessSync, + }, + generateAgentName: () => RESIDENT_AGENT, + checkForUpdates: vi.fn(async () => ({ updateAvailable: false })), + getVersion: () => 'test', + env, + argv: ['node', 'agent-relay', 'node', 'up'], + execPath: process.execPath, + cliScript: 'cli.js', + pid: process.pid, + isPortInUse: vi.fn(async () => false), + now: () => 0, + sleep: async () => undefined, + onSignal: vi.fn(), + holdOpen: async () => undefined, + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + } as unknown as CoreDependencies; + + await runUpCommand({ discoverConfig: true, workspaceKey: 'rk_live_explicit0001' }, deps); + + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_explicit0001'); + }); +}); diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index bcdd014a5..55e21b2e1 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -65,6 +65,12 @@ export { } from './connect.js'; export { createWorkspace, issueWorkspaceToken, resolveActiveWorkspace } from './workspaces.js'; +export { + describeDataPlaneConvergence, + formatDataPlaneDivergence, + type DataPlaneConvergence, + type DataPlaneWorkspaceIds, +} from './workspace-convergence.js'; export { redactCredentialValues } from './redact.js'; export { diff --git a/packages/cloud/src/workspace-convergence.test.ts b/packages/cloud/src/workspace-convergence.test.ts new file mode 100644 index 000000000..c1360bf79 --- /dev/null +++ b/packages/cloud/src/workspace-convergence.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { describeDataPlaneConvergence, formatDataPlaneDivergence } from './workspace-convergence.js'; + +describe('describeDataPlaneConvergence', () => { + it('reports one shared data-plane ID when all three planes agree', () => { + expect( + describeDataPlaneConvergence({ + relaycastWorkspaceId: 'rw_7ccfea89', + relayfileWorkspaceId: 'rw_7ccfea89', + relayauthWorkspaceId: 'rw_7ccfea89', + }) + ).toEqual({ + unified: true, + workspaceId: 'rw_7ccfea89', + planes: { relaycast: 'rw_7ccfea89', relayfile: 'rw_7ccfea89', relayauth: 'rw_7ccfea89' }, + divergent: [], + }); + }); + + it('names the planes that disagree and withholds a shared ID', () => { + const convergence = describeDataPlaneConvergence({ + relaycastWorkspaceId: 'rw_a', + relayfileWorkspaceId: 'rw_b', + relayauthWorkspaceId: 'rw_a', + }); + + expect(convergence.unified).toBe(false); + expect(convergence.workspaceId).toBeUndefined(); + expect(convergence.divergent).toEqual(['relayfile']); + }); + + it('treats a cloud control-plane ID as irrelevant to data-plane convergence', () => { + // The cloud workspace is a UUID in a different id space; including it would + // make every healthy workspace look divergent. + expect( + describeDataPlaneConvergence({ + relaycastWorkspaceId: 'rw_same', + relayfileWorkspaceId: 'rw_same', + relayauthWorkspaceId: 'rw_same', + }).unified + ).toBe(true); + }); + + it('describes a divergence with every plane ID so the report is actionable', () => { + const message = formatDataPlaneDivergence( + describeDataPlaneConvergence({ + relaycastWorkspaceId: 'rw_a', + relayfileWorkspaceId: 'rw_b', + relayauthWorkspaceId: 'rw_c', + }) + ); + + expect(message).toContain('relaycast=rw_a'); + expect(message).toContain('relayfile=rw_b'); + expect(message).toContain('relayauth=rw_c'); + }); +}); diff --git a/packages/cloud/src/workspace-convergence.ts b/packages/cloud/src/workspace-convergence.ts new file mode 100644 index 000000000..a8ad66ac1 --- /dev/null +++ b/packages/cloud/src/workspace-convergence.ts @@ -0,0 +1,74 @@ +/** + * The data-plane workspace-identity invariant. + * + * A workspace is only durable if Relaycast, Relayfile, and RelayAuth all resolve + * it to the SAME data-plane workspace id. When they diverge, each plane keeps + * its own view of who is a member, so a node restart can re-register an agent + * against a different plane and hand it a new address — the exact failure this + * check exists to make visible before it happens. + * + * The Cloud workspace id is deliberately excluded: it is the control-plane + * record that *points at* the data plane, and it legitimately uses a different + * id space (a UUID rather than an `rw_` identity). + */ + +import type { ActiveWorkspaceDescriptor } from './types.js'; + +/** The three data planes that must agree on one workspace identity. */ +export interface DataPlaneWorkspaceIds { + relaycast: string; + relayfile: string; + relayauth: string; +} + +export interface DataPlaneConvergence { + /** True when all three planes report one identical workspace id. */ + unified: boolean; + /** The single shared data-plane id. Present only when `unified`. */ + workspaceId?: string; + /** Per-plane ids, always present so a divergence report names the culprits. */ + planes: DataPlaneWorkspaceIds; + /** Plane names that disagree with the majority/first id. Empty when unified. */ + divergent: Array; +} + +/** + * Describe whether a resolved workspace satisfies the data-plane identity + * invariant. Pure and side-effect free: callers decide whether a divergence is + * a warning or a hard failure. + */ +export function describeDataPlaneConvergence( + workspace: Pick< + ActiveWorkspaceDescriptor, + 'relaycastWorkspaceId' | 'relayfileWorkspaceId' | 'relayauthWorkspaceId' + > +): DataPlaneConvergence { + const planes: DataPlaneWorkspaceIds = { + relaycast: workspace.relaycastWorkspaceId, + relayfile: workspace.relayfileWorkspaceId, + relayauth: workspace.relayauthWorkspaceId, + }; + + const entries = Object.entries(planes) as Array<[keyof DataPlaneWorkspaceIds, string]>; + const [, reference] = entries[0]!; + const divergent = entries.filter(([, id]) => id !== reference).map(([plane]) => plane); + + return divergent.length === 0 + ? { unified: true, workspaceId: reference, planes, divergent: [] } + : { unified: false, planes, divergent }; +} + +/** + * Human-readable one-liner naming which planes disagree. Contains no secrets — + * workspace ids are identifiers, not credentials. + */ +export function formatDataPlaneDivergence(convergence: DataPlaneConvergence): string { + const detail = (Object.entries(convergence.planes) as Array<[string, string]>) + .map(([plane, id]) => `${plane}=${id}`) + .join(', '); + return ( + 'Workspace identity is not durable: Relaycast, Relayfile, and RelayAuth resolve ' + + `to different data-plane workspaces (${detail}). Agents re-registering after a ` + + 'node restart may be issued a new address.' + ); +} diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index a2a153cdf..5139d4e3c 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -1,3 +1,12 @@ +// The machine-global store is the last step of the same precedence chain the +// project key participates in, so it belongs on this subpath: startup paths can +// resolve a workspace without pulling in the cloud package's HTTP surface. +export { + resolveActiveWorkspaceKey, + workspaceStorePath, + type WorkspaceStore, +} from './workspace-store.js'; + export { projectWorkspaceKeyPath, readProjectWorkspaceKey, diff --git a/specs/workspace-identity.md b/specs/workspace-identity.md new file mode 100644 index 000000000..a1bd9f3a1 --- /dev/null +++ b/specs/workspace-identity.md @@ -0,0 +1,164 @@ +# Workspace Identity — Durable Across Node Restarts + +**Status**: Implemented +**Date**: 2026-07-31 +**Tracking**: AR-448 + +--- + +## 1. The invariant + +> A local Relay node, and every resident agent on it, keeps the same workspace +> identity and the same delivery address across a full stop/start. + +Two things have to hold for that to be true. + +**One workspace per node, chosen — not minted.** A node start must join a +workspace that already existed, unless the operator has explicitly asked for a +new one. + +**One data-plane ID per workspace.** Relaycast, Relayfile, and RelayAuth must +all resolve the canonical workspace to the *same* `rw_…` identity. The Cloud +workspace ID is deliberately excluded from the comparison: it is the +control-plane record that points at the data plane, and it lives in a different +ID space (a UUID). + +When both hold, re-registering an agent name after a restart returns the +existing agent — same ID, same address, same inbox — because Relaycast finds +that name already in the workspace. + +## 2. How a node picks its workspace + +`agent-relay node up` (and the `local up` alias) resolves the workspace in this +order, highest priority first: + +1. **Explicit selection** — `--workspace-key` / `--wk`, `RELAY_WORKSPACE_KEY`, + `AGENT_RELAY_WORKSPACE_KEY`, `RELAY_API_KEY`, or a `RELAY_NODE_TOKEN` that + already implies a workspace. The operator chose; nothing overrides it. +2. **Project pin** — `.agentworkforce/relay/workspace-key.json` in the + checkout, written by the previous successful start. +3. **Machine-global canonical workspace** — the active entry in + `$AGENT_RELAY_HOME/workspaces.json` (default + `~/.agentworkforce/relay/workspaces.json`), set by + `agent-relay workspace join|switch|create`. +4. **Mint a new workspace** — last resort. The broker creates a + messaging-only workspace so it can come up at all. + +Step 3 is what AR-448 added, and it is what makes identity durable. Without it, +a start with no project pin fell straight through to step 4: the broker minted a +brand-new workspace, the resident agent registered into it as a stranger, and +every message addressed to its previous address went nowhere. Nothing errored — +the node came up and the agent looked healthy. + +After a successful start the resolved key is pinned to the project, so the next +start takes step 2 and does not need to consult the store at all. + +## 3. Proving the invariant + +``` +$ agent-relay workspace active --json +{ + "name": "default", + "key": "rk_live_…de99", + "cloudWorkspaceId": "50587328-441d-4acb-b8f3-dbe1b3c5de99", + "relaycastWorkspaceId": "rw_7ccfea89", + "relayfileWorkspaceId": "rw_7ccfea89", + "relayauthWorkspaceId": "rw_7ccfea89", + "dataPlane": { + "unified": true, + "workspaceId": "rw_7ccfea89", + "planes": { + "relaycast": "rw_7ccfea89", + "relayfile": "rw_7ccfea89", + "relayauth": "rw_7ccfea89" + }, + "divergent": [] + } +} +``` + +`dataPlane` is emitted on every call, so the JSON is self-sufficient evidence +rather than something a caller has to recompute from the three plane IDs. On a +divergence, `unified` is `false`, `workspaceId` is absent, and `divergent` names +the planes that disagree. + +By default a divergence is reported on stderr and the command still exits 0. +Pass `--require-unified` to turn it into a hard gate — that is the form +supervisors and setup doctors should use: + +``` +$ agent-relay workspace active --json --require-unified +``` + +To check that a restart preserved identity, compare the workspace ID reported by +the node itself: + +``` +$ agent-relay node status +Status: RUNNING +Node: kjglaptop (node_abc) +Workspace: rw_7ccfea89 +Workspace Key: rk_live_…de99 +``` + +`Workspace:` is the durable identity; the key beneath it is a live credential +and is always masked. + +## 4. Secrets + +Status output and startup logs never print a raw workspace key, agent token, +node token, or a credential-bearing observer URL. + +- Keys that are printed on purpose go through `maskSecret` — prefix plus last + four characters. +- Error and log *text* goes through `redactCredentialValues`, which catches + credentials embedded in URL paths and query strings, where key-name redaction + cannot help. +- Structured dumps go through `redactSecrets`, which replaces the value of any + credential-named key. +- The canonical-workspace fallback logs only that it used the machine-global + workspace. It never names the key. + +## 5. Migration for existing local nodes + +No action is required, and nothing is rewritten on upgrade. + +- **A node with a project pin** keeps using that pinned workspace. The new + fallback sits below the pin in precedence and never overrides it. +- **A node with no project pin** now joins the machine-global canonical + workspace on its next start instead of minting a fresh one. If that node had + been drifting onto a new workspace each restart, this is the fix — but its + resident agents move to the canonical workspace, and any address someone + recorded from a previous throwaway workspace stops resolving. That address was + already invalid after the next restart. +- **A machine with no canonical workspace set** behaves exactly as before: + the broker mints one. Set one with `agent-relay workspace join ` + (or `switch`) to opt into durable identity. +- **A node pinned to a workspace you no longer want** re-pins on the next start + after an explicit `--workspace-key`, since step 1 wins and the resolved key is + written back to the project pin. + +To move an existing node onto the canonical workspace deliberately: + +``` +$ agent-relay workspace switch default +$ rm .agentworkforce/relay/workspace-key.json # drop the stale project pin +$ agent-relay node down && agent-relay node up +$ agent-relay node status # Workspace: +``` + +## 6. Coverage + +| Guarantee | Test | +|---|---| +| Workspace and resident address survive a stop/start | `packages/cli/src/cli/lib/workspace-identity-restart.test.ts` | +| First start with no pin joins the canonical workspace | same | +| The resolved workspace is pinned to the project | same | +| Explicit `--workspace-key` still wins | same | +| A second checkout shares the canonical workspace | same | +| Precedence: pin over canonical store | `packages/cli/src/cli/commands/core.test.ts` | +| No canonical workspace ⇒ unchanged legacy behavior | same | +| `node status` shows the workspace ID and leaks no credential | same | +| `workspace active` emits convergence evidence | `packages/cli/src/cli/commands/workspace.test.ts` | +| `--require-unified` exits non-zero on divergence | same | +| Convergence detection itself | `packages/cloud/src/workspace-convergence.test.ts` | From 00dab6e9dddc3dcbb70912bcf6cf78ff569e39e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 30 Jul 2026 22:57:13 +0000 Subject: [PATCH 2/2] style: auto-format with Prettier --- packages/cli/src/cli/commands/core.test.ts | 4 +-- .../cli/src/cli/commands/workspace.test.ts | 9 +----- packages/cloud/src/workspace-key.ts | 6 +--- specs/workspace-identity.md | 30 +++++++++---------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index 68e59ba21..9166e9865 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -1246,9 +1246,7 @@ describe('registerCoreCommands', () => { await runCommand(program, ['status']); expect(deps.log).toHaveBeenCalledWith('Workspace: rw_7ccfea89'); - const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls - .flat() - .join('\n'); + const output = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().join('\n'); expect(output).not.toContain('rk_live_teststatus123'); expect(output).not.toContain('nt_live_nodetoken456'); // Observer URLs carry a scoped token in the query string; status never diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 03d3b85f4..981467024 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -125,14 +125,7 @@ describe('registerWorkspaceCommands', () => { apiUrl: 'https://cloud.test', }); - await program.parseAsync([ - 'node', - 'agent-relay', - 'workspace', - 'active', - '--json', - '--require-unified', - ]); + await program.parseAsync(['node', 'agent-relay', 'workspace', 'active', '--json', '--require-unified']); const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); expect(printed.dataPlane).toEqual({ diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index 5139d4e3c..4f731901c 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -1,11 +1,7 @@ // The machine-global store is the last step of the same precedence chain the // project key participates in, so it belongs on this subpath: startup paths can // resolve a workspace without pulling in the cloud package's HTTP surface. -export { - resolveActiveWorkspaceKey, - workspaceStorePath, - type WorkspaceStore, -} from './workspace-store.js'; +export { resolveActiveWorkspaceKey, workspaceStorePath, type WorkspaceStore } from './workspace-store.js'; export { projectWorkspaceKeyPath, diff --git a/specs/workspace-identity.md b/specs/workspace-identity.md index a1bd9f3a1..6c25fc45e 100644 --- a/specs/workspace-identity.md +++ b/specs/workspace-identity.md @@ -18,7 +18,7 @@ workspace that already existed, unless the operator has explicitly asked for a new one. **One data-plane ID per workspace.** Relaycast, Relayfile, and RelayAuth must -all resolve the canonical workspace to the *same* `rw_…` identity. The Cloud +all resolve the canonical workspace to the _same_ `rw_…` identity. The Cloud workspace ID is deliberately excluded from the comparison: it is the control-plane record that points at the data plane, and it lives in a different ID space (a UUID). @@ -111,7 +111,7 @@ node token, or a credential-bearing observer URL. - Keys that are printed on purpose go through `maskSecret` — prefix plus last four characters. -- Error and log *text* goes through `redactCredentialValues`, which catches +- Error and log _text_ goes through `redactCredentialValues`, which catches credentials embedded in URL paths and query strings, where key-name redaction cannot help. - Structured dumps go through `redactSecrets`, which replaces the value of any @@ -149,16 +149,16 @@ $ agent-relay node status # Workspace: ## 6. Coverage -| Guarantee | Test | -|---|---| -| Workspace and resident address survive a stop/start | `packages/cli/src/cli/lib/workspace-identity-restart.test.ts` | -| First start with no pin joins the canonical workspace | same | -| The resolved workspace is pinned to the project | same | -| Explicit `--workspace-key` still wins | same | -| A second checkout shares the canonical workspace | same | -| Precedence: pin over canonical store | `packages/cli/src/cli/commands/core.test.ts` | -| No canonical workspace ⇒ unchanged legacy behavior | same | -| `node status` shows the workspace ID and leaks no credential | same | -| `workspace active` emits convergence evidence | `packages/cli/src/cli/commands/workspace.test.ts` | -| `--require-unified` exits non-zero on divergence | same | -| Convergence detection itself | `packages/cloud/src/workspace-convergence.test.ts` | +| Guarantee | Test | +| ------------------------------------------------------------ | ------------------------------------------------------------- | +| Workspace and resident address survive a stop/start | `packages/cli/src/cli/lib/workspace-identity-restart.test.ts` | +| First start with no pin joins the canonical workspace | same | +| The resolved workspace is pinned to the project | same | +| Explicit `--workspace-key` still wins | same | +| A second checkout shares the canonical workspace | same | +| Precedence: pin over canonical store | `packages/cli/src/cli/commands/core.test.ts` | +| No canonical workspace ⇒ unchanged legacy behavior | same | +| `node status` shows the workspace ID and leaks no credential | same | +| `workspace active` emits convergence evidence | `packages/cli/src/cli/commands/workspace.test.ts` | +| `--require-unified` exits non-zero on divergence | same | +| Convergence detection itself | `packages/cloud/src/workspace-convergence.test.ts` |