diff --git a/CHANGELOG.md b/CHANGELOG.md index 91620d906..a9ef68e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 node up` warns instead of silently ignoring stored Cloud fleet enrollments when the project workspace pin has no enrolled node id. That combination started the broker in the pinned workspace while the node never heartbeat, leaving the Cloud dashboard and `agent-relay fleet nodes` showing different rosters with no error from either. +- `agent-relay cloud enroll` records the enrolled node on the project workspace pin, so `node up` in that repo serves the node it just enrolled. A pin that already names a different node is reported and left untouched rather than repointed. +- `agent-relay workspace switch|join` keeps the project's enrolled fleet node id instead of dropping it, which previously produced the pin state that made the next `node up` ignore the enrollment store. ## [11.4.1] - 2026-08-03 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..94f664185 100644 --- a/packages/cli/src/cli/agent-relay-mcp.startup.test.ts +++ b/packages/cli/src/cli/agent-relay-mcp.startup.test.ts @@ -29,7 +29,8 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) { const telemetryTrack = vi.fn(); const telemetryInit = vi.fn(); const telemetryShutdown = vi.fn(async () => undefined); - const persistWorkspaceSession = vi.fn(); + // Returns a result object describing what the write changed beyond the key. + const persistWorkspaceSession = vi.fn(() => ({})); const resolveWorkspaceSessionKey = vi.fn(() => options.persistedWorkspaceKey); const validateWorkspaceSessionName = vi.fn((name: string) => { const trimmed = name.trim(); @@ -258,8 +259,12 @@ async function loadAgentRelayMcpModule(options: LoadOptions = {}) { shutdown: telemetryShutdown, track: telemetryTrack, })); - vi.doMock('./lib/workspace-session.js', () => ({ + vi.doMock('./lib/workspace-session.js', async (importOriginal) => ({ persistWorkspaceSession, + // The real formatter, not a copy, so the warning these tools return cannot + // drift away from what the CLI prints for the same event. + describeClearedEnrollment: (await importOriginal()) + .describeClearedEnrollment, resolveWorkspaceSessionKey, validateWorkspaceSessionName, })); @@ -574,6 +579,22 @@ describe('createAgentRelayMcpServer', () => { expect(mocks.relayInstances.some((instance) => instance.config.apiKey === 'rk_live_selected')).toBe(true); }); + it('reports an enrolled fleet node dropped by joining another workspace', async () => { + const { mod, mocks } = await loadAgentRelayMcpModule(); + mocks.persistWorkspaceSession.mockReturnValueOnce({ clearedEnrolledNodeId: 'node_abc' }); + + mod.createAgentRelayMcpServer({ baseUrl: 'https://relay.example.com/' }); + const server = mocks.serverInstances[0]; + const result = await server.tools + .get('set_workspace_key') + ?.handler({ workspace_key: 'rk_live_selected' }); + + // Silence here is what left the fleet node shadowed until some later + // `node up` mentioned it. + expect(result.structuredContent.message).toContain('node_abc'); + expect(result.structuredContent.message).toContain('relay cloud enroll'); + }); + it('registers submit_result when a spawned-agent result callback is configured', async () => { vi.stubEnv('AGENT_RELAY_RESULT_URL', 'http://127.0.0.1:3889/api/agent-result'); vi.stubEnv('AGENT_RELAY_RESULT_TOKEN', 'arr_test'); diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 10b5ff401..d24af51b3 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -33,6 +33,7 @@ import { registerAgentRelayActionTools } from './mcp/action-tools.js'; import { registerMessagingTools } from './mcp/messaging-tools.js'; import { identityOverrideInputShape, messageResult } from './mcp/tool-shapes.js'; import { + describeClearedEnrollment, persistWorkspaceSession, resolveWorkspaceSessionKey, validateWorkspaceSessionName, @@ -409,7 +410,7 @@ function registerAgentRelayTools( title: 'Create Workspace', description: 'Explicitly start a new Agent Relay workspace session and persist it for this project. ' + - 'Returns the new workspace key and its resolved name. A `warning` field is present only when the workspace was created but its session could not be saved to disk, meaning the key must be kept and re-supplied to reconnect.', + "Returns the new workspace key and its resolved name. A `warning` field appears in two cases, and its text says which: the workspace was created but its session could not be saved to disk, meaning the key must be kept and re-supplied to reconnect; or the session was saved and doing so dropped this project's enrolled Cloud fleet node, because the new workspace is not the one that node belongs to.", inputSchema: { name: z.string().describe('Human-readable workspace name'), }, @@ -438,7 +439,12 @@ function registerAgentRelayTools( }); let persistenceWarning: string | undefined; try { - persistWorkspaceSession({ name: workspaceName, workspaceKey }); + // A new workspace key never matches an existing pin, so this can drop + // the project's enrolled fleet node. Report it rather than letting the + // next `node up` be the first thing that mentions it. + persistenceWarning = describeClearedEnrollment( + persistWorkspaceSession({ name: workspaceName, workspaceKey }) + ); } catch (error) { const message = error instanceof Error ? error.message : String(error); persistenceWarning = @@ -459,7 +465,7 @@ function registerAgentRelayTools( title: 'Set Workspace Key', description: 'Join this MCP session to an existing Agent Relay workspace using a shared workspace key. ' + - 'Returns a confirmation message stating whether the key was persisted for this project, and whether "register_agent" must be called to claim an identity in the newly joined workspace.', + 'Returns a confirmation message stating whether the key was persisted for this project, and whether "register_agent" must be called to claim an identity in the newly joined workspace. The message also reports when joining dropped this project\'s enrolled Cloud fleet node, which happens when the key names a workspace that node does not belong to.', inputSchema: { workspace_key: z.string().optional().describe('Workspace key starting with "rk_live_"'), api_key: z.string().optional().describe('Deprecated alias for workspace_key'), @@ -495,7 +501,9 @@ function registerAgentRelayTools( } let persistenceWarning: string | undefined; try { - persistWorkspaceSession({ workspaceKey: key }); + // Joining a different workspace drops this project's enrolled fleet + // node; surface that here instead of at the next `node up`. + persistenceWarning = describeClearedEnrollment(persistWorkspaceSession({ workspaceKey: key })); } catch (error) { const persistenceError = error instanceof Error ? error.message : String(error); persistenceWarning = diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index c705b61be..acb639da0 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -86,6 +86,7 @@ function createHarness(overrides?: Partial) { const deps: CloudDependencies = { log: vi.fn(() => undefined), + warn: vi.fn(() => undefined), error: vi.fn(() => undefined), exit, ensureCloudSession: vi.mocked(ensureCloudSession), @@ -94,6 +95,10 @@ function createHarness(overrides?: Partial) { upsertFleetNodeEnrollment: cloudMocks.upsertFleetNodeEnrollment as unknown as CloudDependencies['upsertFleetNodeEnrollment'], writeEnrollmentRecoveryFile: vi.fn(() => '/tmp/cloud-enrollment-recovery.json'), + // Stubbed by default so no test can reach the real checkout's workspace pin. + linkEnrolledNodeToProjectPin: vi.fn(() => ({ + status: 'no-pin', + })) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin'], ...overrides, }; @@ -1770,6 +1775,121 @@ describe('registerCloudCommands', () => { expect(cloudMocks.upsertFleetNodeEnrollment).not.toHaveBeenCalled(); }); + it('cloud enroll links the enrolled node to this project workspace pin', async () => { + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'kjglaptop', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_123', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'https://relaycast.example.com/v1/node/ws', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const linkEnrolledNodeToProjectPin = vi.fn(() => ({ + status: 'linked', + nodeId: 'node_abc', + pinPath: '/repo/.agentworkforce/relay/workspace-key.json', + })) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin']; + const log = vi.fn(); + const { program } = createHarness({ log, linkEnrolledNodeToProjectPin }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']); + + expect(linkEnrolledNodeToProjectPin).toHaveBeenCalledWith({ nodeId: 'node_abc' }); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('/repo/.agentworkforce/relay/workspace-key.json'); + expect(output).toContain('node_abc'); + // The pinned key holds a workspace *key* and the enrollment holds a + // workspace *id*, so the link cannot be verified locally. Say which + // workspace will actually be served and do not claim more than that. + expect(output).toContain('rw_123'); + expect(output).toContain('was not verified'); + }); + + it('cloud enroll warns instead of repointing a pin that names another node', async () => { + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_new', + nodeName: 'kjglaptop', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_123', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'https://relaycast.example.com/v1/node/ws', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const linkEnrolledNodeToProjectPin = vi.fn(() => ({ + status: 'conflict', + nodeId: 'node_new', + pinnedNodeId: 'node_existing', + pinPath: '/repo/.agentworkforce/relay/workspace-key.json', + })) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin']; + const warn = vi.fn(); + const { program } = createHarness({ warn, linkEnrolledNodeToProjectPin }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']); + + const warned = warn.mock.calls.flat().join('\n'); + expect(warned).toContain('already linked to node node_existing'); + expect(warned).toContain('node_new'); + }); + + it('cloud enroll survives a pin write failure without failing the redeemed enrollment', async () => { + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'kjglaptop', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_123', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'https://relaycast.example.com/v1/node/ws', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const linkEnrolledNodeToProjectPin = vi.fn(() => { + throw new Error('EACCES: permission denied'); + }) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin']; + const log = vi.fn(); + const warn = vi.fn(); + const { program, deps } = createHarness({ log, warn, linkEnrolledNodeToProjectPin }); + + await program.parseAsync(['node', 'agent-relay', 'cloud', 'enroll', '--token', 'ocl_node_enr_x']); + + expect(deps.exit).not.toHaveBeenCalled(); + expect(log.mock.calls.flat().join('\n')).toContain('Enrolled node "kjglaptop"'); + expect(warn.mock.calls.flat().join('\n')).toContain('EACCES'); + }); + + it('cloud enroll --json keeps pin reporting off stdout', async () => { + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_abc', + nodeName: 'kjglaptop', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_123', + relaycastUrl: 'https://relaycast.example.com', + websocketUrl: 'https://relaycast.example.com/v1/node/ws', + }); + cloudMocks.upsertFleetNodeEnrollment.mockReturnValueOnce({ version: 1, active: {}, nodes: {} }); + const linkEnrolledNodeToProjectPin = vi.fn(() => ({ + status: 'linked', + nodeId: 'node_abc', + pinPath: '/repo/.agentworkforce/relay/workspace-key.json', + })) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin']; + const log = vi.fn(); + const { program } = createHarness({ log, linkEnrolledNodeToProjectPin }); + + await program.parseAsync([ + 'node', + 'agent-relay', + 'cloud', + 'enroll', + '--token', + 'ocl_node_enr_x', + '--json', + ]); + + // The pin is still reconciled, but stdout stays parseable JSON. + expect(linkEnrolledNodeToProjectPin).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledTimes(1); + expect(() => JSON.parse(String(log.mock.calls[0][0]))).not.toThrow(); + }); + it('cloud enroll rejects a non-positive --max-agents', async () => { const { program } = createHarness(); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index b3c1e1df9..b4bbef5fd 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -34,6 +34,7 @@ import { type WorkflowSchedule, } from '@agent-relay/cloud'; +import { linkEnrolledNodeToProjectPin, type EnrolledNodePinResult } from '../lib/enrollment-pin.js'; import { defaultExit } from '../lib/exit.js'; import { sanitizeForTerminalLine } from '../lib/formatting.js'; import { maskSecret } from '../lib/redact.js'; @@ -62,6 +63,7 @@ type ExitFn = (code: number) => never; export interface CloudDependencies { log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; ensureCloudSession: typeof ensureCloudSession; @@ -74,6 +76,8 @@ export interface CloudDependencies { * "recovery write also failed" last resort. */ writeEnrollmentRecoveryFile: (record: unknown) => string; + /** Reconcile a freshly enrolled node against this project's workspace pin. */ + linkEnrolledNodeToProjectPin: typeof linkEnrolledNodeToProjectPin; } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -81,6 +85,7 @@ export interface CloudDependencies { function withDefaults(overrides: Partial = {}): CloudDependencies { return { log: (...args: unknown[]) => console.log(...args), + warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, ensureCloudSession, @@ -98,6 +103,7 @@ function withDefaults(overrides: Partial = {}): CloudDependen fs.chmodSync(file, 0o600); return file; }, + linkEnrolledNodeToProjectPin, ...overrides, }; } @@ -447,6 +453,35 @@ async function mintFleetNodeEnrollment( } } +/** + * Link a redeemed enrollment to this project's workspace pin and report anything + * the operator must act on. Runs after the one-time token is already consumed, + * so every failure here is reported and swallowed — never fatal. + */ +function reconcileEnrollmentPin( + nodeId: string, + deps: Pick +): EnrolledNodePinResult | undefined { + try { + const result = deps.linkEnrolledNodeToProjectPin({ nodeId }); + if (result.status === 'conflict') { + deps.warn( + `This project's workspace pin (${result.pinPath}) is already linked to node ${result.pinnedNodeId}, ` + + `so it was left unchanged. 'relay node up' here will keep serving ${result.pinnedNodeId}, not the node ` + + `just enrolled (${result.nodeId}). Update or remove the pin to serve the new node.` + ); + } + return result; + } catch (err) { + deps.warn( + `Enrollment succeeded but this project's workspace pin could not be updated: ${ + err instanceof Error ? err.message : String(err) + }. 'relay node up' in this project may ignore the new enrollment.` + ); + return undefined; + } +} + async function resolveFleetNodeEnrollmentInput( options: { token?: string; @@ -968,6 +1003,11 @@ export function registerCloudCommands(program: Command, overrides: Partial undefined) as unknown as NodeCommandDependencies['resolveEnrollment']); const resolveProjectWorkspaceSession = opts?.resolveProjectWorkspaceSession ?? vi.fn(() => undefined); + // Never let a test read the developer's real fleet-enrollments.json. + const listFleetEnrollments = (opts?.listFleetEnrollments ?? + vi.fn(() => [])) as NodeCommandDependencies['listFleetEnrollments']; const program = new Command(); program.exitOverride(); @@ -62,6 +66,7 @@ function createNodeHarness(opts?: { error, warn, resolveEnrollment, + listFleetEnrollments, resolveProjectWorkspaceSession, }); @@ -70,8 +75,10 @@ function createNodeHarness(opts?: { env, log, error, + warn, exit, resolveEnrollment, + listFleetEnrollments, resolveProjectWorkspaceSession, }; } @@ -258,6 +265,83 @@ describe('registerNodeCommands', () => { expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); + it('warns that a project pin without an enrolled node id is shadowing stored enrollments', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const listFleetEnrollments = vi.fn(() => [ + enrollmentRecord, + ]) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, warn, env } = createNodeHarness({ + env: {}, + resolveEnrollment, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ workspaceKey: 'rk_project_session' })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + // The silent case: the pin wins, the enrollment is dropped, and before this + // fix nothing was printed at all. + expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + const warned = warn.mock.calls.flat().join('\n'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warned).toContain('1 stored Cloud fleet enrollment(s)'); + expect(warned).toContain('pinned to a workspace with no enrolled node id'); + expect(warned).toContain('relay cloud enroll'); + }); + + it('stays quiet when a project pin shadows nothing', async () => { + const { program, warn } = createNodeHarness({ + env: {}, + listFleetEnrollments: vi.fn(() => []) as unknown as NodeCommandDependencies['listFleetEnrollments'], + resolveProjectWorkspaceSession: vi.fn(() => ({ workspaceKey: 'rk_project_session' })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(warn).not.toHaveBeenCalled(); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('still warns and still starts when the enrollment store cannot be read', async () => { + const listFleetEnrollments = vi.fn(() => { + throw new Error('Fleet enrollment store at /tmp/fleet-enrollments.json is corrupt'); + }) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, warn } = createNodeHarness({ + env: {}, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ workspaceKey: 'rk_project_session' })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(warn.mock.calls.flat().join('\n')).toContain('stored Cloud fleet enrollments'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('does not warn about shadowed enrollments when the pin names an enrolled node', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const listFleetEnrollments = vi.fn(() => [ + enrollmentRecord, + ]) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, warn } = createNodeHarness({ + env: {}, + resolveEnrollment, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + enrolledNodeId: 'node_abc', + })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(warn).not.toHaveBeenCalled(); + }); + 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..febc49231 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -1,5 +1,9 @@ import type { Command } from 'commander'; -import { resolveActiveFleetNodeEnrollment } from '@agent-relay/cloud'; +import { + readFleetNodeEnrollmentStore, + resolveActiveFleetNodeEnrollment, + type FleetNodeEnrollmentRecord, +} from '@agent-relay/cloud'; import { addUpCommandOptions, @@ -19,6 +23,8 @@ type ExitFn = (code: number) => never; export interface NodeCommandDependencies { core: CoreDependencies; resolveEnrollment: typeof resolveActiveFleetNodeEnrollment; + /** Every stored fleet enrollment, used only to report how many a project pin is shadowing. */ + listFleetEnrollments: (env: NodeJS.ProcessEnv) => FleetNodeEnrollmentRecord[]; resolveProjectWorkspaceSession: () => ProjectWorkspaceSession | undefined; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; @@ -26,11 +32,18 @@ export interface NodeCommandDependencies { exit: ExitFn; } +/** + * Fill in the real Cloud/enrollment/project lookups for anything the caller did + * not inject. + * @param overrides - Dependencies to substitute, as tests do. + * @returns A fully populated dependency set. + */ function withNodeDefaults(overrides: Partial = {}): NodeCommandDependencies { const core = overrides.core ?? withDefaults(); return { core, resolveEnrollment: resolveActiveFleetNodeEnrollment, + listFleetEnrollments: (env: NodeJS.ProcessEnv) => Object.values(readFleetNodeEnrollmentStore(env).nodes), resolveProjectWorkspaceSession: () => readProjectWorkspaceSession(core.getProjectPaths().dataDir), log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), @@ -108,6 +121,36 @@ function applyEnrollment( return record.nodeName?.trim() || undefined; } +/** + * Report the enrollments a project pin without an `enrolledNodeId` shadows. + * + * The pin wins over the enrollment store, so the broker starts in the pinned + * workspace and this machine never serves its Cloud fleet node. That used to be + * completely silent: the Cloud dashboard showed the node, `fleet nodes` showed a + * different roster, and nothing said the two were different workspaces. + */ +function warnPinShadowsFleetEnrollments(deps: NodeCommandDependencies): void { + let count: number | undefined; + try { + count = deps.listFleetEnrollments(deps.core.env).length; + } catch { + // A corrupt or unreadable store must not break `node up` here — nothing on + // this path needs it. Warn without a count rather than hide the mismatch. + count = undefined; + } + if (count === 0) { + return; + } + const subject = + count === undefined ? 'stored Cloud fleet enrollments' : `${count} stored Cloud fleet enrollment(s)`; + deps.warn( + `This project is pinned to a workspace with no enrolled node id, so ${subject} will be ignored: ` + + 'the broker starts in the pinned workspace and this machine will not serve its Cloud fleet node. ' + + "Re-run 'relay cloud enroll' from this project to link the pin to a node, or export " + + "RELAY_NODE_ID and RELAY_NODE_TOKEN and start with 'relay node up --broker-name '." + ); +} + /** Resolve the enrollment associated with a project session, avoiding ambiguous global fallback. */ function resolveEnrollmentForProject( session: ProjectWorkspaceSession | undefined, @@ -122,6 +165,7 @@ function resolveEnrollmentForProject( }); } if (session) { + warnPinShadowsFleetEnrollments(deps); return undefined; } return deps.resolveEnrollment({ diff --git a/packages/cli/src/cli/commands/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index 252904a8a..e57c5e302 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -8,8 +8,13 @@ vi.mock('@agent-relay/cloud', () => ({ switchWorkspace: vi.fn(), })); -vi.mock('../lib/workspace-session.js', () => ({ - persistWorkspaceSession: vi.fn(), +vi.mock('../lib/workspace-session.js', async (importOriginal) => ({ + // Returns a result object describing what the write changed beyond the key. + persistWorkspaceSession: vi.fn(() => ({})), + // The real formatter, not a copy: these tests assert on its wording, so a + // stand-in here would let the command output drift past them. + describeClearedEnrollment: (await importOriginal()) + .describeClearedEnrollment, validateWorkspaceSessionName: vi.fn((name: string) => { const trimmed = name.trim(); if (!trimmed) throw new Error('Workspace name is required.'); @@ -186,6 +191,58 @@ describe('registerWorkspaceCommands', () => { expect(switchWorkspace).not.toHaveBeenCalled(); }); + it('workspace switch reports an enrolled fleet node dropped by the move', async () => { + vi.mocked(readWorkspaceStore).mockReturnValue({ + active: 'default', + workspaces: { other: { key: 'rk_live_other' } }, + }); + vi.mocked(persistWorkspaceSession).mockReturnValueOnce({ clearedEnrolledNodeId: 'node_abc' }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'switch', 'other']); + + const output = vi.mocked(deps.log).mock.calls.flat().join('\n'); + expect(output).toContain('node_abc'); + expect(output).toContain('relay cloud enroll'); + }); + + it('workspace create reports a dropped enrolled node inside its JSON output', async () => { + vi.mocked(persistWorkspaceSession).mockReturnValueOnce({ clearedEnrolledNodeId: 'node_abc' }); + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ workspaceKey: 'rk_live_fresh' } as never); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'fresh']); + + // A new workspace key never matches the existing pin, so create is a + // clearing path too — and its output must stay parseable JSON. + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed.clearedEnrolledNodeId).toBe('node_abc'); + expect(printed.warning).toContain('relay cloud enroll'); + }); + + it('workspace create emits no warning key when nothing was dropped', async () => { + const { program, deps } = createHarness(); + vi.mocked(deps.createWorkspace).mockResolvedValueOnce({ workspaceKey: 'rk_live_fresh' } as never); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'create', 'fresh']); + + const printed = JSON.parse(String(vi.mocked(deps.log).mock.calls[0][0])); + expect(printed).not.toHaveProperty('clearedEnrolledNodeId'); + expect(printed).not.toHaveProperty('warning'); + }); + + it('workspace switch stays quiet when no enrolled node was dropped', async () => { + vi.mocked(readWorkspaceStore).mockReturnValue({ + active: 'default', + workspaces: { other: { key: 'rk_live_other' } }, + }); + const { program, deps } = createHarness(); + + await program.parseAsync(['node', 'agent-relay', 'workspace', 'switch', 'other']); + + expect(vi.mocked(deps.log).mock.calls.flat().join('\n')).not.toContain('Cleared'); + }); + it('workspace key prints the stored key masked by default and raw with --reveal-secrets', async () => { vi.mocked(readWorkspaceStore).mockReturnValue({ active: 'default', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index 2e4a0f7c2..e727c00f2 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -5,10 +5,29 @@ 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 { + describeClearedEnrollment, + persistWorkspaceSession, + validateWorkspaceSessionName, + type PersistWorkspaceSessionResult, +} from '../lib/workspace-session.js'; export type WorkspaceCommandDependencies = SdkCommandDeps; +/** + * Say so when moving workspaces drops this project's enrolled fleet node. + * Leaving it silent is what produced the split rosters in #1432. + */ +function reportClearedEnrollment( + result: PersistWorkspaceSessionResult, + deps: WorkspaceCommandDependencies +): void { + const message = describeClearedEnrollment(result); + if (message) { + deps.log(message); + } +} + function parsePositiveInteger(value: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { @@ -83,15 +102,23 @@ export function registerWorkspaceCommands( await runSdk(deps, async () => { const workspaceName = validateWorkspaceSessionName(name); const relay = await deps.createWorkspace(workspaceName, o.baseUrl as string | undefined); - if (relay.workspaceKey) { - persistWorkspaceSession({ name: workspaceName, workspaceKey: relay.workspaceKey }); - } + const persisted = relay.workspaceKey + ? persistWorkspaceSession({ name: workspaceName, workspaceKey: relay.workspaceKey }) + : {}; // The key is persisted to the workspace store either way; the output - // masks it unless the caller explicitly asks for the raw value. + // masks it unless the caller explicitly asks for the raw value. A + // dropped enrollment rides in the JSON rather than a log line so the + // output stays parseable. printJson(deps, { name: workspaceName, workspaceKey: relay.workspaceKey && !o.revealSecrets ? maskSecret(relay.workspaceKey) : relay.workspaceKey, + ...(persisted.clearedEnrolledNodeId + ? { + clearedEnrolledNodeId: persisted.clearedEnrolledNodeId, + warning: describeClearedEnrollment(persisted), + } + : {}), }); }); }); @@ -148,8 +175,9 @@ export function registerWorkspaceCommands( .argument('', 'Workspace key') .action(async (name: string, key: string) => { await runSdk(deps, async () => { - persistWorkspaceSession({ name, workspaceKey: key }); + const result = persistWorkspaceSession({ name, workspaceKey: key }); deps.log(`Joined and switched to workspace "${name}".`); + reportClearedEnrollment(result, deps); }); }); @@ -166,8 +194,9 @@ export function registerWorkspaceCommands( `Unknown workspace "${name}". Add it with \`relay workspace set_key ${name} \`.` ); } - persistWorkspaceSession({ name, workspaceKey: workspace.key }); + const result = persistWorkspaceSession({ name, workspaceKey: workspace.key }); deps.log(`Switched to workspace "${name}".`); + reportClearedEnrollment(result, deps); }); }); } diff --git a/packages/cli/src/cli/lib/enrollment-pin.test.ts b/packages/cli/src/cli/lib/enrollment-pin.test.ts new file mode 100644 index 000000000..2be08d64a --- /dev/null +++ b/packages/cli/src/cli/lib/enrollment-pin.test.ts @@ -0,0 +1,79 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { linkEnrolledNodeToProjectPin } from './enrollment-pin.js'; +import { readProjectWorkspaceSession, writeProjectWorkspaceKey } from './project-workspace-key.js'; + +const tempRoots: string[] = []; + +function projectDataDir(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-enrollment-pin-')); + tempRoots.push(root); + return path.join(root, 'project', '.agentworkforce', 'relay'); +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe('linkEnrolledNodeToProjectPin', () => { + it('records the enrolled node on a pin that has none', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned'); + + const result = linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir }); + + expect(result).toMatchObject({ status: 'linked', nodeId: 'node_abc' }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_live_pinned', + enrolledNodeId: 'node_abc', + }); + }); + + it('leaves an unpinned project alone', () => { + const dataDir = projectDataDir(); + + expect(linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir })).toEqual({ + status: 'no-pin', + }); + expect(readProjectWorkspaceSession(dataDir)).toBeUndefined(); + }); + + it('reports an unchanged pin that already names the node', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned', { enrolledNodeId: 'node_abc' }); + + expect(linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir })).toMatchObject({ + status: 'unchanged', + nodeId: 'node_abc', + }); + }); + + it('never repoints a pin that names a different node', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned', { enrolledNodeId: 'node_existing' }); + + const result = linkEnrolledNodeToProjectPin({ nodeId: 'node_new', projectDataDir: dataDir }); + + expect(result).toMatchObject({ + status: 'conflict', + nodeId: 'node_new', + pinnedNodeId: 'node_existing', + }); + expect(readProjectWorkspaceSession(dataDir)?.enrolledNodeId).toBe('node_existing'); + }); + + it('preserves the pinned workspace key when linking', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned'); + + linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir }); + + expect(readProjectWorkspaceSession(dataDir)?.workspaceKey).toBe('rk_live_pinned'); + }); +}); diff --git a/packages/cli/src/cli/lib/enrollment-pin.ts b/packages/cli/src/cli/lib/enrollment-pin.ts new file mode 100644 index 000000000..364129895 --- /dev/null +++ b/packages/cli/src/cli/lib/enrollment-pin.ts @@ -0,0 +1,68 @@ +import { getProjectPaths } from '@agent-relay/config'; + +import { + projectWorkspaceKeyPath, + readProjectWorkspaceSession, + writeProjectWorkspaceKey, +} from './project-workspace-key.js'; + +/** Outcome of reconciling a fresh enrollment against the project workspace pin. */ +export type EnrolledNodePinResult = + /** No project pin (or no node id to record) — `node up` resolves the enrollment globally. */ + | { status: 'no-pin' } + /** The pin already names this node. */ + | { status: 'unchanged'; nodeId: string; pinPath: string } + /** The pin now names this node, so `node up` serves it from this project. */ + | { status: 'linked'; nodeId: string; pinPath: string } + /** The pin names a different node; it is left untouched for the operator to resolve. */ + | { status: 'conflict'; nodeId: string; pinnedNodeId: string; pinPath: string }; + +export interface LinkEnrolledNodeToProjectPinOptions { + /** Node id from the enrollment record just persisted. */ + nodeId: string; + /** Project root whose pin should be reconciled. Defaults to the current project. */ + projectRoot?: string; + /** Explicit project Relay data directory. Takes precedence over `projectRoot`. */ + projectDataDir?: string; +} + +/** + * Record a freshly enrolled node on the project workspace pin. + * + * `relay cloud enroll` writes only `fleet-enrollments.json`, so a repo pinned to + * a workspace stays pinned with no `enrolledNodeId` — and `relay node up` then + * ignores the enrollment entirely. Linking the two here closes that gap. + * + * An existing, different `enrolledNodeId` is never overwritten: the enrollment + * token is already redeemed by the time this runs, so silently repointing a pin + * would trade one invisible mismatch for another. Report it and let the operator + * choose. + * + * @param options - The enrolled node id and the project to reconcile. + * @returns What happened to the pin. + */ +export function linkEnrolledNodeToProjectPin( + options: LinkEnrolledNodeToProjectPinOptions +): EnrolledNodePinResult { + const nodeId = options.nodeId.trim(); + if (!nodeId) { + return { status: 'no-pin' }; + } + + const dataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; + const session = readProjectWorkspaceSession(dataDir); + if (!session) { + return { status: 'no-pin' }; + } + + const pinPath = projectWorkspaceKeyPath(dataDir); + if (session.enrolledNodeId === nodeId) { + return { status: 'unchanged', nodeId, pinPath }; + } + if (session.enrolledNodeId) { + return { status: 'conflict', nodeId, pinnedNodeId: session.enrolledNodeId, pinPath }; + } + + writeProjectWorkspaceKey(dataDir, session.workspaceKey, { enrolledNodeId: nodeId }); + return { status: 'linked', nodeId, pinPath }; +} diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 8183077c9..26e2045c2 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -6,7 +6,11 @@ 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 { + readProjectWorkspaceKey, + readProjectWorkspaceSession, + writeProjectWorkspaceKey, +} from './project-workspace-key.js'; import { readWorkspaceStore, setWorkspaceKey } from './workspace-store.js'; const tempRoots: string[] = []; @@ -109,6 +113,58 @@ describe('workspace session persistence', () => { expect(readWorkspaceStore(env).workspaces).toEqual({}); }); + it('preserves the enrolled Fleet node id when re-selecting the same workspace', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + writeProjectWorkspaceKey(projectDataDir, 'rk_live_enrolled', { enrolledNodeId: 'node_abc' }); + + const result = persistWorkspaceSession({ + workspaceKey: 'rk_live_enrolled', + name: 'enrolled', + projectDataDir, + env, + }); + + // Dropping the node id here is what manufactures the pin that makes + // `node up` silently ignore the fleet enrollment store. + expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ + workspaceKey: 'rk_live_enrolled', + enrolledNodeId: 'node_abc', + }); + expect(result.clearedEnrolledNodeId).toBeUndefined(); + }); + + it('clears the enrolled Fleet node id when moving to a different workspace', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + writeProjectWorkspaceKey(projectDataDir, 'rk_live_enrolled', { enrolledNodeId: 'node_abc' }); + + const result = persistWorkspaceSession({ + workspaceKey: 'rk_live_other', + name: 'other', + projectDataDir, + env, + }); + + // Carrying the id across would run the broker in the old workspace while + // every other command in this project reads the new key — the split this + // change exists to remove. + expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ workspaceKey: 'rk_live_other' }); + expect(result.clearedEnrolledNodeId).toBe('node_abc'); + }); + + it('does not invent an enrolled node id for a project that never had one', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + + persistWorkspaceSession({ workspaceKey: 'rk_live_fresh', projectDataDir, env }); + + expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ workspaceKey: 'rk_live_fresh' }); + }); + it('resumes the project workspace ahead of the machine-global active workspace', () => { const root = tempRoot(); const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts index f22289d3b..80b9ffa5f 100644 --- a/packages/cli/src/cli/lib/workspace-session.ts +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -1,7 +1,7 @@ import { getProjectPaths } from '@agent-relay/config'; import { resolveWorkspaceKeyWithSource } from '@agent-relay/cloud/workspace-key'; -import { writeProjectWorkspaceKey } from './project-workspace-key.js'; +import { readProjectWorkspaceSession, writeProjectWorkspaceKey } from './project-workspace-key.js'; import { setWorkspaceKey, switchWorkspace, validateWorkspaceName } from './workspace-store.js'; export interface WorkspaceSessionOptions { @@ -29,12 +29,44 @@ export function resolveWorkspaceSessionKey(options: WorkspaceSessionOptions = {} return resolveWorkspaceKeyWithSource(options)?.key; } +export interface PersistWorkspaceSessionResult { + /** + * Enrolled node association dropped because the project moved to a different + * workspace. Present only when there was one to drop. + */ + clearedEnrolledNodeId?: string; +} + +/** + * Operator-facing explanation of a dropped enrollment association. + * + * Shared by every caller that changes the pin — CLI and MCP alike — so none of + * them can report success while the clearing goes unmentioned. + * + * @param result - What {@link persistWorkspaceSession} reported. + * @returns The message, or `undefined` when nothing was cleared. + */ +export function describeClearedEnrollment(result: PersistWorkspaceSessionResult): string | undefined { + if (!result.clearedEnrolledNodeId) { + return undefined; + } + return ( + `Cleared this project's enrolled fleet node (${result.clearedEnrolledNodeId}): it belongs to the ` + + "workspace you moved away from. Run 'relay cloud enroll' from this project to serve a node in the " + + 'new workspace.' + ); +} + /** * Pin a workspace to the current project so later CLI and MCP processes resume * the same collaboration session. A named selection also becomes the * machine-global active workspace; a bare shared key only changes this project. + * + * @returns What the write changed beyond the key itself. */ -export function persistWorkspaceSession(options: PersistWorkspaceSessionOptions): void { +export function persistWorkspaceSession( + options: PersistWorkspaceSessionOptions +): PersistWorkspaceSessionResult { const workspaceKey = options.workspaceKey.trim(); if (!workspaceKey) { throw new Error('Workspace key is required.'); @@ -43,10 +75,29 @@ 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); + const existing = readProjectWorkspaceSession(projectDataDir); + // Re-selecting the same workspace must keep the enrolled node: dropping it + // there manufactured the pin `node up` now warns about, where the next start + // ignores the enrollment store entirely. + // + // Moving to a *different* workspace must not. The enrollment resolves by node + // id alone, and `node up` applies its credentials without applying the pinned + // key — so carrying the id across would run the broker in the old workspace + // while every other command in this project reads the new one. That is the + // split this whole change exists to remove, and the workspace ids the + // enrollment store holds cannot be checked against the key the pin holds. + const keepsWorkspace = existing?.workspaceKey === workspaceKey; + const enrolledNodeId = keepsWorkspace ? existing?.enrolledNodeId : undefined; + writeProjectWorkspaceKey(projectDataDir, workspaceKey, { + ...(enrolledNodeId ? { enrolledNodeId } : {}), + }); if (name) { setWorkspaceKey(name, workspaceKey, options.env); switchWorkspace(name, options.env); } + + return existing?.enrolledNodeId && !enrolledNodeId + ? { clearedEnrolledNodeId: existing.enrolledNodeId } + : {}; }