From e9b31786c80867e9b6cc52dda0af6a5504666ab6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 03:52:38 +0000 Subject: [PATCH 1/3] fix(cli): resolve the broker workspace through one precedence ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-relay up` / `node up` hand-rolled a narrower workspace chain than the SDK: flag -> env -> repository pin -> give up, with a `RELAY_NODE_TOKEN` short-circuit at the top. Two failures fell out of it. A Cloud enrollment set `RELAY_NODE_TOKEN` and no workspace key, so the pin was skipped and the broker, left with no key candidates, minted a fresh workspace — re-homing an enrolled node out of its repository's workspace. And a fresh directory never consulted the machine-global store, so a first start minted a new workspace even when the machine already had an active one selected. Both now resolve through one ladder, documented in `resolveWorkspaceSelection` and in the CLI README: 1. --workspace-key / --wk 2. RELAY_WORKSPACE_KEY > AGENT_RELAY_WORKSPACE_KEY > RELAY_API_KEY 3. /.agentworkforce/relay/workspace-key.json 4. the active entry in ~/.agentworkforce/relay/workspaces.json 5. create a workspace — only when nothing above resolves The repository pin always outranks the machine-global entry, and a node token selects node identity only, so it no longer suppresses steps 1-4. Startup prints the winning source (flag name, variable, or path — never key material) and says explicitly when it created a workspace rather than joined one. Each start now records the resolved workspace id on the pin, so a later start can detect a stored enrollment pointing at a different workspace and stop with both source paths named instead of silently choosing one. Fixes #1406 Fixes #1378 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P5FD3oEnWbMbAhjsdtmmiP --- CHANGELOG.md | 3 + packages/cli/README.md | 36 +++++ packages/cli/src/cli/commands/core.ts | 5 + packages/cli/src/cli/commands/node.test.ts | 66 +++++++- packages/cli/src/cli/commands/node.ts | 66 ++++++-- .../cli/src/cli/lib/broker-lifecycle.test.ts | 104 ++++++++++++- packages/cli/src/cli/lib/broker-lifecycle.ts | 141 +++++++++++++++--- .../cli/src/cli/lib/project-workspace-key.ts | 3 + packages/cloud/src/index.ts | 3 + .../cloud/src/project-workspace-key.test.ts | 54 +++++++ packages/cloud/src/project-workspace-key.ts | 111 +++++++++++--- packages/cloud/src/workspace-key.ts | 3 + packages/harness-driver/src/client.ts | 3 + 13 files changed, 546 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da433bd49..72912262a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - CLI output no longer disappears when stdout or stderr is a pipe instead of a terminal. Node's stdio writes are asynchronous for pipes on macOS, so exiting in the same tick as the write discarded whatever was still buffered — `agent-relay cloud session --json | parser` and `$(agent-relay …)` could come back with empty stdout _and_ empty stderr, hiding the payload and the error that explained the failure. Every hard-exit path now drains stdio first. +- `agent-relay up` / `node up` resolve the workspace through one documented precedence ladder: `--workspace-key` → `RELAY_WORKSPACE_KEY`/`AGENT_RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` → the repository pin in `.agentworkforce/relay/workspace-key.json` → the machine-global active workspace in `~/.agentworkforce/relay/workspaces.json` → creating one. Startup prints the winning source (flag, variable, or file path — never key material). +- A Cloud enrollment no longer re-homes an enrolled node out of its repository's workspace. `RELAY_NODE_TOKEN` selects the node's identity, not its workspace, and no longer suppresses the repository pin; when a stored enrollment addresses a different workspace than the pin, `node up` stops and names both sources instead of silently choosing one. +- A first `up` in a fresh directory joins the machine's active workspace instead of silently creating a new one, and a start that does create a workspace says so instead of printing the same output as a join. ## [11.4.0] - 2026-08-02 diff --git a/packages/cli/README.md b/packages/cli/README.md index adfd75b97..f1e15864d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -47,6 +47,42 @@ agent-relay node agent release For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged. +### Which workspace a broker joins + +`agent-relay up` and `agent-relay node up` resolve the workspace through one +precedence ladder. The first source that resolves wins: + +| # | Source | Where it comes from | +| --- | ------------------------------- | ----------------------------------------------------------------------------- | +| 1 | Command-line flag | `--workspace-key` / `--wk` | +| 2 | Environment | `RELAY_WORKSPACE_KEY`, then `AGENT_RELAY_WORKSPACE_KEY`, then `RELAY_API_KEY` | +| 3 | Repository pin | `/.agentworkforce/relay/workspace-key.json` | +| 4 | Machine-global active workspace | the `active` entry in `~/.agentworkforce/relay/workspaces.json` | +| 5 | New workspace | created only when nothing above resolves | + +Two rules follow from the order: + +- **The repository pin always beats the machine-global active workspace.** + Switching your active workspace (`agent-relay workspace use `) never + re-homes a checkout that already pinned one. +- **A new workspace is a last resort, not a default.** A fresh directory joins + the machine's active workspace when one is selected. When nothing resolves and + a workspace is created, startup says so explicitly. + +Startup prints the winning source (a flag name, an environment variable, or a +file path — never key material): + +``` +Workspace source: repository pin (/repo/.agentworkforce/relay/workspace-key.json) +Workspace: joined rw_7ccfea89 +``` + +A Cloud enrollment (`RELAY_NODE_TOKEN`, or a record in the Fleet enrollment +store) selects the node's _identity_, not its workspace, so it never appears on +this ladder. If a stored enrollment addresses a different workspace than the +repository pin, `node up` refuses to start and names both source files rather +than silently choosing one. + ## Remote fleet agents The `fleet` command group lists and controls agents across all live nodes in diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index 5b5008160..aa5b5256e 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -59,6 +59,8 @@ export interface CoreRelay { shutdown: () => Promise; /** Agent Relay workspace key, available after the hello handshake. */ workspaceKey?: string; + /** Relay workspace id the broker joined, available after the hello handshake. */ + workspaceId?: string; /** PID of the underlying broker process, when available. */ brokerPid?: number; /** Actual HTTP API port bound by the broker, including OS-assigned ports. */ @@ -187,6 +189,9 @@ async function createDefaultRelay( get workspaceKey() { return client.workspaceKey; }, + get workspaceId() { + return client.workspaceId; + }, get brokerPid() { return client.brokerPid; }, diff --git a/packages/cli/src/cli/commands/node.test.ts b/packages/cli/src/cli/commands/node.test.ts index 572481e2d..00c658525 100644 --- a/packages/cli/src/cli/commands/node.test.ts +++ b/packages/cli/src/cli/commands/node.test.ts @@ -47,7 +47,14 @@ function createNodeHarness(opts?: { const error = vi.fn(); const warn = vi.fn(); - const core = { env, exit, log, error, warn } as unknown as CoreDependencies; + const core = { + env, + exit, + log, + error, + warn, + getProjectPaths: () => ({ projectRoot: '/repo', dataDir: '/repo/.agentworkforce/relay' }), + } as unknown as CoreDependencies; const resolveEnrollment = opts?.resolveEnrollment ?? (vi.fn(() => undefined) as unknown as NodeCommandDependencies['resolveEnrollment']); @@ -236,7 +243,7 @@ describe('registerNodeCommands', () => { expect(env.RELAY_NODE_TOKEN).toBeUndefined(); }); - it('resumes a project-pinned workspace instead of replacing it with an enrollment', async () => { + it('never adopts an enrollment for a project that pinned its own workspace', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord ) as unknown as NodeCommandDependencies['resolveEnrollment']; @@ -251,13 +258,64 @@ describe('registerNodeCommands', () => { await program.parseAsync(['node', 'up'], { from: 'user' }); + // A pin without an enrolled node id never reaches for the machine-global + // enrollment store, and no node token is applied — so `runUpCommand`'s + // precedence ladder resolves the repository pin unopposed. expect(resolveEnrollment).not.toHaveBeenCalled(); - expect(env.RELAY_WORKSPACE_KEY).toBe('rk_project_session'); - expect(env.RELAY_API_KEY).toBe('rk_project_session'); expect(env.RELAY_NODE_TOKEN).toBeUndefined(); expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); + it('refuses to start when the enrollment and the repository pin disagree (#1406)', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const { program, env, error, exit } = createNodeHarness({ + env: { AGENT_RELAY_HOME: '/tmp/relay-home-fixture' }, + resolveEnrollment, + // A previous start recorded rw_stale; the enrollment points at rw_123. + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_project_session', + enrolledNodeId: 'node_abc', + workspaceId: 'rw_stale', + })), + }); + + await expect(program.parseAsync(['node', 'up'], { from: 'user' })).rejects.toBeInstanceOf(ExitSignal); + + expect(exit).toHaveBeenCalledWith(1); + const message = error.mock.calls.flat().join('\n'); + expect(message).toContain('select different workspaces'); + expect(message).toContain('rw_stale'); + expect(message).toContain('rw_123'); + expect(message).toContain('workspace-key.json'); + // Diagnostics name sources, never credentials. + expect(message).not.toContain('rk_project_session'); + expect(message).not.toContain('nt_secret'); + expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(brokerMocks.runUpCommand).not.toHaveBeenCalled(); + }); + + it('starts normally when the enrollment matches the pinned workspace', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const { program, env } = createNodeHarness({ + env: {}, + resolveEnrollment, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_project_session', + enrolledNodeId: 'node_abc', + workspaceId: 'rw_123', + })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(env.RELAY_NODE_TOKEN).toBe('nt_secret'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + it('preserves an enrolled identity across a consecutive project-session restart', async () => { const firstResolveEnrollment = vi.fn( () => enrollmentRecord diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index 36711aa53..6ea90ed4a 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { resolveActiveFleetNodeEnrollment } from '@agent-relay/cloud'; +import { fleetNodeEnrollmentStorePath, resolveActiveFleetNodeEnrollment } from '@agent-relay/cloud'; import { addUpCommandOptions, @@ -9,7 +9,11 @@ import { type UpCommandOptions, } from './core.js'; import { runUpCommand } from '../lib/broker-lifecycle.js'; -import { readProjectWorkspaceSession, type ProjectWorkspaceSession } from '../lib/project-workspace-key.js'; +import { + projectWorkspaceKeyPath, + readProjectWorkspaceSession, + type ProjectWorkspaceSession, +} from '../lib/project-workspace-key.js'; import { promoteWorkspaceKeyEnvAlias } from '../lib/workspace-env.js'; import { registerLocalAgentCommands } from './local-agent.js'; import { registerLocalWorkflowCommands } from './local-workflow.js'; @@ -81,10 +85,41 @@ function prepareExplicitWorkspaceForNodeUp( return Boolean(options.workspaceKey?.trim() || envWorkspaceKey); } -/** Apply a project-pinned workspace without changing the persisted enrolled-node association. */ -function resumeProjectWorkspace(session: ProjectWorkspaceSession, deps: NodeCommandDependencies): void { - deps.core.env.RELAY_WORKSPACE_KEY = session.workspaceKey; - deps.core.env.RELAY_API_KEY = session.workspaceKey; +/** + * Refuse to start when the stored enrollment addresses a different workspace + * than the repository pin. + * + * The enrollment store is machine-global; the pin is per-repository. When they + * disagree, silently preferring either one re-homes the node — so name both + * sources and stop. Only possible once a previous start recorded the pin's + * workspace id; before that the two are simply passed through together (the + * pin wins for workspace selection, the enrollment for node identity) and a + * mismatched node token fails loudly at registration instead. + */ +function reportWorkspaceSourceConflict( + record: NonNullable>, + session: ProjectWorkspaceSession | undefined, + deps: NodeCommandDependencies +): boolean { + const pinnedWorkspaceId = session?.workspaceId?.trim(); + const enrolledWorkspaceId = record.relayWorkspaceId?.trim(); + if (!pinnedWorkspaceId || !enrolledWorkspaceId || pinnedWorkspaceId === enrolledWorkspaceId) { + return false; + } + + const pinPath = projectWorkspaceKeyPath(deps.core.getProjectPaths().dataDir); + deps.error( + 'Refusing to start: this repository and the stored Fleet enrollment select different workspaces.' + ); + deps.error(` repository pin ${pinPath} -> workspace ${pinnedWorkspaceId}`); + deps.error( + ` fleet enrollment ${fleetNodeEnrollmentStorePath(deps.core.env)} -> workspace ${enrolledWorkspaceId} (node ${record.nodeId})` + ); + deps.error( + 'Pass --workspace-key to choose explicitly, re-enroll this node in the pinned workspace, ' + + 'or delete the repository pin to adopt the enrollment.' + ); + return true; } /** Apply stored enrollment credentials and return the enrolled node name, when present. */ @@ -130,7 +165,14 @@ function resolveEnrollmentForProject( }); } -/** Apply an enrollment or safely resume a project workspace when its enrollment is unavailable. */ +/** + * Apply the node identity for this start. + * + * Workspace selection is NOT decided here — `runUpCommand` walks the shared + * precedence ladder (flag → env → repository pin → machine-global active) after + * this returns. This function only settles which node identity the broker runs + * as, so an enrollment can no longer suppress the repository's workspace. + */ function applyResolvedNodeSession( record: ReturnType | undefined, projectSession: ProjectWorkspaceSession | undefined, @@ -139,16 +181,12 @@ function applyResolvedNodeSession( if (record) { return applyEnrollment(record, deps); } - if (!projectSession) { - return undefined; - } - if (projectSession.enrolledNodeId) { + if (projectSession?.enrolledNodeId) { deps.core.env.AGENT_RELAY_ENROLLED_NODE_ID = projectSession.enrolledNodeId; deps.warn( `Persisted enrollment for node "${projectSession.enrolledNodeId}" was not found; resuming the pinned workspace without that node identity.` ); } - resumeProjectWorkspace(projectSession, deps); return undefined; } @@ -184,6 +222,10 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie deps.exit(1); return; } + if (record && reportWorkspaceSourceConflict(record, projectSession, deps)) { + deps.exit(1); + return; + } // Serve under the enrolled name (mirrors the old `fleet serve // --enrollment-token` behavior where --name beat the enrollment name). enrolledNodeName = applyResolvedNodeSession(record, projectSession, deps); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index 299d796cd..fceb00920 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -221,6 +221,7 @@ import fsReal from 'node:fs'; import os from 'node:os'; import pathReal from 'node:path'; import { startServeNode } from '@agent-relay/fleet'; +import { setWorkspaceKey } from '@agent-relay/cloud'; import { runUpCommand } from './broker-lifecycle.js'; import { startReflexCapture } from './reflex-capture.js'; class ExitSignal extends Error { @@ -248,6 +249,7 @@ function createUpHarness() { getStatus: vi.fn(async () => ({})), shutdown: vi.fn(async () => undefined), workspaceKey: 'rk_test', + workspaceId: 'rw_test', })); const exit = vi.fn((code: number) => { throw new ExitSignal(code); @@ -297,7 +299,19 @@ function createUpHarness() { exit, } as unknown as CoreDependencies; - return { deps, projectRoot, createRelay, log, warn, error, exit }; + // Every start now consults the machine-global workspace store, so point it at + // a scratch home instead of the developer's real one. + const home = fsReal.mkdtempSync(pathReal.join(os.tmpdir(), 'broker-lifecycle-home-')); + upTmpRoots.push(home); + (deps.env as NodeJS.ProcessEnv).AGENT_RELAY_HOME = home; + + return { deps, projectRoot, dataDir, home, createRelay, log, warn, error, exit }; +} + +/** Pin a workspace to the harness project, as a previous `up` would have. */ +function writeRepositoryPin(dataDir: string, session: Record): void { + fsReal.mkdirSync(dataDir, { recursive: true }); + fsReal.writeFileSync(pathReal.join(dataDir, 'workspace-key.json'), JSON.stringify(session, null, 2)); } afterEach(() => { @@ -480,6 +494,94 @@ describe('runUpCommand node-config gating', () => { }); }); +describe('runUpCommand workspace precedence', () => { + const readPin = (dataDir: string): Record => + JSON.parse(fsReal.readFileSync(pathReal.join(dataDir, 'workspace-key.json'), 'utf-8')); + + it('prefers the repository pin over the machine-global active workspace (#1406)', async () => { + const { deps, dataDir, home, log } = createUpHarness(); + setWorkspaceKey('stale-global', 'rk_stale_global', { AGENT_RELAY_HOME: home }); + writeRepositoryPin(dataDir, { workspaceKey: 'rk_repository', workspaceId: 'rw_repository' }); + + await runUpCommand({}, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_repository'); + expect(deps.env.RELAY_API_KEY).toBe('rk_repository'); + expect(log.mock.calls.flat().join('\n')).toContain('Workspace source: repository pin'); + }); + + it('applies the repository pin even when an enrollment node token is present (#1406)', async () => { + const { deps, dataDir } = createUpHarness(); + // The harness env already carries RELAY_NODE_TOKEN, which is exactly the + // condition that used to skip the pin and let the broker mint instead. + expect(deps.env.RELAY_NODE_TOKEN).toBeTruthy(); + writeRepositoryPin(dataDir, { workspaceKey: 'rk_repository', enrolledNodeId: 'node_a' }); + + await runUpCommand({}, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_repository'); + expect(deps.env.AGENT_RELAY_ENROLLED_NODE_ID).toBe('node_a'); + }); + + it('joins the machine-global active workspace in a fresh directory instead of minting (#1378)', async () => { + const { deps, home, log } = createUpHarness(); + setWorkspaceKey('account', 'rk_account_active', { AGENT_RELAY_HOME: home }); + + await runUpCommand({}, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_account_active'); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Workspace source: machine-global active workspace'); + expect(output).toContain('active: "account"'); + expect(output).not.toContain('created new workspace'); + }); + + it('announces a mint when no source resolves (#1378)', async () => { + const { deps, log } = createUpHarness(); + + await runUpCommand({}, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBeUndefined(); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('Workspace: none selected'); + expect(output).toContain('Workspace: created new workspace rw_test'); + }); + + it('keeps an explicit --workspace-key ahead of both stores', async () => { + const { deps, dataDir, home, log } = createUpHarness(); + setWorkspaceKey('global', 'rk_global', { AGENT_RELAY_HOME: home }); + writeRepositoryPin(dataDir, { workspaceKey: 'rk_repository' }); + + await runUpCommand({ workspaceKey: 'rk_flag' }, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_flag'); + expect(log.mock.calls.flat().join('\n')).toContain('Workspace source: command-line flag'); + }); + + it('records the resolved workspace id on the pin for later conflict detection', async () => { + const { deps, dataDir } = createUpHarness(); + + await runUpCommand({}, deps); + + expect(readPin(dataDir)).toMatchObject({ workspaceKey: 'rk_test', workspaceId: 'rw_test' }); + }); + + it('never prints workspace key material while reporting the winning source', async () => { + const { deps, dataDir, home, log, warn, error } = createUpHarness(); + setWorkspaceKey('global', 'rk_global', { AGENT_RELAY_HOME: home }); + writeRepositoryPin(dataDir, { workspaceKey: 'rk_repository' }); + + await runUpCommand({}, deps); + + const output = [log, warn, error] + .flatMap((fn) => vi.mocked(fn).mock.calls.flat()) + .map((arg) => String(arg)) + .join('\n'); + expect(output).not.toContain('rk_repository'); + expect(output).not.toContain('rk_global'); + }); +}); + describe('resolveNodeIdentityFromSession', () => { const noSleep = vi.fn(async () => {}); diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 520adb93b..3be312a83 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -24,7 +24,12 @@ import { import { describeError } from './describe-error.js'; import { maskSecret } from './redact.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; -import { projectWorkspaceKeyPath, writeProjectWorkspaceKey } from './project-workspace-key.js'; +import { + projectWorkspaceKeyPath, + resolveActiveWorkspaceSelection, + writeProjectWorkspaceKey, + type WorkspaceSelection, +} from './project-workspace-key.js'; import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; type UpOptions = { @@ -1290,6 +1295,7 @@ function planCapacitySource( interface PinnedProjectWorkspaceSession { workspaceKey: string; enrolledNodeId?: string; + workspaceId?: string; } /** Read the minimal project session needed during broker startup. */ @@ -1301,43 +1307,125 @@ function readPinnedProjectWorkspaceSession( const parsed = JSON.parse(deps.fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8')) as Partial<{ workspaceKey: string; enrolledNodeId: string; + workspaceId: string; }>; - const workspaceKey = - typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey.trim() || undefined : undefined; + const workspaceKey = trimmedOrUndefined(parsed.workspaceKey); if (!workspaceKey) { return undefined; } - const enrolledNodeId = - typeof parsed.enrolledNodeId === 'string' ? parsed.enrolledNodeId.trim() || undefined : undefined; + const enrolledNodeId = trimmedOrUndefined(parsed.enrolledNodeId); + const workspaceId = trimmedOrUndefined(parsed.workspaceId); return { workspaceKey, ...(enrolledNodeId ? { enrolledNodeId } : {}), + ...(workspaceId ? { workspaceId } : {}), }; } catch { return undefined; } } -/** Resume the pinned project session unless explicit credentials override it. */ -function resumePinnedProjectWorkspace( +/** Narrow an unknown JSON field to a non-blank string. */ +function trimmedOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' ? value.trim() || undefined : undefined; +} + +/** + * Resolve the workspace this broker start joins, walking the shared precedence + * ladder: `--workspace-key` → env → the repository pin → the machine-global + * active workspace. Nothing resolving means the broker will mint a workspace. + * + * The repository pin is read through {@link CoreDependencies.fs} (tests stub it) + * while the machine-global store is read by the shared cloud resolver, so both + * halves of the ladder stay in one place. + * + * A Fleet enrollment (`RELAY_NODE_TOKEN`) selects the node's identity, not its + * workspace, and no longer short-circuits this walk — letting it do so is what + * re-homed an enrolled node out of its repository's workspace and into a + * freshly minted one. + */ +function resolveWorkspaceForBrokerStart( options: UpOptions, deps: CoreDependencies, projectDataDir: string -): PinnedProjectWorkspaceSession | undefined { +): WorkspaceSelection | undefined { + const flag = options.workspaceKey?.trim(); + if (flag) { + return { key: flag, source: 'flag', origin: '--workspace-key' }; + } + const explicitEnvWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.env); - if (options.workspaceKey?.trim() || explicitEnvWorkspaceKey || deps.env.RELAY_NODE_TOKEN?.trim()) { + if (explicitEnvWorkspaceKey) { + return { key: explicitEnvWorkspaceKey, source: 'env', origin: '$RELAY_WORKSPACE_KEY' }; + } + + const pinned = readPinnedProjectWorkspaceSession(projectDataDir, deps); + if (pinned) { + return { + key: pinned.workspaceKey, + source: 'project', + origin: projectWorkspaceKeyPath(projectDataDir), + ...(pinned.workspaceId ? { workspaceId: pinned.workspaceId } : {}), + }; + } + + // Everything below the repository pin: the machine-global active workspace. + // Without this step a fresh checkout mints its own workspace even though the + // machine already has an active one selected. + return resolveActiveWorkspaceSelection(deps.env); +} + +/** + * Apply the resolved workspace to the environment the broker (and any detached + * child) inherits, and report which source won. Returns the pinned project + * session when the repository pin supplied the selection. + */ +function applyWorkspaceSelection( + selection: WorkspaceSelection | undefined, + deps: CoreDependencies, + projectDataDir: string +): PinnedProjectWorkspaceSession | undefined { + if (!selection) { + deps.log( + 'Workspace: none selected (no --workspace-key, no RELAY_WORKSPACE_KEY, no repository pin, ' + + 'no active workspace in the machine-global store). A new workspace will be created.' + ); return undefined; } - 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; - } + deps.log(`Workspace source: ${describeWorkspaceSource(selection.source)} (${selection.origin})`); + if (selection.source === 'flag' || selection.source === 'env') { + // Both already live in the environment the broker inherits: the flag is + // exported by runUpCommand before the --background fork, and an env alias + // was promoted to RELAY_WORKSPACE_KEY during resolution. Writing + // RELAY_API_KEY here would clobber a value the caller set deliberately. + return undefined; + } + deps.env.RELAY_WORKSPACE_KEY = selection.key; + deps.env.RELAY_API_KEY = selection.key; + if (selection.source !== 'project') { + return undefined; + } + + const pinned = readPinnedProjectWorkspaceSession(projectDataDir, deps); + if (pinned?.enrolledNodeId) { + deps.env.AGENT_RELAY_ENROLLED_NODE_ID = pinned.enrolledNodeId; + } + return pinned; +} + +/** Human-readable name for a precedence-ladder step, for the startup line. */ +function describeWorkspaceSource(source: WorkspaceSelection['source']): string { + switch (source) { + case 'flag': + return 'command-line flag'; + case 'env': + return 'environment'; + case 'project': + return 'repository pin'; + case 'store': + return 'machine-global active workspace'; } - return session; } export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise { @@ -1350,7 +1438,8 @@ 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 workspaceSelection = resolveWorkspaceForBrokerStart(options, deps, projectWorkspaceKeyDataDir); + const resumedProjectSession = applyWorkspaceSelection(workspaceSelection, deps, projectWorkspaceKeyDataDir); // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { const resolved = path.resolve(options.stateDir); @@ -1573,6 +1662,18 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.log(`Project: ${paths.projectRoot}`); deps.log('Mode: broker (stdio)'); deps.log(`Workspace Key: ${relay.workspaceKey ? maskSecret(relay.workspaceKey) : 'unknown'}`); + // Minting must be observable: without this line "created a workspace" and + // "joined the pinned workspace" print identically. + const joinedWorkspaceId = relay.workspaceId ?? 'unknown'; + if (workspaceSelection) { + deps.log(`Workspace: joined ${joinedWorkspaceId}`); + } else { + deps.log(`Workspace: created new workspace ${joinedWorkspaceId}`); + deps.log( + 'Pin a workspace for this repository with `agent-relay up --workspace-key `, ' + + 'or select one machine-wide with `agent-relay workspace use `.' + ); + } deps.log('Broker started.'); // Record the workspace this broker joined (explicitly passed or auto-minted) @@ -1583,6 +1684,10 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): try { writeProjectWorkspaceKey(projectWorkspaceKeyDataDir, relay.workspaceKey ?? undefined, { enrolledNodeId: deps.env.AGENT_RELAY_ENROLLED_NODE_ID ?? resumedProjectSession?.enrolledNodeId, + // Recording the resolved workspace id lets the NEXT start detect a + // conflicting source (a stored enrollment in another workspace) before + // the broker comes up, instead of after agents land in the wrong place. + workspaceId: relay.workspaceId ?? resumedProjectSession?.workspaceId, }); } catch { // best-effort: a broker that came up should stay up even if the key file diff --git a/packages/cli/src/cli/lib/project-workspace-key.ts b/packages/cli/src/cli/lib/project-workspace-key.ts index 0cac556c4..294542ab7 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -4,6 +4,9 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, + resolveWorkspaceSelection, writeProjectWorkspaceKey, type ProjectWorkspaceSession, + type WorkspaceSelection, } from '@agent-relay/cloud/workspace-key'; diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index bcdd014a5..94738e8cc 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -125,12 +125,15 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, + resolveWorkspaceSelection, writeProjectWorkspaceKey, type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, type WorkspaceKeySource, + type WorkspaceSelection, } from './project-workspace-key.js'; export { diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index 20e39662f..d5b6386b1 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -9,6 +9,7 @@ import { readProjectWorkspaceKey, readProjectWorkspaceSession, resolveWorkspaceKeyWithSource, + resolveWorkspaceSelection, writeProjectWorkspaceKey, } from './project-workspace-key.js'; import { setWorkspaceKey } from './workspace-store.js'; @@ -92,3 +93,56 @@ describe('project workspace key resolution', () => { ).toBeUndefined(); }); }); + +describe('workspace precedence ladder diagnostics', () => { + it('round-trips the resolved workspace id on the project pin', () => { + writeProjectWorkspaceKey(dataDir, 'rk_project', { workspaceId: ' rw_pinned ' }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_project', + workspaceId: 'rw_pinned', + }); + expect( + resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } })?.workspaceId + ).toBe('rw_pinned'); + }); + + it('names each source without leaking key material', () => { + const env = { AGENT_RELAY_HOME: home, AGENT_RELAY_WORKSPACE_KEY: 'rk_env' }; + setWorkspaceKey('global', 'rk_global', env); + writeProjectWorkspaceKey(dataDir, 'rk_project'); + + const flag = resolveWorkspaceSelection({ workspaceKey: 'rk_flag', projectDataDir: dataDir, env }); + expect(flag).toMatchObject({ key: 'rk_flag', source: 'flag', origin: '--workspace-key' }); + + const fromEnv = resolveWorkspaceSelection({ projectDataDir: dataDir, env }); + expect(fromEnv).toMatchObject({ source: 'env', origin: '$AGENT_RELAY_WORKSPACE_KEY' }); + + const project = resolveWorkspaceSelection({ + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home }, + }); + expect(project).toMatchObject({ source: 'project', origin: projectWorkspaceKeyPath(dataDir) }); + + fs.rmSync(projectWorkspaceKeyPath(dataDir)); + const store = resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }); + expect(store).toMatchObject({ key: 'rk_global', source: 'store' }); + expect(store?.origin).toContain('workspaces.json'); + expect(store?.origin).toContain('active: "global"'); + + for (const selection of [flag, fromEnv, project, store]) { + expect(selection?.origin).not.toContain(selection?.key ?? ''); + } + }); + + it('keeps the repository pin ahead of the machine-global active entry (#1406)', () => { + const env = { AGENT_RELAY_HOME: home }; + setWorkspaceKey('stale-global', 'rk_stale_global', env); + writeProjectWorkspaceKey(dataDir, 'rk_repository', { workspaceId: 'rw_repository' }); + + expect(resolveWorkspaceSelection({ projectDataDir: dataDir, env })).toMatchObject({ + key: 'rk_repository', + source: 'project', + workspaceId: 'rw_repository', + }); + }); +}); diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 5fbb77de1..2fbe224fa 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -4,14 +4,23 @@ import path from 'node:path'; import { getProjectPaths } from '@agent-relay/config'; -import { resolveActiveWorkspaceKey } from './workspace-store.js'; +import { readWorkspaceStore, workspaceStorePath } from './workspace-store.js'; const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; +/** Workspace-key environment aliases, highest precedence first. */ +const WORKSPACE_KEY_ENV_VARS = ['RELAY_WORKSPACE_KEY', 'AGENT_RELAY_WORKSPACE_KEY', 'RELAY_API_KEY'] as const; + export interface ProjectWorkspaceSession { workspaceKey: string; /** Enrolled Fleet node associated with this project session, when one started the broker. */ enrolledNodeId?: string; + /** + * Relay workspace id the pinned key resolved to on a previous start. Recorded + * so a later start can detect — before the broker comes up — that another + * source (a stored Fleet enrollment, say) points at a different workspace. + */ + workspaceId?: string; } export type WorkspaceKeySource = 'flag' | 'env' | 'project' | 'store'; @@ -25,6 +34,21 @@ export interface ResolveWorkspaceKeyOptions { projectDataDir?: string; } +/** + * A resolved workspace selection plus where it came from. + * + * `origin` is safe to print: it names a flag, an environment variable, or a + * file path — never key material. + */ +export interface WorkspaceSelection { + key: string; + source: WorkspaceKeySource; + /** Human-readable origin for diagnostics. Never contains key material. */ + origin: string; + /** Workspace id this selection is known to address, when previously recorded. */ + workspaceId?: string; +} + /** Absolute path to the workspace key recorded by `agent-relay node up`. */ export function projectWorkspaceKeyPath(dataDir: string): string { return path.join(dataDir, PROJECT_WORKSPACE_KEY_FILENAME); @@ -43,9 +67,11 @@ export function readProjectWorkspaceSession(dataDir: string): ProjectWorkspaceSe const workspaceKey = trimOrUndefined(parsed.workspaceKey); if (!workspaceKey) return undefined; const enrolledNodeId = trimOrUndefined(parsed.enrolledNodeId); + const workspaceId = trimOrUndefined(parsed.workspaceId); return { workspaceKey, ...(enrolledNodeId ? { enrolledNodeId } : {}), + ...(workspaceId ? { workspaceId } : {}), }; } catch { return undefined; @@ -59,11 +85,12 @@ export function readProjectWorkspaceSession(dataDir: string): ProjectWorkspaceSe export function writeProjectWorkspaceKey( dataDir: string, workspaceKey: string | undefined, - options: { enrolledNodeId?: string } = {} + options: { enrolledNodeId?: string; workspaceId?: string } = {} ): void { const key = trimOrUndefined(workspaceKey); if (!key) return; const enrolledNodeId = trimOrUndefined(options.enrolledNodeId); + const workspaceId = trimOrUndefined(options.workspaceId); fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); // Worker threads share a PID, so include a per-write nonce as well as the PID. @@ -72,6 +99,7 @@ export function writeProjectWorkspaceKey( { workspaceKey: key, ...(enrolledNodeId ? { enrolledNodeId } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ProjectWorkspaceSession, null, 2 @@ -102,29 +130,78 @@ export function writeProjectWorkspaceKey( } /** - * Resolve the Relay workspace used by SDK clients. The project-local key comes - * before the machine-global active workspace so a process addresses the same - * workspace as the broker and fleet node running in that checkout. + * Resolve which Relay workspace this process addresses. + * + * This is THE workspace precedence ladder — every caller (SDK clients, the CLI, + * `agent-relay up` / `node up`) resolves through it so a repository cannot end + * up in one workspace and its tooling in another: + * + * 1. `flag` — an explicit `--workspace-key` / `--wk`. + * 2. `env` — `RELAY_WORKSPACE_KEY` > `AGENT_RELAY_WORKSPACE_KEY` > `RELAY_API_KEY`. + * 3. `project` — the repository pin, `/.agentworkforce/relay/workspace-key.json`. + * 4. `store` — the machine-global active entry in `~/.agentworkforce/relay/workspaces.json`. + * 5. nothing resolves — the caller decides (the broker mints a new workspace). + * + * The repository pin always outranks the machine-global active entry: a global + * selection must never silently re-home a checkout that pinned a workspace. A + * Fleet enrollment / node token selects the node's *identity*, never its + * workspace, so it does not appear on this ladder at all. */ -export function resolveWorkspaceKeyWithSource( +export function resolveWorkspaceSelection( options: ResolveWorkspaceKeyOptions = {} -): { key: string; source: WorkspaceKeySource } | undefined { +): WorkspaceSelection | undefined { const env = options.env ?? process.env; const flag = trimOrUndefined(options.workspaceKey); - if (flag) return { key: flag, source: 'flag' }; + if (flag) return { key: flag, source: 'flag', origin: '--workspace-key' }; - const envKey = - trimOrUndefined(env.RELAY_WORKSPACE_KEY) ?? - trimOrUndefined(env.AGENT_RELAY_WORKSPACE_KEY) ?? - trimOrUndefined(env.RELAY_API_KEY); - if (envKey) return { key: envKey, source: 'env' }; + for (const name of WORKSPACE_KEY_ENV_VARS) { + const envKey = trimOrUndefined(env[name]); + if (envKey) return { key: envKey, source: 'env', origin: `$${name}` }; + } const dataDir = options.projectDataDir ?? projectDataDir(options.projectRoot); - const project = dataDir ? readProjectWorkspaceKey(dataDir) : undefined; - if (project) return { key: project, source: 'project' }; + const project = dataDir ? readProjectWorkspaceSession(dataDir) : undefined; + if (project) { + return { + key: project.workspaceKey, + source: 'project', + origin: projectWorkspaceKeyPath(dataDir as string), + ...(project.workspaceId ? { workspaceId: project.workspaceId } : {}), + }; + } + + return resolveActiveWorkspaceSelection(env); +} + +/** + * Step 4 of {@link resolveWorkspaceSelection} on its own: the machine-global + * active workspace. + * + * Exposed separately for callers that inject their own file system for the + * higher (repository-pin) steps and must not re-read the pin through `node:fs`. + * It is never correct to consult this ahead of steps 1–3. + */ +export function resolveActiveWorkspaceSelection( + env: NodeJS.ProcessEnv = process.env +): WorkspaceSelection | undefined { + const store = readWorkspaceStore(env); + const activeName = trimOrUndefined(store.active); + const storeKey = activeName ? trimOrUndefined(store.workspaces[activeName]?.key) : undefined; + return storeKey + ? { + key: storeKey, + source: 'store', + origin: `${workspaceStorePath(env)} (active: "${activeName}")`, + } + : undefined; +} - const store = trimOrUndefined(resolveActiveWorkspaceKey(env)); - return store ? { key: store, source: 'store' } : undefined; +/** Resolve the selected workspace key and its source. See {@link resolveWorkspaceSelection}. */ +export function resolveWorkspaceKeyWithSource( + options: ResolveWorkspaceKeyOptions = {} +): { key: string; source: WorkspaceKeySource } | undefined { + const selection = resolveWorkspaceSelection(options); + return selection ? { key: selection.key, source: selection.source } : undefined; } /** Resolve only the selected workspace key while preserving the shared precedence rules. */ diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index a2a153cdf..d4b7e8138 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -2,10 +2,13 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, + resolveWorkspaceSelection, writeProjectWorkspaceKey, type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, type WorkspaceKeySource, + type WorkspaceSelection, } from './project-workspace-key.js'; diff --git a/packages/harness-driver/src/client.ts b/packages/harness-driver/src/client.ts index 7b3bb420c..71a867c96 100644 --- a/packages/harness-driver/src/client.ts +++ b/packages/harness-driver/src/client.ts @@ -225,6 +225,8 @@ export class HarnessDriverClient { private brokerExitListeners = new Set(); workspaceKey?: string; + /** Relay workspace id the broker joined, as reported on `/api/session`. */ + workspaceId?: string; /** Resolved broker URL — captured so call-site lifecycle contexts can surface it. */ readonly baseUrl: string; /** Shared multi-listener registry. Created bare when no `eventBus` is passed in. */ @@ -502,6 +504,7 @@ export class HarnessDriverClient { async getSession(): Promise { const session = await this.transport.request('/api/session'); this.workspaceKey = session.workspace_key; + this.workspaceId = session.default_workspace_id; return session; } From c5b0f849555c9f7dc9349adbd3ec105edc17251c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 3 Aug 2026 22:50:43 +0200 Subject: [PATCH 2/3] fix(cli): make workspace activation reversible --- CHANGELOG.md | 16 +- packages/cli/README.md | 58 ++-- .../src/cli/agent-relay-mcp.startup.test.ts | 5 + packages/cli/src/cli/bootstrap.test.ts | 2 + packages/cli/src/cli/commands/core.test.ts | 97 ++++++- packages/cli/src/cli/commands/node.test.ts | 5 + packages/cli/src/cli/commands/node.ts | 5 +- .../cli/src/cli/commands/workspace.test.ts | 124 ++++++++- packages/cli/src/cli/commands/workspace.ts | 59 +++- .../cli/src/cli/lib/broker-lifecycle.test.ts | 27 +- packages/cli/src/cli/lib/broker-lifecycle.ts | 259 ++++++++++-------- .../cli/src/cli/lib/project-workspace-key.ts | 1 + .../cli/src/cli/lib/workspace-session.test.ts | 50 +++- packages/cli/src/cli/lib/workspace-session.ts | 25 +- packages/cli/src/cli/telemetry/client.test.ts | 11 + packages/cli/src/cli/telemetry/client.ts | 12 +- packages/cloud/src/auth.test.ts | 1 + packages/cloud/src/index.ts | 1 + .../cloud/src/project-workspace-key.test.ts | 18 ++ packages/cloud/src/project-workspace-key.ts | 22 +- packages/cloud/src/workspace-key.ts | 1 + packages/cloud/src/workspace-store.test.ts | 14 + packages/cloud/src/workspace-store.ts | 12 +- 23 files changed, 673 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72912262a..42fd3c1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased - Patch] +## [Unreleased - Minor] + +### Added + +- `agent-relay workspace restore` returns to the recorded previous workspace, while `workspace rebind ` explicitly pins a project's next broker start without changing the machine-global active workspace. + +### Changed + +- `workspace create` warns on stderr when it changes the active workspace and records the prior name; named switches now record the same restore point, and first-run telemetry notices no longer contaminate JSON stdout. ### Fixed - `agent-relay node up` resolves its installed broker through canonical package-manager links and Relay's user install directories, so mise-managed and minimal-`PATH` launches no longer fail when the broker binary is already installed. +- `agent-relay up` / `node up` use one precedence ladder: `--workspace-key` → workspace environment variables → repository pin → machine-global active workspace → creating one. A fresh project joins the active workspace instead of silently creating another, startup announces the winning source, and `node status` reports the same five-source provenance. +- Cloud enrollment selects node identity without overriding workspace resolution. A conflict with the repository pin stops startup, names both non-secret sources, and points to `workspace rebind ` as the recovery path. +- Detached `node up --background` surfaces early child failures and stops polling when the child exits without trying to kill an already dead process. ## [11.4.1] - 2026-08-03 @@ -20,9 +31,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - CLI output no longer disappears when stdout or stderr is a pipe instead of a terminal. Node's stdio writes are asynchronous for pipes on macOS, so exiting in the same tick as the write discarded whatever was still buffered — `agent-relay cloud session --json | parser` and `$(agent-relay …)` could come back with empty stdout _and_ empty stderr, hiding the payload and the error that explained the failure. Every hard-exit path now drains stdio first. -- `agent-relay up` / `node up` resolve the workspace through one documented precedence ladder: `--workspace-key` → `RELAY_WORKSPACE_KEY`/`AGENT_RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` → the repository pin in `.agentworkforce/relay/workspace-key.json` → the machine-global active workspace in `~/.agentworkforce/relay/workspaces.json` → creating one. Startup prints the winning source (flag, variable, or file path — never key material). -- A Cloud enrollment no longer re-homes an enrolled node out of its repository's workspace. `RELAY_NODE_TOKEN` selects the node's identity, not its workspace, and no longer suppresses the repository pin; when a stored enrollment addresses a different workspace than the pin, `node up` stops and names both sources instead of silently choosing one. -- A first `up` in a fresh directory joins the machine's active workspace instead of silently creating a new one, and a start that does create a workspace says so instead of printing the same output as a join. ## [11.4.0] - 2026-08-02 diff --git a/packages/cli/README.md b/packages/cli/README.md index f1e15864d..1db68a8e7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -47,7 +47,7 @@ agent-relay node agent release For AI SDK native harnesses, attach renders structured activity, text, tools, approvals, files, usage, and lifecycle events. Add `--json` for NDJSON, `--reasoning` for reasoning events, or `--diagnostics` for sidecar diagnostics. Native harness `drive` is line-oriented and acknowledged; native harness `passthrough` is unsupported because no terminal stream exists. PTY attach behavior is unchanged. -### Which workspace a broker joins +### Workspace binding and recovery `agent-relay up` and `agent-relay node up` resolve the workspace through one precedence ladder. The first source that resolves wins: @@ -58,30 +58,56 @@ precedence ladder. The first source that resolves wins: | 2 | Environment | `RELAY_WORKSPACE_KEY`, then `AGENT_RELAY_WORKSPACE_KEY`, then `RELAY_API_KEY` | | 3 | Repository pin | `/.agentworkforce/relay/workspace-key.json` | | 4 | Machine-global active workspace | the `active` entry in `~/.agentworkforce/relay/workspaces.json` | -| 5 | New workspace | created only when nothing above resolves | +| 5 | Created workspace | created only when nothing above resolves | -Two rules follow from the order: +The repository pin always beats the machine-global active workspace, so +`agent-relay workspace switch ` never silently re-homes a checkout that +already pinned one. A new workspace is a last resort: a fresh directory joins +the machine-global active workspace when one exists, and startup explicitly +announces creation when none of the first four sources resolves. -- **The repository pin always beats the machine-global active workspace.** - Switching your active workspace (`agent-relay workspace use `) never - re-homes a checkout that already pinned one. -- **A new workspace is a last resort, not a default.** A fresh directory joins - the machine's active workspace when one is selected. When nothing resolves and - a workspace is created, startup says so explicitly. +Startup and `node status` report the winning source without printing key +material. Status uses the same five labels: command-line flag, environment, +repository pin, machine-global active workspace, or created. -Startup prints the winning source (a flag name, an environment variable, or a -file path — never key material): - -``` +```text Workspace source: repository pin (/repo/.agentworkforce/relay/workspace-key.json) Workspace: joined rw_7ccfea89 ``` A Cloud enrollment (`RELAY_NODE_TOKEN`, or a record in the Fleet enrollment store) selects the node's _identity_, not its workspace, so it never appears on -this ladder. If a stored enrollment addresses a different workspace than the -repository pin, `node up` refuses to start and names both source files rather -than silently choosing one. +the ladder. If a stored enrollment addresses a different workspace than the +repository pin, `node up` refuses to start and names both source files and +workspace IDs, never their keys. + +`workspace create`, `join`, and `switch` select a named workspace globally and +pin it to the current project. A changed selection records the old name, so an +accidental create can be undone: + +```bash +agent-relay workspace restore +``` + +To change only the workspace this project's broker will use on its next start, +without changing the machine-global active workspace, use: + +```bash +agent-relay workspace rebind default +agent-relay node down +agent-relay node up +``` + +`rebind` is also the supported recovery command for the conflict above: it +writes the repository pin (which outranks the machine-global active workspace) +and clears the project's stale enrolled-node association so the next start does +not fight the conflict guard. It does not stop a running broker; restart the +broker when you are ready to apply the new pin. + +For detached startup failures, `node up --background` reports the child error +when available and otherwise tells you to retry without `--background`; a child +that already exited is no longer misreported as an unkillable half-started +broker. ## Remote fleet agents diff --git a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts index b9ca08a9c..216b35830 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -293,6 +293,11 @@ beforeEach(() => { vi.stubEnv('RELAYCAST_HARNESS', ''); vi.stubEnv('X_RELAYCAST_HARNESS', ''); vi.stubEnv('AGENT_RELAY_DISTINCT_ID', ''); + vi.stubEnv('AGENT_RELAY_MACHINE_ID', ''); + vi.stubEnv('AGENT_RELAY_USER_ID', ''); + vi.stubEnv('AGENT_RELAY_ORG_ID', ''); + vi.stubEnv('AGENT_RELAY_ORG_SLUG', ''); + vi.stubEnv('AGENT_RELAY_USER_EMAIL', ''); }); afterEach(() => { diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index cb4da75b7..f686a5b10 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -87,6 +87,8 @@ const expectedLeafCommands = [ 'workspace join', 'workspace key', 'workspace switch', + 'workspace restore', + 'workspace rebind', // workspace agents 'agent register', 'agent list', diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index e7bb634bb..cac21e274 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 { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { readProjectWorkspaceKey, readProjectWorkspaceSession } from '../lib/project-workspace-key.js'; @@ -34,6 +34,8 @@ const telemetryMocks = vi.hoisted(() => ({ track: vi.fn(), })); +const isolatedWorkspaceHome = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'relay-core-test-home-')); + vi.mock('../telemetry/index.js', () => ({ track: telemetryMocks.track, })); @@ -60,6 +62,10 @@ beforeEach(() => { telemetryMocks.track.mockClear(); }); +afterAll(() => { + nodeFs.rmSync(isolatedWorkspaceHome, { recursive: true, force: true }); +}); + import { registerCoreCommands, registerCoreMaintenance, @@ -76,12 +82,18 @@ class ExitSignal extends Error { } } -function connectionFile(pid: number, url = 'http://127.0.0.1:3889', apiKey = 'br_secret'): string { +function connectionFile( + pid: number, + url = 'http://127.0.0.1:3889', + apiKey = 'br_secret', + workspaceSource?: string +): string { return JSON.stringify({ url, port: Number(new URL(url).port || '0'), api_key: apiKey, pid, + ...(workspaceSource ? { workspace_source: workspaceSource } : {}), }); } @@ -156,6 +168,7 @@ function createHarness(options?: { const spawnedProcess = options?.spawnedProcess ?? createSpawnedProcessMock(); const env = options?.env ?? {}; env.AGENT_RELAY_DISABLE_IMPLICIT_FLEET_NODE ??= '1'; + env.AGENT_RELAY_HOME ??= isolatedWorkspaceHome; const exit = vi.fn((code: number) => { throw new ExitSignal(code); @@ -566,6 +579,7 @@ describe('registerCoreCommands', () => { ); expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_live_customflag77'); expect(deps.env.RELAY_API_KEY).toBe('rk_live_customflag77'); + expect(deps.env.AGENT_RELAY_WORKSPACE_SOURCE).toBe('flag'); expect(deps.env.AGENT_RELAY_STATE_DIR).toBe(stateDir); expect(deps.log).toHaveBeenCalledWith('Broker started.'); expect(deps.log).toHaveBeenCalledWith('Broker PID: 5151'); @@ -882,6 +896,44 @@ describe('registerCoreCommands', () => { expect(deps.log).not.toHaveBeenCalledWith('Broker started.'); }); + it('up --background reports an early detached-child failure without trying to kill a dead PID', async () => { + const spawnedProcess = createSpawnedProcessMock(); + let now = 0; + let childRunning = true; + const fs = createFsMock(); + const sleepImpl = vi.fn(async (ms: number) => { + now += ms; + childRunning = false; + fs.writeFileSync( + '/tmp/project/.agentworkforce/relay/background-start-error.log', + 'explicit workspace key was rejected' + ); + }); + const killImpl = vi.fn((pid: number, signal?: NodeJS.Signals | number) => { + if (pid === 9001 && signal === 0 && childRunning) return; + throw new Error('not running'); + }); + const { program, deps } = createHarness({ + fs, + spawnedProcess, + killImpl, + nowImpl: vi.fn(() => now), + sleepImpl, + }); + + const exitCode = await runCommand(program, ['up', '--background', '--workspace-key', 'rk_live_other']); + + expect(exitCode).toBe(1); + expect(deps.error).toHaveBeenCalledWith( + 'Broker background child exited before becoming ready (pid: 9001).' + ); + expect(deps.error).toHaveBeenCalledWith('Detached broker error: explicit workspace key was rejected'); + expect(killImpl).not.toHaveBeenCalledWith(9001, 'SIGTERM'); + expect(deps.error).not.toHaveBeenCalledWith( + expect.stringContaining('Failed to stop half-started broker process') + ); + }); + it('down --force only kills actual orphaned broker executables for the project', async () => { const runningPids = new Set([222, 444, 666]); const execCommand = vi.fn(async (command: string) => { @@ -1173,7 +1225,9 @@ describe('registerCoreCommands', () => { it('status checks broker status and prints metrics', async () => { const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json'; - const fs = createFsMock({ [connectionPath]: connectionFile(4242) }); + const fs = createFsMock({ + [connectionPath]: connectionFile(4242, 'http://127.0.0.1:3889', 'br_secret', 'project'), + }); sdkStatusClient.getStatus.mockResolvedValueOnce({ agent_count: 4, pending_delivery_count: 2 }); sdkStatusClient.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_teststatus123', @@ -1191,11 +1245,48 @@ describe('registerCoreCommands', () => { expect(deps.log).toHaveBeenCalledWith('Pending deliveries: 2'); expect(deps.log).toHaveBeenCalledWith('Node: sf-mini (node_enrolled)'); expect(deps.log).toHaveBeenCalledWith('Workspace Key: rk_live_…s123'); + expect(deps.log).toHaveBeenCalledWith( + 'Workspace source: repository pin (.agentworkforce/relay/workspace-key.json)' + ); const logCalls = (deps.log as unknown as { mock: { calls: unknown[][] } }).mock.calls; expect(logCalls.some((call) => String(call[0]).startsWith('Observer:'))).toBe(false); expect(sdkStatusClient.disconnect).toHaveBeenCalled(); }); + it.each([ + { + source: 'flag', + label: 'command-line flag (--workspace-key / --wk)', + }, + { + source: 'env', + label: 'environment (RELAY_WORKSPACE_KEY > AGENT_RELAY_WORKSPACE_KEY > RELAY_API_KEY)', + }, + { + source: 'project', + label: 'repository pin (.agentworkforce/relay/workspace-key.json)', + }, + { + source: 'store', + label: 'machine-global active workspace (~/.agentworkforce/relay/workspaces.json)', + }, + { + source: 'created', + label: 'created (no configured workspace resolved)', + }, + ])('status reports the $source workspace source', async ({ source, label }) => { + const connectionPath = '/tmp/project/.agentworkforce/relay/connection.json'; + const fs = createFsMock({ + [connectionPath]: connectionFile(4242, 'http://127.0.0.1:3889', 'br_secret', source), + }); + const { program, deps } = createHarness({ fs }); + + const exitCode = await runCommand(program, ['status']); + + expect(exitCode).toBeUndefined(); + expect(deps.log).toHaveBeenCalledWith(`Workspace source: ${label}`); + }); + 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) }); diff --git a/packages/cli/src/cli/commands/node.test.ts b/packages/cli/src/cli/commands/node.test.ts index 00c658525..e19be315d 100644 --- a/packages/cli/src/cli/commands/node.test.ts +++ b/packages/cli/src/cli/commands/node.test.ts @@ -9,6 +9,7 @@ const brokerMocks = vi.hoisted(() => ({ })); vi.mock('../lib/broker-lifecycle.js', () => ({ + WORKSPACE_BINDING_SOURCE_ENV: 'AGENT_RELAY_WORKSPACE_SOURCE', runUpCommand: (...args: unknown[]) => brokerMocks.runUpCommand(...args), runDownCommand: (...args: unknown[]) => brokerMocks.runDownCommand(...args), runStatusCommand: (...args: unknown[]) => brokerMocks.runStatusCommand(...args), @@ -289,6 +290,7 @@ describe('registerNodeCommands', () => { expect(message).toContain('rw_stale'); expect(message).toContain('rw_123'); expect(message).toContain('workspace-key.json'); + expect(message).toContain('agent-relay workspace rebind '); // Diagnostics name sources, never credentials. expect(message).not.toContain('rk_project_session'); expect(message).not.toContain('nt_secret'); @@ -350,7 +352,10 @@ describe('registerNodeCommands', () => { RELAY_NODE_ID: 'node_abc', RELAY_NODE_TOKEN: 'nt_secret', }); + // Node startup resolves identity only. The shared runUpCommand resolver + // applies the repository workspace, so there is no second ladder here. expect(restart.env.RELAY_WORKSPACE_KEY).toBeUndefined(); + expect(restart.env.RELAY_API_KEY).toBeUndefined(); expect(brokerMocks.runUpCommand).toHaveBeenLastCalledWith( expect.objectContaining({ background: true, diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index 6ea90ed4a..0124179fa 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -116,8 +116,9 @@ function reportWorkspaceSourceConflict( ` fleet enrollment ${fleetNodeEnrollmentStorePath(deps.core.env)} -> workspace ${enrolledWorkspaceId} (node ${record.nodeId})` ); deps.error( - 'Pass --workspace-key to choose explicitly, re-enroll this node in the pinned workspace, ' + - 'or delete the repository pin to adopt the enrollment.' + 'Run `agent-relay workspace rebind ` to repin this project and clear the stale ' + + 'enrolled-node association; alternatively pass --workspace-key or re-enroll this node in ' + + 'the pinned workspace.' ); return true; } diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 252904a8a..01461abec 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -10,6 +10,7 @@ vi.mock('@agent-relay/cloud', () => ({ vi.mock('../lib/workspace-session.js', () => ({ persistWorkspaceSession: vi.fn(), + pinProjectWorkspaceSession: vi.fn(), validateWorkspaceSessionName: vi.fn((name: string) => { const trimmed = name.trim(); if (!trimmed) throw new Error('Workspace name is required.'); @@ -25,7 +26,11 @@ import { } from '@agent-relay/cloud'; import { registerWorkspaceCommands, type WorkspaceCommandDependencies } from './workspace.js'; -import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; +import { + persistWorkspaceSession, + pinProjectWorkspaceSession, + validateWorkspaceSessionName, +} from '../lib/workspace-session.js'; beforeEach(() => { vi.clearAllMocks(); @@ -160,6 +165,46 @@ describe('registerWorkspaceCommands', () => { }); }); + it('workspace create records and visibly warns about the previous active workspace on stderr', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'default', + workspaces: { default: { key: 'rk_live_default' } }, + }); + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ + workspaceKey: 'rk_live_session_two', + } as never); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'session-two']); + + expect(persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'session-two', + workspaceKey: 'rk_live_session_two', + }); + expect(deps.error).toHaveBeenNthCalledWith(1, '⚠ Active workspace changed: default → session-two'); + expect(deps.error).toHaveBeenNthCalledWith(2, ' Restore with: agent-relay workspace restore'); + expect(() => JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).not.toThrow(); + }); + + it('workspace create --json keeps stdout parseable and routes the warning away from it', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'default', + workspaces: { default: { key: 'rk_live_default' } }, + }); + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ + workspaceKey: 'rk_live_json_workspace', + } as never); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'json-workspace', '--json']); + + expect(vi.mocked(deps.log).mock.calls).toHaveLength(1); + expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toMatchObject({ + name: 'json-workspace', + }); + expect(deps.error).toHaveBeenCalledWith('⚠ Active workspace changed: default → json-workspace'); + }); + it('workspace create rejects a blank name before provisioning a remote workspace', async () => { const { program, deps } = createHarness(); @@ -227,4 +272,81 @@ describe('registerWorkspaceCommands', () => { workspaceKey: 'rk_live_shared', }); }); + + it('workspace restore switches back to the recorded previous workspace', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'scratch', + previous: 'default', + workspaces: { + default: { key: 'rk_live_default' }, + scratch: { key: 'rk_live_scratch' }, + }, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'restore']); + + expect(persistWorkspaceSession).toHaveBeenCalledWith({ + name: 'default', + workspaceKey: 'rk_live_default', + }); + expect(deps.log).toHaveBeenCalledWith('Switched to workspace "default" (was scratch).'); + }); + + it('workspace restore reports when nothing was recorded', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ active: 'default', workspaces: {} }); + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'workspace', 'restore'])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith('No previous workspace is recorded.'); + }); + + it('workspace restore reports when the recorded workspace no longer exists', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'scratch', + previous: 'deleted', + workspaces: { scratch: { key: 'rk_live_scratch' } }, + }); + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'workspace', 'restore'])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith('The recorded previous workspace "deleted" no longer exists.'); + }); + + it('workspace restore reports when the recorded workspace is already active', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'default', + previous: 'default', + workspaces: { default: { key: 'rk_live_default' } }, + }); + const { program, deps } = createHarness(); + + await expect(program.parseAsync(['node', 'agent-relay', 'workspace', 'restore'])).rejects.toThrow( + 'exit:1' + ); + + expect(deps.error).toHaveBeenCalledWith('The recorded previous workspace "default" is already active.'); + }); + + it('workspace rebind pins the selected workspace to this project without changing global state', async () => { + vi.mocked(readWorkspaceStore).mockReturnValueOnce({ + active: 'scratch', + workspaces: { default: { key: 'rk_live_default' } }, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'rebind', 'default']); + + expect(pinProjectWorkspaceSession).toHaveBeenCalledWith({ workspaceKey: 'rk_live_default' }); + expect(persistWorkspaceSession).not.toHaveBeenCalled(); + expect(deps.log).toHaveBeenCalledWith( + `Rebound this project's broker to workspace "default". Restart the broker to apply it.` + ); + }); }); diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 2e4a0f7c2..c457de12f 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -5,7 +5,11 @@ import { resolveActiveWorkspace } from '@agent-relay/cloud'; import { maskSecret } from '../lib/redact.js'; import { printJson, runSdk, withSdkDefaults, type SdkCommandDeps } from '../lib/sdk-command.js'; import { readWorkspaceStore, setWorkspaceKey } from '../lib/workspace-store.js'; -import { persistWorkspaceSession, validateWorkspaceSessionName } from '../lib/workspace-session.js'; +import { + persistWorkspaceSession, + pinProjectWorkspaceSession, + validateWorkspaceSessionName, +} from '../lib/workspace-session.js'; export type WorkspaceCommandDependencies = SdkCommandDeps; @@ -78,13 +82,19 @@ export function registerWorkspaceCommands( .description('Create a new workspace and store its key') .argument('', 'Workspace name') .option('--base-url ', 'Override the API base URL') + .option('--json', 'Output the created workspace as JSON') .option('--reveal-secrets', 'Include the raw workspace key in the output') .action(async (name: string, o: Record) => { await runSdk(deps, async () => { const workspaceName = validateWorkspaceSessionName(name); const relay = await deps.createWorkspace(workspaceName, o.baseUrl as string | undefined); if (relay.workspaceKey) { + const previousActive = readWorkspaceStore().active; persistWorkspaceSession({ name: workspaceName, workspaceKey: relay.workspaceKey }); + if (previousActive && previousActive !== workspaceName) { + deps.error(`⚠ Active workspace changed: ${previousActive} → ${workspaceName}`); + deps.error(' Restore with: agent-relay workspace restore'); + } } // The key is persisted to the workspace store either way; the output // masks it unless the caller explicitly asks for the raw value. @@ -104,6 +114,7 @@ export function registerWorkspaceCommands( const store = readWorkspaceStore(); printJson(deps, { active: store.active, + previous: store.previous, workspaces: Object.keys(store.workspaces), }); }); @@ -170,4 +181,50 @@ export function registerWorkspaceCommands( deps.log(`Switched to workspace "${name}".`); }); }); + + group + .command('restore') + .description('Switch back to the previously active workspace') + .action(async () => { + await runSdk(deps, async () => { + const store = readWorkspaceStore(); + const previous = store.previous; + if (!previous) { + throw new Error('No previous workspace is recorded.'); + } + if (previous === store.active) { + throw new Error(`The recorded previous workspace "${previous}" is already active.`); + } + const workspace = Object.hasOwn(store.workspaces, previous) ? store.workspaces[previous] : undefined; + if (!workspace) { + throw new Error(`The recorded previous workspace "${previous}" no longer exists.`); + } + const current = store.active; + persistWorkspaceSession({ name: previous, workspaceKey: workspace.key }); + deps.log(`Switched to workspace "${previous}" (was ${current ?? 'none'}).`); + }); + }); + + group + .command('rebind') + .description("Pin this project's broker to a stored workspace") + .argument('', 'Stored workspace name') + .action(async (name: string) => { + await runSdk(deps, async () => { + const workspaceName = validateWorkspaceSessionName(name); + const store = readWorkspaceStore(); + const workspace = Object.hasOwn(store.workspaces, workspaceName) + ? store.workspaces[workspaceName] + : undefined; + if (!workspace) { + throw new Error( + `Unknown workspace "${workspaceName}". Add it with \`relay workspace set_key ${workspaceName} \`.` + ); + } + pinProjectWorkspaceSession({ workspaceKey: workspace.key }); + deps.log( + `Rebound this project's broker to workspace "${workspaceName}". Restart the broker to apply it.` + ); + }); + }); } diff --git a/packages/cli/src/cli/lib/broker-lifecycle.test.ts b/packages/cli/src/cli/lib/broker-lifecycle.test.ts index fceb00920..3c59541d0 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.test.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.test.ts @@ -63,6 +63,14 @@ describe('describeErrorWithCause', () => { expect(result).toContain('agentrelay.com'); }); + it('redacts credentials found only in a nested cause', () => { + const err = new Error('broker start failed', { + cause: new Error('workspace rk_live_0123456789abcdef was rejected'), + }); + + expect(describeErrorWithCause(err)).toBe('broker start failed — workspace rk_live_…cdef was rejected'); + }); + it('handles non-Error values without throwing', () => { expect(describeErrorWithCause('something went wrong')).toBe('something went wrong'); expect(describeErrorWithCause(undefined)).toBe('undefined'); @@ -497,6 +505,8 @@ describe('runUpCommand node-config gating', () => { describe('runUpCommand workspace precedence', () => { const readPin = (dataDir: string): Record => JSON.parse(fsReal.readFileSync(pathReal.join(dataDir, 'workspace-key.json'), 'utf-8')); + const readBindingSource = (dataDir: string): string => + JSON.parse(fsReal.readFileSync(pathReal.join(dataDir, 'connection.json'), 'utf-8')).workspace_source; it('prefers the repository pin over the machine-global active workspace (#1406)', async () => { const { deps, dataDir, home, log } = createUpHarness(); @@ -508,6 +518,7 @@ describe('runUpCommand workspace precedence', () => { expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_repository'); expect(deps.env.RELAY_API_KEY).toBe('rk_repository'); expect(log.mock.calls.flat().join('\n')).toContain('Workspace source: repository pin'); + expect(readBindingSource(dataDir)).toBe('project'); }); it('applies the repository pin even when an enrollment node token is present (#1406)', async () => { @@ -534,10 +545,11 @@ describe('runUpCommand workspace precedence', () => { expect(output).toContain('Workspace source: machine-global active workspace'); expect(output).toContain('active: "account"'); expect(output).not.toContain('created new workspace'); + expect(readBindingSource(deps.getProjectPaths().dataDir)).toBe('store'); }); it('announces a mint when no source resolves (#1378)', async () => { - const { deps, log } = createUpHarness(); + const { deps, dataDir, log } = createUpHarness(); await runUpCommand({}, deps); @@ -545,6 +557,7 @@ describe('runUpCommand workspace precedence', () => { const output = log.mock.calls.flat().join('\n'); expect(output).toContain('Workspace: none selected'); expect(output).toContain('Workspace: created new workspace rw_test'); + expect(readBindingSource(dataDir)).toBe('created'); }); it('keeps an explicit --workspace-key ahead of both stores', async () => { @@ -556,6 +569,18 @@ describe('runUpCommand workspace precedence', () => { expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_flag'); expect(log.mock.calls.flat().join('\n')).toContain('Workspace source: command-line flag'); + expect(readBindingSource(dataDir)).toBe('flag'); + }); + + it('records environment provenance after normalizing a workspace-key alias', async () => { + const { deps, dataDir, log } = createUpHarness(); + deps.env.AGENT_RELAY_WORKSPACE_KEY = ' rk_environment '; + + await runUpCommand({}, deps); + + expect(deps.env.RELAY_WORKSPACE_KEY).toBe('rk_environment'); + expect(log.mock.calls.flat().join('\n')).toContain('$AGENT_RELAY_WORKSPACE_KEY'); + expect(readBindingSource(dataDir)).toBe('env'); }); it('records the resolved workspace id on the pin for later conflict detection', async () => { diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 3be312a83..5417c9d29 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; import { startServeNode, type FleetNodeDefinition, type RunningNode } from '@agent-relay/fleet'; import { createLogger } from '@agent-relay/utils'; +import { redactCredentialValues } from '@agent-relay/cloud/redact'; import type { CoreDependencies, CoreProjectPaths, CoreRelay, SpawnedProcess } from '../commands/core.js'; import { track } from '../telemetry/index.js'; @@ -25,12 +26,12 @@ import { describeError } from './describe-error.js'; import { maskSecret } from './redact.js'; import { startReflexCapture, type RunningReflexCapture } from './reflex-capture.js'; import { - projectWorkspaceKeyPath, - resolveActiveWorkspaceSelection, + readProjectWorkspaceSession, + resolveWorkspaceSelection, writeProjectWorkspaceKey, + type ProjectWorkspaceSession, type WorkspaceSelection, } from './project-workspace-key.js'; -import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; type UpOptions = { spawn?: boolean; @@ -70,6 +71,8 @@ const DEFAULT_BROKER_BASE_PORT = 3888; /** The broker writes this file with URL, port, API key, and PID. */ const CONNECTION_FILENAME = 'connection.json'; +const BACKGROUND_START_ERROR_FILENAME = 'background-start-error.log'; +export const WORKSPACE_BINDING_SOURCE_ENV = 'AGENT_RELAY_WORKSPACE_SOURCE'; const STATUS_POLL_INTERVAL_MS = 500; const DETACHED_START_READY_TIMEOUT_MS = 10_000; const NODE_DELIVERY_READY_TIMEOUT_MS = 10_000; @@ -78,11 +81,15 @@ const NODE_DELIVERY_READY_TIMEOUT_MS = 10_000; // RELAY_NODE_TOKEN. const NODE_TOKEN_WAIT_MS = 15_000; +export type WorkspaceBindingSource = WorkspaceSelection['source'] | 'created'; + export interface BrokerConnection { url: string; port: number; api_key: string; pid: number; + /** Non-secret provenance recorded by the CLI after the broker handshake. */ + workspace_source?: WorkspaceBindingSource; } type BrokerStatusDetails = { @@ -296,7 +303,7 @@ export function describeErrorWithCause(err: unknown): string { const parts = [top]; if (detail && detail !== top) parts.push(detail); if (codes.length > 0) parts.push(`[${codes.join(', ')}]`); - return parts.join(' — '); + return redactCredentialValues(parts.join(' — ')); } /** @@ -718,6 +725,68 @@ function safeUnlink(filePath: string, deps: CoreDependencies): void { } } +function workspaceBindingSource(value: string | undefined): WorkspaceBindingSource | undefined { + return value === 'flag' || + value === 'env' || + value === 'project' || + value === 'store' || + value === 'created' + ? value + : undefined; +} + +function workspaceBindingSourceLabel(source: WorkspaceBindingSource): string { + switch (source) { + case 'flag': + return 'command-line flag (--workspace-key / --wk)'; + case 'env': + return 'environment (RELAY_WORKSPACE_KEY > AGENT_RELAY_WORKSPACE_KEY > RELAY_API_KEY)'; + case 'project': + return 'repository pin (.agentworkforce/relay/workspace-key.json)'; + case 'store': + return 'machine-global active workspace (~/.agentworkforce/relay/workspaces.json)'; + case 'created': + return 'created (no configured workspace resolved)'; + } +} + +function writeBrokerBindingSource( + dataDir: string, + source: WorkspaceBindingSource, + deps: CoreDependencies +): void { + const connectionPath = path.join(dataDir, CONNECTION_FILENAME); + const connection = readBrokerConnectionFromFs(deps.fs, dataDir); + if (!connection) return; + deps.fs.writeFileSync( + connectionPath, + `${JSON.stringify({ ...connection, workspace_source: source }, null, 2)}\n`, + 'utf-8' + ); +} + +function backgroundStartErrorPath(dataDir: string): string { + return path.join(dataDir, BACKGROUND_START_ERROR_FILENAME); +} + +function readBackgroundStartError(dataDir: string, deps: CoreDependencies): string | undefined { + try { + return deps.fs.readFileSync(backgroundStartErrorPath(dataDir), 'utf-8').trim() || undefined; + } catch { + return undefined; + } +} + +function recordBackgroundStartError(message: string, deps: CoreDependencies): void { + const file = deps.env.AGENT_RELAY_BACKGROUND_START_ERROR_FILE?.trim(); + if (!file) return; + try { + deps.fs.writeFileSync(file, `${message}\n`, 'utf-8'); + } catch { + // Diagnostics must never replace the original startup error. + } +} + function readBrokerPid(dataDir: string, _deps: CoreDependencies): number | null { const conn = readBrokerConnectionFromFs(_deps.fs, dataDir); return conn?.pid ?? null; @@ -964,6 +1033,7 @@ function cleanupBrokerFiles(paths: CoreProjectPaths, deps: CoreDependencies): vo safeUnlink(path.join(paths.dataDir, CONNECTION_FILENAME), deps); safeUnlink(relaySockPath, deps); safeUnlink(runtimePath, deps); + safeUnlink(backgroundStartErrorPath(paths.dataDir), deps); // Clean up lock files and legacy pid files try { @@ -1114,13 +1184,17 @@ async function waitForBrokerReadiness( deps: CoreDependencies, waitMs: number, requireApi: boolean, - verbose?: boolean + verbose?: boolean, + stopWhenPidExits?: number ): Promise { const deadline = deps.now() + waitMs; let latest = await checkBrokerReadiness(paths, deps, requireApi); vlog(deps, verbose, `Broker readiness: ${latest.state}`); while (latest.state !== 'running' && waitMs > 0 && deps.now() < deadline) { + if (stopWhenPidExits && !isProcessRunning(stopWhenPidExits, deps)) { + return latest; + } await deps.sleep(Math.min(STATUS_POLL_INTERVAL_MS, Math.max(0, deadline - deps.now()))); const previousState = latest.state; latest = await checkBrokerReadiness(paths, deps, requireApi); @@ -1292,89 +1366,6 @@ function planCapacitySource( return plan.mode === 'in-process' ? plan.definition : descriptorCapacitySource(plan.descriptor); } -interface PinnedProjectWorkspaceSession { - workspaceKey: string; - enrolledNodeId?: string; - workspaceId?: string; -} - -/** Read the minimal project session needed during broker startup. */ -function readPinnedProjectWorkspaceSession( - dataDir: string, - deps: CoreDependencies -): PinnedProjectWorkspaceSession | undefined { - try { - const parsed = JSON.parse(deps.fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8')) as Partial<{ - workspaceKey: string; - enrolledNodeId: string; - workspaceId: string; - }>; - const workspaceKey = trimmedOrUndefined(parsed.workspaceKey); - if (!workspaceKey) { - return undefined; - } - const enrolledNodeId = trimmedOrUndefined(parsed.enrolledNodeId); - const workspaceId = trimmedOrUndefined(parsed.workspaceId); - return { - workspaceKey, - ...(enrolledNodeId ? { enrolledNodeId } : {}), - ...(workspaceId ? { workspaceId } : {}), - }; - } catch { - return undefined; - } -} - -/** Narrow an unknown JSON field to a non-blank string. */ -function trimmedOrUndefined(value: unknown): string | undefined { - return typeof value === 'string' ? value.trim() || undefined : undefined; -} - -/** - * Resolve the workspace this broker start joins, walking the shared precedence - * ladder: `--workspace-key` → env → the repository pin → the machine-global - * active workspace. Nothing resolving means the broker will mint a workspace. - * - * The repository pin is read through {@link CoreDependencies.fs} (tests stub it) - * while the machine-global store is read by the shared cloud resolver, so both - * halves of the ladder stay in one place. - * - * A Fleet enrollment (`RELAY_NODE_TOKEN`) selects the node's identity, not its - * workspace, and no longer short-circuits this walk — letting it do so is what - * re-homed an enrolled node out of its repository's workspace and into a - * freshly minted one. - */ -function resolveWorkspaceForBrokerStart( - options: UpOptions, - deps: CoreDependencies, - projectDataDir: string -): WorkspaceSelection | undefined { - const flag = options.workspaceKey?.trim(); - if (flag) { - return { key: flag, source: 'flag', origin: '--workspace-key' }; - } - - const explicitEnvWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.env); - if (explicitEnvWorkspaceKey) { - return { key: explicitEnvWorkspaceKey, source: 'env', origin: '$RELAY_WORKSPACE_KEY' }; - } - - const pinned = readPinnedProjectWorkspaceSession(projectDataDir, deps); - if (pinned) { - return { - key: pinned.workspaceKey, - source: 'project', - origin: projectWorkspaceKeyPath(projectDataDir), - ...(pinned.workspaceId ? { workspaceId: pinned.workspaceId } : {}), - }; - } - - // Everything below the repository pin: the machine-global active workspace. - // Without this step a fresh checkout mints its own workspace even though the - // machine already has an active one selected. - return resolveActiveWorkspaceSelection(deps.env); -} - /** * Apply the resolved workspace to the environment the broker (and any detached * child) inherits, and report which source won. Returns the pinned project @@ -1384,7 +1375,7 @@ function applyWorkspaceSelection( selection: WorkspaceSelection | undefined, deps: CoreDependencies, projectDataDir: string -): PinnedProjectWorkspaceSession | undefined { +): ProjectWorkspaceSession | undefined { if (!selection) { deps.log( 'Workspace: none selected (no --workspace-key, no RELAY_WORKSPACE_KEY, no repository pin, ' + @@ -1394,20 +1385,18 @@ function applyWorkspaceSelection( } deps.log(`Workspace source: ${describeWorkspaceSource(selection.source)} (${selection.origin})`); - if (selection.source === 'flag' || selection.source === 'env') { - // Both already live in the environment the broker inherits: the flag is - // exported by runUpCommand before the --background fork, and an env alias - // was promoted to RELAY_WORKSPACE_KEY during resolution. Writing - // RELAY_API_KEY here would clobber a value the caller set deliberately. - return undefined; - } + // Normalize every winning source to the primary env var inherited by the + // broker and any detached child. Keep a caller-supplied RELAY_API_KEY intact + // when an explicit flag or environment variable won. deps.env.RELAY_WORKSPACE_KEY = selection.key; - deps.env.RELAY_API_KEY = selection.key; + if (selection.source === 'project' || selection.source === 'store') { + deps.env.RELAY_API_KEY = selection.key; + } if (selection.source !== 'project') { return undefined; } - const pinned = readPinnedProjectWorkspaceSession(projectDataDir, deps); + const pinned = readProjectWorkspaceSession(projectDataDir, deps.fs); if (pinned?.enrolledNodeId) { deps.env.AGENT_RELAY_ENROLLED_NODE_ID = pinned.enrolledNodeId; } @@ -1428,6 +1417,22 @@ function describeWorkspaceSource(source: WorkspaceSelection['source']): string { } } +/** + * Preserve the original source across `--background` re-exec. The detached + * child sees the normalized RELAY_WORKSPACE_KEY as an env selection, so this + * marker carries only provenance; it never participates in resolution. + */ +function recordWorkspaceBindingSource( + selection: WorkspaceSelection | undefined, + deps: CoreDependencies +): WorkspaceBindingSource { + const inheritedSource = workspaceBindingSource(deps.env[WORKSPACE_BINDING_SOURCE_ENV]); + const source: WorkspaceBindingSource = + selection?.source === 'env' && inheritedSource ? inheritedSource : (selection?.source ?? 'created'); + deps.env[WORKSPACE_BINDING_SOURCE_ENV] = source; + return source; +} + export async function runUpCommand(options: UpOptions, deps: CoreDependencies): Promise { ensureBundledAgentRelayMcpCommand(deps); @@ -1438,7 +1443,13 @@ 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 workspaceSelection = resolveWorkspaceForBrokerStart(options, deps, projectWorkspaceKeyDataDir); + const workspaceSelection = resolveWorkspaceSelection({ + workspaceKey: options.workspaceKey, + env: deps.env, + projectDataDir: projectWorkspaceKeyDataDir, + fileSystem: deps.fs, + }); + const workspaceBindingSource = recordWorkspaceBindingSource(workspaceSelection, deps); const resumedProjectSession = applyWorkspaceSelection(workspaceSelection, deps, projectWorkspaceKeyDataDir); // --state-dir overrides where the broker writes state / connection files if (options.stateDir) { @@ -1475,6 +1486,9 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): return; } + const startErrorPath = backgroundStartErrorPath(paths.dataDir); + safeUnlink(startErrorPath, deps); + deps.env.AGENT_RELAY_BACKGROUND_START_ERROR_FILE = startErrorPath; const args = childUpArgsForDetachedStart(options, deps); const invocation = detachedCliInvocation(deps, args); let child: SpawnedProcess; @@ -1500,23 +1514,36 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps, DETACHED_START_READY_TIMEOUT_MS, true, - options.verbose + options.verbose, + child.pid ); if (readiness.state !== 'running') { const pid = readiness.state === 'starting' ? readiness.conn.pid : child.pid; - deps.error( - pid - ? `Broker background start did not become ready within ${DETACHED_START_READY_TIMEOUT_MS / 1000}s (pid: ${pid}).` - : `Broker background start did not become ready within ${DETACHED_START_READY_TIMEOUT_MS / 1000}s.` - ); + const childExited = + typeof child.pid === 'number' && child.pid > 0 && !isProcessRunning(child.pid, deps); + if (childExited) { + deps.error(`Broker background child exited before becoming ready (pid: ${child.pid}).`); + } else { + deps.error( + pid + ? `Broker background start did not become ready within ${DETACHED_START_READY_TIMEOUT_MS / 1000}s (pid: ${pid}).` + : `Broker background start did not become ready within ${DETACHED_START_READY_TIMEOUT_MS / 1000}s.` + ); + } if (readiness.state === 'starting') { deps.error('Broker process is running, but the API did not become ready.'); } + const detachedError = readBackgroundStartError(paths.dataDir, deps); + if (detachedError) { + deps.error(`Detached broker error: ${detachedError}`); + } else if (childExited) { + deps.error('Retry without --background to see the broker startup error.'); + } deps.error( 'Run `agent-relay status --wait-for=10` for details, or `agent-relay down --force` to clean up.' ); const cleanupPids = new Set(); - if (typeof child.pid === 'number' && child.pid > 0) { + if (typeof child.pid === 'number' && child.pid > 0 && isProcessRunning(child.pid, deps)) { cleanupPids.add(child.pid); } if (readiness.state === 'starting') { @@ -1584,6 +1611,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.log('Broker started.'); deps.log(`Broker PID: ${readiness.conn.pid}`); deps.log('Stop with: agent-relay down'); + safeUnlink(startErrorPath, deps); deps.exit(0); return; } @@ -1658,6 +1686,13 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): ); relay = started.relay; + try { + writeBrokerBindingSource(paths.dataDir, workspaceBindingSource, deps); + } catch { + // Provenance is diagnostic metadata; a broker that came up stays up. + } + safeUnlink(backgroundStartErrorPath(paths.dataDir), deps); + deps.log(`Relay API: http://localhost:${started.apiPort}`); deps.log(`Project: ${paths.projectRoot}`); deps.log('Mode: broker (stdio)'); @@ -1671,7 +1706,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): deps.log(`Workspace: created new workspace ${joinedWorkspaceId}`); deps.log( 'Pin a workspace for this repository with `agent-relay up --workspace-key `, ' + - 'or select one machine-wide with `agent-relay workspace use `.' + 'or select one machine-wide with `agent-relay workspace switch `.' ); } deps.log('Broker started.'); @@ -1772,10 +1807,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): stage, error_class: classifyBrokerStartError(err), }); + const detailedMessage = describeErrorWithCause(err); + recordBackgroundStartError(detailedMessage, deps); if (isBrokerAlreadyRunningError(message)) { reportAlreadyRunningError(message, paths.dataDir, deps); } else { - deps.error(`Failed to start broker: ${describeErrorWithCause(err)}`); + deps.error(`Failed to start broker: ${detailedMessage}`); } deps.exit(1); } @@ -1934,6 +1971,12 @@ export async function runStatusCommand( deps.log('Mode: broker (stdio)'); deps.log(`PID: ${readiness.conn.pid}`); deps.log(`Project: ${paths.projectRoot}`); + const source = workspaceBindingSource(readiness.conn.workspace_source); + deps.log( + source + ? `Workspace source: ${workspaceBindingSourceLabel(source)}` + : 'Workspace source: unknown (startup provenance was not recorded)' + ); // Query the running broker for additional status info const statusDetails = diff --git a/packages/cli/src/cli/lib/project-workspace-key.ts b/packages/cli/src/cli/lib/project-workspace-key.ts index 294542ab7..5d97af820 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -8,5 +8,6 @@ export { resolveWorkspaceSelection, writeProjectWorkspaceKey, type ProjectWorkspaceSession, + type WorkspaceKeyFileSystem, type WorkspaceSelection, } from '@agent-relay/cloud/workspace-key'; diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 8183077c9..55999665e 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -5,9 +5,17 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; -import { persistWorkspaceSession, resolveWorkspaceSessionKey } from './workspace-session.js'; -import { readProjectWorkspaceKey } from './project-workspace-key.js'; -import { readWorkspaceStore, setWorkspaceKey } from './workspace-store.js'; +import { + persistWorkspaceSession, + pinProjectWorkspaceSession, + resolveWorkspaceSessionKey, +} from './workspace-session.js'; +import { + readProjectWorkspaceKey, + readProjectWorkspaceSession, + writeProjectWorkspaceKey, +} from './project-workspace-key.js'; +import { readWorkspaceStore, setWorkspaceKey, switchWorkspace } from './workspace-store.js'; const tempRoots: string[] = []; @@ -75,6 +83,22 @@ describe('workspace session persistence', () => { }); }); + it('records the previous global workspace when a named session changes it', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + setWorkspaceKey('default', 'rk_live_default', env); + + persistWorkspaceSession({ + workspaceKey: 'rk_live_session_two', + name: 'session-two', + projectDataDir, + env, + }); + + expect(readWorkspaceStore(env)).toMatchObject({ active: 'session-two', previous: 'default' }); + }); + it('pins an explicitly supplied key without changing the named global workspace', () => { const root = tempRoot(); const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); @@ -122,4 +146,24 @@ describe('workspace session persistence', () => { expect(resolveWorkspaceSessionKey({ projectDataDir, env })).toBe('rk_live_project'); }); + + it('rebinds the project without changing the machine-global active workspace or old enrollment', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + setWorkspaceKey('default', 'rk_live_default', env); + setWorkspaceKey('scratch', 'rk_live_scratch', env); + switchWorkspace('scratch', env); + writeProjectWorkspaceKey(projectDataDir, 'rk_live_old', { + enrolledNodeId: 'node_old', + workspaceId: 'rw_old', + }); + + pinProjectWorkspaceSession({ workspaceKey: 'rk_live_default', projectDataDir, env }); + + expect(readProjectWorkspaceKey(projectDataDir)).toBe('rk_live_default'); + expect(readWorkspaceStore(env).active).toBe('scratch'); + expect(resolveWorkspaceSessionKey({ projectDataDir, env })).toBe('rk_live_default'); + expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ workspaceKey: 'rk_live_default' }); + }); }); diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts index f22289d3b..71efe4aa8 100644 --- a/packages/cli/src/cli/lib/workspace-session.ts +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -16,6 +16,10 @@ export interface PersistWorkspaceSessionOptions extends WorkspaceSessionOptions name?: string; } +export interface PinProjectWorkspaceSessionOptions extends WorkspaceSessionOptions { + workspaceKey: string; +} + /** Validate and normalize a workspace session name before local or remote writes. */ export function validateWorkspaceSessionName(name: string): string { return validateWorkspaceName(name); @@ -42,11 +46,28 @@ export function persistWorkspaceSession(options: PersistWorkspaceSessionOptions) const name = options.name === undefined ? undefined : validateWorkspaceSessionName(options.name); - const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; - writeProjectWorkspaceKey(projectDataDir, workspaceKey); + pinProjectWorkspaceSession({ + workspaceKey, + ...(options.projectDataDir ? { projectDataDir: options.projectDataDir } : {}), + ...(options.projectRoot ? { projectRoot: options.projectRoot } : {}), + }); if (name) { setWorkspaceKey(name, workspaceKey, options.env); switchWorkspace(name, options.env); } } + +/** + * Rebind only the current project to a workspace key. This intentionally drops + * any enrolled-node association: a later `node up` must honor the newly pinned + * messaging workspace instead of resuming credentials from the old binding. + */ +export function pinProjectWorkspaceSession(options: PinProjectWorkspaceSessionOptions): void { + const workspaceKey = options.workspaceKey.trim(); + if (!workspaceKey) { + throw new Error('Workspace key is required.'); + } + const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; + writeProjectWorkspaceKey(projectDataDir, workspaceKey); +} diff --git a/packages/cli/src/cli/telemetry/client.test.ts b/packages/cli/src/cli/telemetry/client.test.ts index 494ad1313..c3a9ec7bb 100644 --- a/packages/cli/src/cli/telemetry/client.test.ts +++ b/packages/cli/src/cli/telemetry/client.test.ts @@ -56,12 +56,14 @@ describe('telemetry client events', () => { vi.stubEnv('AGENT_RELAY_ORG_ID', ''); vi.stubEnv('AGENT_RELAY_ORG_SLUG', ''); vi.stubEnv('AGENT_RELAY_USER_EMAIL', ''); + vi.stubEnv('AGENT_RELAY_MACHINE_ID', ''); posthogMocks.capture.mockClear(); posthogMocks.identify.mockClear(); posthogMocks.alias.mockClear(); posthogMocks.groupIdentify.mockClear(); posthogMocks.shutdown.mockClear(); vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'error').mockImplementation(() => undefined); }); afterEach(async () => { @@ -101,6 +103,15 @@ describe('telemetry client events', () => { expect(posthogMocks.capture).not.toHaveBeenCalledWith(expect.objectContaining({ event: 'cli_install' })); }); + it('writes the first-run notice to stderr so JSON stdout stays parseable', () => { + initTelemetry({ cliVersion: '1.2.3' }); + + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith( + 'Agent Relay collects usage telemetry to improve the product.' + ); + }); + describe('anonymous (not logged in)', () => { it('keys events by the machine hash and marks them unauthenticated', () => { const machineDistinctId = getDistinctId(); diff --git a/packages/cli/src/cli/telemetry/client.ts b/packages/cli/src/cli/telemetry/client.ts index e37ffcff2..ddfafcdb7 100644 --- a/packages/cli/src/cli/telemetry/client.ts +++ b/packages/cli/src/cli/telemetry/client.ts @@ -233,11 +233,13 @@ function showFirstRunNotice(): void { return; } - console.log(''); - console.log('Agent Relay collects usage telemetry to improve the product.'); - console.log('Run `agent-relay telemetry disable` to opt out.'); - console.log('Learn more: https://agentrelay.com/telemetry'); - console.log(''); + // Notices are diagnostics, never command data. Keeping them on stderr means + // a first run cannot corrupt commands whose stdout is a JSON contract. + console.error(''); + console.error('Agent Relay collects usage telemetry to improve the product.'); + console.error('Run `agent-relay telemetry disable` to opt out.'); + console.error('Learn more: https://agentrelay.com/telemetry'); + console.error(''); markNotified(); } diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index 681f4d743..45f9637c9 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -664,6 +664,7 @@ describe('refreshStoredAuth', () => { describe('authorizedApiFetch telemetry headers', () => { const telemetryEnvKeys = [ 'AGENT_RELAY_DISTINCT_ID', + 'AGENT_RELAY_MACHINE_ID', 'AGENT_RELAY_USER_ID', 'AGENT_RELAY_ORG_ID', 'AGENT_RELAY_ORG_SLUG', diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 94738e8cc..7f1312f7f 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -132,6 +132,7 @@ export { writeProjectWorkspaceKey, type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, + type WorkspaceKeyFileSystem, type WorkspaceKeySource, type WorkspaceSelection, } from './project-workspace-key.js'; diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index d5b6386b1..29a5c1b4f 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -145,4 +145,22 @@ describe('workspace precedence ladder diagnostics', () => { workspaceId: 'rw_repository', }); }); + + it('keeps the shared ladder authoritative when a caller injects repository-pin I/O', () => { + const env = { AGENT_RELAY_HOME: home }; + setWorkspaceKey('global', 'rk_global', env); + const fileSystem = { + readFileSync: (filePath: string, encoding: BufferEncoding): string => { + expect(filePath).toBe(projectWorkspaceKeyPath(dataDir)); + expect(encoding).toBe('utf-8'); + return JSON.stringify({ workspaceKey: 'rk_injected', workspaceId: 'rw_injected' }); + }, + }; + + expect(resolveWorkspaceSelection({ projectDataDir: dataDir, env, fileSystem })).toMatchObject({ + key: 'rk_injected', + source: 'project', + workspaceId: 'rw_injected', + }); + }); }); diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 2fbe224fa..cf39845ec 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -32,6 +32,12 @@ export interface ResolveWorkspaceKeyOptions { projectRoot?: string; /** Explicit project Relay data directory. Takes precedence over projectRoot. */ projectDataDir?: string; + /** Optional filesystem adapter for reading the repository pin. */ + fileSystem?: WorkspaceKeyFileSystem; +} + +export interface WorkspaceKeyFileSystem { + readFileSync(filePath: string, encoding: BufferEncoding): string; } /** @@ -55,14 +61,20 @@ export function projectWorkspaceKeyPath(dataDir: string): string { } /** Read a project broker's workspace key, falling through on absent or malformed state. */ -export function readProjectWorkspaceKey(dataDir: string): string | undefined { - return readProjectWorkspaceSession(dataDir)?.workspaceKey; +export function readProjectWorkspaceKey( + dataDir: string, + fileSystem: WorkspaceKeyFileSystem = fs +): string | undefined { + return readProjectWorkspaceSession(dataDir, fileSystem)?.workspaceKey; } /** Read the project workspace and its optional enrolled Fleet identity. */ -export function readProjectWorkspaceSession(dataDir: string): ProjectWorkspaceSession | undefined { +export function readProjectWorkspaceSession( + dataDir: string, + fileSystem: WorkspaceKeyFileSystem = fs +): ProjectWorkspaceSession | undefined { try { - const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); + const raw = fileSystem.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); const parsed = JSON.parse(raw) as Partial; const workspaceKey = trimOrUndefined(parsed.workspaceKey); if (!workspaceKey) return undefined; @@ -160,7 +172,7 @@ export function resolveWorkspaceSelection( } const dataDir = options.projectDataDir ?? projectDataDir(options.projectRoot); - const project = dataDir ? readProjectWorkspaceSession(dataDir) : undefined; + const project = dataDir ? readProjectWorkspaceSession(dataDir, options.fileSystem ?? fs) : undefined; if (project) { return { key: project.workspaceKey, diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index d4b7e8138..f10229583 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -9,6 +9,7 @@ export { writeProjectWorkspaceKey, type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, + type WorkspaceKeyFileSystem, type WorkspaceKeySource, type WorkspaceSelection, } from './project-workspace-key.js'; diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 03c0262c1..5e9c45dc0 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -36,6 +36,20 @@ describe('workspace store', () => { setActiveWorkspace('support'); expect(resolveActiveWorkspaceKey()).toBe('rk_support'); + expect(readWorkspaceStore().previous).toBe('ops'); + }); + + it('records only genuine active-workspace changes', () => { + setWorkspaceKey('ops', 'rk_ops'); + setActiveWorkspace('ops'); + expect(readWorkspaceStore().previous).toBeUndefined(); + + setWorkspaceKey('support', 'rk_support'); + setActiveWorkspace('support'); + expect(readWorkspaceStore()).toMatchObject({ active: 'support', previous: 'ops' }); + + setActiveWorkspace('support'); + expect(readWorkspaceStore()).toMatchObject({ active: 'support', previous: 'ops' }); }); it('throws when switching to an unknown workspace', () => { diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index e6977ff92..11ae26973 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -9,6 +9,8 @@ import path from 'node:path'; */ export interface WorkspaceStore { active?: string; + /** Workspace that was active before the most recent named selection. */ + previous?: string; workspaces: Record; } @@ -38,7 +40,12 @@ export function readWorkspaceStore(env: NodeJS.ProcessEnv = process.env): Worksp const file = workspaceStorePath(env); try { const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) as Partial; - return { active: parsed.active, workspaces: parsed.workspaces ?? {} }; + const previous = typeof parsed.previous === 'string' ? parsed.previous.trim() : ''; + return { + active: parsed.active, + ...(previous ? { previous } : {}), + workspaces: parsed.workspaces ?? {}, + }; } catch (err: unknown) { if (isNodeError(err) && err.code === 'ENOENT') { return { workspaces: {} }; @@ -75,6 +82,9 @@ export function setActiveWorkspace(name: string, env: NodeJS.ProcessEnv = proces `Unknown workspace "${workspaceName}". Add it with \`relay workspace set_key ${workspaceName} \`.` ); } + if (store.active && store.active !== workspaceName) { + store.previous = store.active; + } store.active = workspaceName; writeWorkspaceStore(store, env); return store; From 52630c4ce39f541c526331e174a3872909f2fa99 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 4 Aug 2026 12:34:05 +0200 Subject: [PATCH 3/3] fix(cli): address workspace restore review feedback --- CHANGELOG.md | 8 +- packages/cli/src/cli/commands/core.test.ts | 132 +++++++++++++++--- packages/cli/src/cli/commands/core.ts | 5 +- .../cli/src/cli/commands/workspace.test.ts | 4 +- packages/cli/src/cli/commands/workspace.ts | 1 - packages/cli/src/cli/lib/broker-lifecycle.ts | 23 ++- .../cli/src/cli/lib/workspace-session.test.ts | 2 +- .../harness-driver/src/spawn-config.test.ts | 18 +++ packages/harness-driver/src/spawn-config.ts | 6 +- 9 files changed, 164 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fd3c1de..fe64be4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,13 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `workspace create` warns on stderr when it changes the active workspace and records the prior name; named switches now record the same restore point, and first-run telemetry notices no longer contaminate JSON stdout. +- `workspace create` warns on stderr when it changes the active workspace and records the prior name; named switches now record the same restore point. +- `agent-relay node status` reports whether the broker workspace came from a command-line flag, environment variable, repository pin, machine-global active workspace, or first-run creation. ### Fixed - `agent-relay node up` resolves its installed broker through canonical package-manager links and Relay's user install directories, so mise-managed and minimal-`PATH` launches no longer fail when the broker binary is already installed. -- `agent-relay up` / `node up` use one precedence ladder: `--workspace-key` → workspace environment variables → repository pin → machine-global active workspace → creating one. A fresh project joins the active workspace instead of silently creating another, startup announces the winning source, and `node status` reports the same five-source provenance. -- Cloud enrollment selects node identity without overriding workspace resolution. A conflict with the repository pin stops startup, names both non-secret sources, and points to `workspace rebind ` as the recovery path. +- `agent-relay up` / `node up` use one precedence ladder: `--workspace-key` → workspace environment variables → repository pin → machine-global active workspace → creating one. A fresh project joins the active workspace instead of silently creating another, and startup announces the winning source. +- Enrolled-node restarts preserve the repository-pinned workspace while resuming the enrolled identity. A conflicting enrollment stops startup, names both non-secret sources, and points to `workspace rebind ` as the recovery path. +- First-run telemetry notices are written to stderr so JSON stdout remains parseable. - Detached `node up --background` surfaces early child failures and stops polling when the child exits without trying to kill an already dead process. ## [11.4.1] - 2026-08-03 diff --git a/packages/cli/src/cli/commands/core.test.ts b/packages/cli/src/cli/commands/core.test.ts index cac21e274..6bebd3d23 100644 --- a/packages/cli/src/cli/commands/core.test.ts +++ b/packages/cli/src/cli/commands/core.test.ts @@ -507,11 +507,15 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); - expect(deps.spawnProcess).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', 'up'], { - detached: true, - stdio: 'ignore', - env: deps.env, - }); + expect(deps.spawnProcess).toHaveBeenCalledWith( + '/usr/bin/node', + ['/tmp/agent-relay.js', 'up', '--background-child'], + { + detached: true, + stdio: 'ignore', + env: deps.env, + } + ); expect(spawnedProcess.unref).toHaveBeenCalled(); expect(sleepImpl).toHaveBeenCalledWith(500); expect(sdkStatusClient.getStatus).toHaveBeenCalledTimes(1); @@ -570,7 +574,15 @@ describe('registerCoreCommands', () => { // `ps` for the daemon's whole lifetime) must never carry it. expect(deps.spawnProcess).toHaveBeenCalledWith( '/usr/bin/node', - ['/tmp/agent-relay.js', 'up', '--state-dir', stateDir, '--broker-name', 'relayfile-dev'], + [ + '/tmp/agent-relay.js', + 'up', + '--state-dir', + stateDir, + '--broker-name', + 'relayfile-dev', + '--background-child', + ], { detached: true, stdio: 'ignore', @@ -666,7 +678,7 @@ describe('registerCoreCommands', () => { expect(exitCode).toBe(0); expect(deps.spawnProcess).toHaveBeenCalledWith( '/tmp/agent-relay-darwin-arm64', - ['node', 'up', '--config', 'agent-relay.mjs', '--broker-name', 'sf-mini'], + ['node', 'up', '--config', 'agent-relay.mjs', '--broker-name', 'sf-mini', '--background-child'], { detached: true, stdio: 'ignore', @@ -934,6 +946,35 @@ describe('registerCoreCommands', () => { ); }); + it.each(['../../../etc/relay-background-error', '/tmp/relay-background-error-escape'])( + 'detached-child failure ignores an untrusted background error path %s', + async (untrustedPath) => { + const fs = createFsMock(); + const relay = createRelayMock({ + getStatus: vi.fn(async () => { + throw new Error('detached child failed'); + }), + }); + const { program, dataDir } = createHarness({ + fs, + relay, + env: { + AGENT_RELAY_BACKGROUND_START_ERROR_FILE: untrustedPath, + }, + }); + + const exitCode = await runCommand(program, ['up', '--background-child']); + + expect(exitCode).toBe(1); + expect(fs.writeFileSync).toHaveBeenCalledWith( + `${dataDir}/background-start-error.log`, + 'detached child failed\n', + 'utf-8' + ); + expect(fs.writeFileSync).not.toHaveBeenCalledWith(untrustedPath, expect.anything(), expect.anything()); + } + ); + it('down --force only kills actual orphaned broker executables for the project', async () => { const runningPids = new Set([222, 444, 666]); const execCommand = vi.fn(async (command: string) => { @@ -1629,6 +1670,43 @@ describe('registerCoreCommands', () => { expect(env.RELAY_API_KEY).toBe('rk_live_pinned'); }); + it('up resumes the repository pin when an enrolled node token is present', async () => { + const env: NodeJS.ProcessEnv = { + RELAY_NODE_ID: 'node_enrolled', + RELAY_NODE_TOKEN: 'nt_enrolled', + }; + const projectSessionPath = '/tmp/project/.agentworkforce/relay/workspace-key.json'; + const fs = createFsMock({ + [projectSessionPath]: JSON.stringify({ + workspaceKey: 'rk_live_project_pin', + workspaceId: 'rw_project', + enrolledNodeId: 'node_enrolled', + }), + }); + const relay = createRelayMock({ + workspaceKey: 'rk_live_project_pin', + workspaceId: 'rw_project', + }); + const createRelay = vi.fn(async () => { + // This is the non-mocked handoff to broker creation: the project pin is + // already canonicalized even though the enrolled identity is present. + expect(env.RELAY_WORKSPACE_KEY).toBe('rk_live_project_pin'); + expect(env.RELAY_API_KEY).toBe('rk_live_project_pin'); + expect(env.RELAY_NODE_TOKEN).toBe('nt_enrolled'); + return relay; + }); + const { program, deps } = createHarness({ fs, env, relay, createRelay }); + + const exitCode = await runCommand(program, ['up']); + + expect(exitCode).toBeUndefined(); + expect(createRelay).toHaveBeenCalledTimes(1); + expect(deps.log).toHaveBeenCalledWith( + 'Workspace source: repository pin (/tmp/project/.agentworkforce/relay/workspace-key.json)' + ); + expect(deps.log).toHaveBeenCalledWith('Workspace: joined rw_project'); + }); + it('up treats a non-blank workspace env alias as explicit when the primary is blank', async () => { const env: NodeJS.ProcessEnv = { RELAY_WORKSPACE_KEY: ' ', @@ -1676,7 +1754,7 @@ describe('registerCoreCommands', () => { } }); - it('background up forwards a resumed enrolled-node association to the detached child', async () => { + it('background up forwards the repository pin with an enrolled identity to the detached child', async () => { const spawnedProcess = createSpawnedProcessMock(); let now = 0; const projectSessionPath = '/tmp/project/.agentworkforce/relay/workspace-key.json'; @@ -1694,9 +1772,21 @@ describe('registerCoreCommands', () => { if ((pid === 9001 || pid === 5151) && signal === 0) return; throw new Error('unexpected kill check'); }); + sdkStatusClient.getStatus.mockResolvedValue({ + node_connected: true, + node_delivery: { token_present: true, connected: true }, + }); + sdkStatusClient.getSession.mockResolvedValue({ + workspace_key: 'rk_live_pinned', + node_id: 'node_enrolled', + node_name: 'project', + }); const { program, deps } = createHarness({ fs, - env: {}, + env: { + RELAY_NODE_ID: 'node_enrolled', + RELAY_NODE_TOKEN: 'nt_enrolled', + }, spawnedProcess, killImpl, nowImpl: vi.fn(() => now), @@ -1706,15 +1796,21 @@ describe('registerCoreCommands', () => { const exitCode = await runCommand(program, ['up', '--background']); expect(exitCode).toBe(0); - expect(deps.spawnProcess).toHaveBeenCalledWith('/usr/bin/node', ['/tmp/agent-relay.js', 'up'], { - detached: true, - stdio: 'ignore', - env: expect.objectContaining({ - AGENT_RELAY_ENROLLED_NODE_ID: 'node_enrolled', - RELAY_API_KEY: 'rk_live_pinned', - RELAY_WORKSPACE_KEY: 'rk_live_pinned', - }), - }); + expect(deps.spawnProcess).toHaveBeenCalledWith( + '/usr/bin/node', + ['/tmp/agent-relay.js', 'up', '--background-child'], + { + detached: true, + stdio: 'ignore', + env: expect.objectContaining({ + AGENT_RELAY_ENROLLED_NODE_ID: 'node_enrolled', + RELAY_API_KEY: 'rk_live_pinned', + RELAY_NODE_ID: 'node_enrolled', + RELAY_NODE_TOKEN: 'nt_enrolled', + RELAY_WORKSPACE_KEY: 'rk_live_pinned', + }), + } + ); }); it('up configures a bundled Agent Relay MCP command when the wrapper script exists', async () => { diff --git a/packages/cli/src/cli/commands/core.ts b/packages/cli/src/cli/commands/core.ts index aa5b5256e..07113667d 100644 --- a/packages/cli/src/cli/commands/core.ts +++ b/packages/cli/src/cli/commands/core.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { exec, spawn as spawnProcess } from 'node:child_process'; import { promisify } from 'node:util'; -import { Command, InvalidArgumentError } from 'commander'; +import { Command, InvalidArgumentError, Option } from 'commander'; import { getProjectPaths, loadTeamsConfig } from '@agent-relay/config'; import { HarnessDriverClient, type BrokerInitArgs } from '@agent-relay/harness-driver'; @@ -278,6 +278,8 @@ export function withDefaults(overrides: Partial = {}): CoreDep export interface UpCommandOptions { spawn?: boolean; background?: boolean; + /** Internal marker set only on the detached child re-exec. */ + backgroundChild?: boolean; verbose?: boolean; workspaceKey?: string; stateDir?: string; @@ -298,6 +300,7 @@ export function addUpCommandOptions(command: Command): Command { .option('--spawn', 'Force spawn all agents from teams.json') .option('--no-spawn', 'Do not auto-spawn agents (just start broker)') .option('--background', 'Run broker in the background (detached)') + .addOption(new Option('--background-child').hideHelp()) .option('--verbose', 'Enable verbose logging') .option('--workspace-key ', 'Use a pre-established Relaycast workspace key') .option('--wk ', 'Alias for --workspace-key') diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 01461abec..3cc9449af 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -186,7 +186,7 @@ describe('registerWorkspaceCommands', () => { expect(() => JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).not.toThrow(); }); - it('workspace create --json keeps stdout parseable and routes the warning away from it', async () => { + it('workspace create keeps stdout parseable and routes the warning away from it', async () => { vi.mocked(readWorkspaceStore).mockReturnValueOnce({ active: 'default', workspaces: { default: { key: 'rk_live_default' } }, @@ -196,7 +196,7 @@ describe('registerWorkspaceCommands', () => { workspaceKey: 'rk_live_json_workspace', } as never); - await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'json-workspace', '--json']); + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'json-workspace']); expect(vi.mocked(deps.log).mock.calls).toHaveLength(1); expect(JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0]))).toMatchObject({ diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index c457de12f..04e2e1e00 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -82,7 +82,6 @@ export function registerWorkspaceCommands( .description('Create a new workspace and store its key') .argument('', 'Workspace name') .option('--base-url ', 'Override the API base URL') - .option('--json', 'Output the created workspace as JSON') .option('--reveal-secrets', 'Include the raw workspace key in the output') .action(async (name: string, o: Record) => { await runSdk(deps, async () => { diff --git a/packages/cli/src/cli/lib/broker-lifecycle.ts b/packages/cli/src/cli/lib/broker-lifecycle.ts index 5417c9d29..d8cebbc6f 100644 --- a/packages/cli/src/cli/lib/broker-lifecycle.ts +++ b/packages/cli/src/cli/lib/broker-lifecycle.ts @@ -36,6 +36,8 @@ import { type UpOptions = { spawn?: boolean; background?: boolean; + /** Internal marker set only on the detached child re-exec. */ + backgroundChild?: boolean; verbose?: boolean; workspaceKey?: string; stateDir?: string; @@ -777,11 +779,18 @@ function readBackgroundStartError(dataDir: string, deps: CoreDependencies): stri } } -function recordBackgroundStartError(message: string, deps: CoreDependencies): void { - const file = deps.env.AGENT_RELAY_BACKGROUND_START_ERROR_FILE?.trim(); - if (!file) return; +function recordBackgroundStartError( + message: string, + dataDir: string, + isDetachedChild: boolean, + deps: CoreDependencies +): void { + if (!isDetachedChild) return; try { - deps.fs.writeFileSync(file, `${message}\n`, 'utf-8'); + // Never trust a project-loaded environment variable as a filesystem path. + // Detached startup owns one fixed diagnostic file inside its resolved + // broker state directory; foreground failures do not write it at all. + deps.fs.writeFileSync(backgroundStartErrorPath(dataDir), `${message}\n`, 'utf-8'); } catch { // Diagnostics must never replace the original startup error. } @@ -1076,6 +1085,9 @@ function childUpArgsForDetachedStart(options: UpOptions, deps: CoreDependencies) if (options.verbose === true && !args.includes('--verbose')) { args.push('--verbose'); } + if (!args.includes('--background-child')) { + args.push('--background-child'); + } return args; } @@ -1488,7 +1500,6 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): const startErrorPath = backgroundStartErrorPath(paths.dataDir); safeUnlink(startErrorPath, deps); - deps.env.AGENT_RELAY_BACKGROUND_START_ERROR_FILE = startErrorPath; const args = childUpArgsForDetachedStart(options, deps); const invocation = detachedCliInvocation(deps, args); let child: SpawnedProcess; @@ -1808,7 +1819,7 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): error_class: classifyBrokerStartError(err), }); const detailedMessage = describeErrorWithCause(err); - recordBackgroundStartError(detailedMessage, deps); + recordBackgroundStartError(detailedMessage, paths.dataDir, options.backgroundChild === true, deps); if (isBrokerAlreadyRunningError(message)) { reportAlreadyRunningError(message, paths.dataDir, deps); } else { diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 55999665e..c61b04bad 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -147,7 +147,7 @@ describe('workspace session persistence', () => { expect(resolveWorkspaceSessionKey({ projectDataDir, env })).toBe('rk_live_project'); }); - it('rebinds the project without changing the machine-global active workspace or old enrollment', () => { + it('rebinds the project without changing the machine-global active workspace and clears the old enrollment', () => { const root = tempRoot(); const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); const env = isolatedEnv(root); diff --git a/packages/harness-driver/src/spawn-config.test.ts b/packages/harness-driver/src/spawn-config.test.ts index 9fc18ce24..1b7afb7d6 100644 --- a/packages/harness-driver/src/spawn-config.test.ts +++ b/packages/harness-driver/src/spawn-config.test.ts @@ -82,4 +82,22 @@ describe('buildBrokerSpawnConfig', () => { '/tmp/relay-state', ]); }); + + it('prefers RELAY_WORKSPACE_KEY over AGENT_RELAY_WORKSPACE_KEY in the same env', () => { + const config = buildBrokerSpawnConfig( + { + cwd: '/tmp/my-project', + env: { + RELAY_WORKSPACE_KEY: 'rk_live_primary', + AGENT_RELAY_WORKSPACE_KEY: 'rk_live_alias', + }, + }, + 'br_test', + {} + ); + + expect(config.workspaceKey).toBe('rk_live_primary'); + expect(config.env.RELAY_WORKSPACE_KEY).toBe('rk_live_primary'); + expect(config.env.AGENT_RELAY_WORKSPACE_KEY).toBe('rk_live_primary'); + }); }); diff --git a/packages/harness-driver/src/spawn-config.ts b/packages/harness-driver/src/spawn-config.ts index ee0212ab3..f01047f34 100644 --- a/packages/harness-driver/src/spawn-config.ts +++ b/packages/harness-driver/src/spawn-config.ts @@ -94,10 +94,10 @@ export function buildBrokerSpawnConfig( (path.basename(cwd) || 'project'); const workspaceKey = nonEmptyString(options?.workspaceKey) ?? - nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? nonEmptyString(options?.env?.RELAY_WORKSPACE_KEY) ?? - nonEmptyString(parentEnv.AGENT_RELAY_WORKSPACE_KEY) ?? - nonEmptyString(parentEnv.RELAY_WORKSPACE_KEY); + nonEmptyString(options?.env?.AGENT_RELAY_WORKSPACE_KEY) ?? + nonEmptyString(parentEnv.RELAY_WORKSPACE_KEY) ?? + nonEmptyString(parentEnv.AGENT_RELAY_WORKSPACE_KEY); const channels = options?.channels ?? ['general']; const timeoutMs = options?.startupTimeoutMs ?? 45_000; const userArgs = buildBrokerInitArgs(options?.binaryArgs);