diff --git a/CHANGELOG.md b/CHANGELOG.md index da433bd49..fe64be4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,24 @@ 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. +- `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, 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/README.md b/packages/cli/README.md index adfd75b97..1db68a8e7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -47,6 +47,68 @@ 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. +### 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: + +| # | 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 | Created workspace | created only when nothing above resolves | + +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. + +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. + +```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 +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 The `fleet` command group lists and controls agents across all live nodes in 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..6bebd3d23 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); @@ -494,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); @@ -557,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', @@ -566,6 +591,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'); @@ -652,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', @@ -882,6 +908,73 @@ 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.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) => { @@ -1173,7 +1266,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 +1286,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) }); @@ -1538,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: ' ', @@ -1585,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'; @@ -1603,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), @@ -1615,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 5b5008160..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'; @@ -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; }, @@ -273,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; @@ -293,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/node.test.ts b/packages/cli/src/cli/commands/node.test.ts index 572481e2d..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), @@ -47,7 +48,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 +244,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 +259,65 @@ 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'); + 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'); + 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 @@ -292,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 36711aa53..0124179fa 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,42 @@ 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( + '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; } /** Apply stored enrollment credentials and return the enrolled node name, when present. */ @@ -130,7 +166,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 +182,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 +223,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/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 252904a8a..3cc9449af 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 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']); + + 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..04e2e1e00 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; @@ -84,7 +88,12 @@ export function registerWorkspaceCommands( 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 +113,7 @@ export function registerWorkspaceCommands( const store = readWorkspaceStore(); printJson(deps, { active: store.active, + previous: store.previous, workspaces: Object.keys(store.workspaces), }); }); @@ -170,4 +180,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 299d796cd..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'); @@ -221,6 +229,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 +257,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 +307,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 +502,111 @@ 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(); + 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'); + expect(readBindingSource(dataDir)).toBe('project'); + }); + + 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'); + expect(readBindingSource(deps.getProjectPaths().dataDir)).toBe('store'); + }); + + it('announces a mint when no source resolves (#1378)', async () => { + const { deps, dataDir, 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'); + expect(readBindingSource(dataDir)).toBe('created'); + }); + + 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'); + 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 () => { + 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..d8cebbc6f 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'; @@ -24,12 +25,19 @@ 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 { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; +import { + readProjectWorkspaceSession, + resolveWorkspaceSelection, + writeProjectWorkspaceKey, + type ProjectWorkspaceSession, + type WorkspaceSelection, +} from './project-workspace-key.js'; type UpOptions = { spawn?: boolean; background?: boolean; + /** Internal marker set only on the detached child re-exec. */ + backgroundChild?: boolean; verbose?: boolean; workspaceKey?: string; stateDir?: string; @@ -65,6 +73,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; @@ -73,11 +83,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 = { @@ -291,7 +305,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(' — ')); } /** @@ -713,6 +727,75 @@ 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, + dataDir: string, + isDetachedChild: boolean, + deps: CoreDependencies +): void { + if (!isDetachedChild) return; + try { + // 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. + } +} + function readBrokerPid(dataDir: string, _deps: CoreDependencies): number | null { const conn = readBrokerConnectionFromFs(_deps.fs, dataDir); return conn?.pid ?? null; @@ -959,6 +1042,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 { @@ -1001,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; } @@ -1109,13 +1196,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); @@ -1287,57 +1378,71 @@ function planCapacitySource( return plan.mode === 'in-process' ? plan.definition : descriptorCapacitySource(plan.descriptor); } -interface PinnedProjectWorkspaceSession { - workspaceKey: string; - enrolledNodeId?: 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; - }>; - const workspaceKey = - typeof parsed.workspaceKey === 'string' ? parsed.workspaceKey.trim() || undefined : undefined; - if (!workspaceKey) { - return undefined; - } - const enrolledNodeId = - typeof parsed.enrolledNodeId === 'string' ? parsed.enrolledNodeId.trim() || undefined : undefined; - return { - workspaceKey, - ...(enrolledNodeId ? { enrolledNodeId } : {}), - }; - } catch { +/** + * 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 +): ProjectWorkspaceSession | 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; } -} -/** Resume the pinned project session unless explicit credentials override it. */ -function resumePinnedProjectWorkspace( - options: UpOptions, - deps: CoreDependencies, - projectDataDir: string -): PinnedProjectWorkspaceSession | undefined { - const explicitEnvWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.env); - if (options.workspaceKey?.trim() || explicitEnvWorkspaceKey || deps.env.RELAY_NODE_TOKEN?.trim()) { + deps.log(`Workspace source: ${describeWorkspaceSource(selection.source)} (${selection.origin})`); + // 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; + if (selection.source === 'project' || selection.source === 'store') { + deps.env.RELAY_API_KEY = selection.key; + } + if (selection.source !== 'project') { 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; - } + const pinned = readProjectWorkspaceSession(projectDataDir, deps.fs); + if (pinned?.enrolledNodeId) { + deps.env.AGENT_RELAY_ENROLLED_NODE_ID = pinned.enrolledNodeId; } - return session; + 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'; + } +} + +/** + * 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 { @@ -1350,7 +1455,14 @@ 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 = 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) { const resolved = path.resolve(options.stateDir); @@ -1386,6 +1498,8 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): return; } + const startErrorPath = backgroundStartErrorPath(paths.dataDir); + safeUnlink(startErrorPath, deps); const args = childUpArgsForDetachedStart(options, deps); const invocation = detachedCliInvocation(deps, args); let child: SpawnedProcess; @@ -1411,23 +1525,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') { @@ -1495,6 +1622,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; } @@ -1569,10 +1697,29 @@ 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)'); 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 switch `.' + ); + } deps.log('Broker started.'); // Record the workspace this broker joined (explicitly passed or auto-minted) @@ -1583,6 +1730,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 @@ -1667,10 +1818,12 @@ export async function runUpCommand(options: UpOptions, deps: CoreDependencies): stage, error_class: classifyBrokerStartError(err), }); + const detailedMessage = describeErrorWithCause(err); + recordBackgroundStartError(detailedMessage, paths.dataDir, options.backgroundChild === true, 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); } @@ -1829,6 +1982,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 0cac556c4..5d97af820 100644 --- a/packages/cli/src/cli/lib/project-workspace-key.ts +++ b/packages/cli/src/cli/lib/project-workspace-key.ts @@ -4,6 +4,10 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, + 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..c61b04bad 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 and clears the 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 bcdd014a5..7f1312f7f 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -125,12 +125,16 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, + resolveWorkspaceSelection, writeProjectWorkspaceKey, type ProjectWorkspaceSession, type ResolveWorkspaceKeyOptions, + type WorkspaceKeyFileSystem, 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..29a5c1b4f 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,74 @@ 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', + }); + }); + + 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 5fbb77de1..cf39845ec 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'; @@ -23,6 +32,27 @@ 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; +} + +/** + * 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`. */ @@ -31,21 +61,29 @@ 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; const enrolledNodeId = trimOrUndefined(parsed.enrolledNodeId); + const workspaceId = trimOrUndefined(parsed.workspaceId); return { workspaceKey, ...(enrolledNodeId ? { enrolledNodeId } : {}), + ...(workspaceId ? { workspaceId } : {}), }; } catch { return undefined; @@ -59,11 +97,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 +111,7 @@ export function writeProjectWorkspaceKey( { workspaceKey: key, ...(enrolledNodeId ? { enrolledNodeId } : {}), + ...(workspaceId ? { workspaceId } : {}), } satisfies ProjectWorkspaceSession, null, 2 @@ -102,29 +142,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, options.fileSystem ?? fs) : 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..f10229583 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -2,10 +2,14 @@ export { projectWorkspaceKeyPath, readProjectWorkspaceKey, readProjectWorkspaceSession, + resolveActiveWorkspaceSelection, resolveWorkspaceKey, resolveWorkspaceKeyWithSource, + resolveWorkspaceSelection, 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; 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; } 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);