diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b845f47..1a4bea8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ 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] +## [Unreleased - Patch] + +### Fixed + +- `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.3.1] - 2026-07-31 diff --git a/packages/cli/README.md b/packages/cli/README.md index 4d12f1550..ba976f4a1 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..541704e03 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,123 @@ 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( +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; +} + +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 +1436,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 +1660,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 +1682,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; }