From fe20d6ea9b44b02338fe73e1808ec15c2235d05c Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 09:59:15 +0200 Subject: [PATCH 1/5] fix(cli): stop silently ignoring fleet enrollments behind a project pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project workspace pin without an `enrolledNodeId` made `relay node up` skip the fleet enrollment store entirely — no warning, no credentials, no heartbeat. The Cloud dashboard showed the node, `fleet nodes` showed a different roster, and nothing said the two were different workspaces (#1432). One machine was enrolled but invisible for five days. Three fixes, smallest first: 1. `node.ts` warns before returning when a pin shadows stored enrollments, naming how many are being ignored and how to recover. The sibling branch already warned; this one did not. 2. `cloud enroll` records the enrolled node on the project pin so `node up` in that repo serves it. A pin naming a different node is reported and left untouched — the one-time token is already redeemed by then, so repointing it would trade one invisible mismatch for another. Pin failures never fail a completed enrollment. 3. `workspace switch|join` preserves the pin's `enrolledNodeId` instead of dropping it, which is what manufactured the broken state in fix 1. Refs #1432 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 3 + packages/cli/src/cli/commands/cloud.test.ts | 115 ++++++++++++++++++ packages/cli/src/cli/commands/cloud.ts | 46 +++++++ packages/cli/src/cli/commands/node.test.ts | 84 +++++++++++++ packages/cli/src/cli/commands/node.ts | 40 +++++- .../cli/src/cli/lib/enrollment-pin.test.ts | 79 ++++++++++++ packages/cli/src/cli/lib/enrollment-pin.ts | 68 +++++++++++ .../cli/src/cli/lib/workspace-session.test.ts | 37 +++++- packages/cli/src/cli/lib/workspace-session.ts | 11 +- 9 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/cli/lib/enrollment-pin.test.ts create mode 100644 packages/cli/src/cli/lib/enrollment-pin.ts 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/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index c705b61be..6ffcb53cd 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,116 @@ 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'); + }); + + 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..0f4ea1324 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..1e44d0c72 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; @@ -31,6 +37,7 @@ function withNodeDefaults(overrides: Partial = {}): Nod 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 +115,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 +159,7 @@ function resolveEnrollmentForProject( }); } if (session) { + warnPinShadowsFleetEnrollments(deps); return undefined; } return deps.resolveEnrollment({ 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..568738bc0 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,37 @@ describe('workspace session persistence', () => { expect(readWorkspaceStore(env).workspaces).toEqual({}); }); + it('preserves the enrolled Fleet node id when switching the pinned workspace', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const env = isolatedEnv(root); + writeProjectWorkspaceKey(projectDataDir, 'rk_live_enrolled', { enrolledNodeId: 'node_abc' }); + + persistWorkspaceSession({ + workspaceKey: 'rk_live_other', + name: 'other', + 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_other', + enrolledNodeId: '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..be5bc3bc9 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 { @@ -43,7 +43,14 @@ 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); + // The enrolled Fleet node is a property of this machine+project, not of the + // workspace being selected. Dropping it here silently manufactured the broken + // state `node up` warns about: a pin with no node id, which makes the next + // start ignore the enrollment store entirely. + const enrolledNodeId = readProjectWorkspaceSession(projectDataDir)?.enrolledNodeId; + writeProjectWorkspaceKey(projectDataDir, workspaceKey, { + ...(enrolledNodeId ? { enrolledNodeId } : {}), + }); if (name) { setWorkspaceKey(name, workspaceKey, options.env); From d40a3f013598afb3d60af1aa3c2cf92f11cc15ce Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 10:19:02 +0200 Subject: [PATCH 2/5] fix(cli): do not carry an enrolled node across a workspace change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex and cubic both landed the same finding on the first cut, and they were right: preserving `enrolledNodeId` unconditionally in `persistWorkspaceSession` recreated the split it was meant to remove. `node up` resolves an enrollment by node id alone and applies its credentials *without* applying the pinned key, so a project that switched from workspace A to B would run the broker as A's node while every other command read B. The enrollment store holds workspace ids and the pin holds a key, so the two cannot be reconciled locally. What can be compared is key against key: - Re-selecting the same workspace keeps the enrolled node (the original fix — dropping it manufactured the pin `node up` warns about). - Moving to a different workspace clears it, and `workspace switch|join` now says so instead of dropping it silently. - `cloud enroll` still links the pin, but no longer implies the link was verified: it names the workspace that will actually be served and states that the pinned key was not checked against it. Declined cubic's P2 on read/check/write atomicity in `linkEnrolledNodeToProjectPin`: `writeProjectWorkspaceKey` is already write-then-rename, two concurrent `cloud enroll` runs in one project would each need their own one-time token, and the race can only lose a conflict *report*, never corrupt the pin. A lockfile is disproportionate here. Refs #1432 Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli/commands/cloud.test.ts | 5 +++ packages/cli/src/cli/commands/cloud.ts | 6 ++-- .../cli/src/cli/commands/workspace.test.ts | 30 +++++++++++++++- packages/cli/src/cli/commands/workspace.ts | 30 ++++++++++++++-- .../cli/src/cli/lib/workspace-session.test.ts | 31 +++++++++++++--- packages/cli/src/cli/lib/workspace-session.ts | 36 +++++++++++++++---- 6 files changed, 121 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index 6ffcb53cd..acb639da0 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -1799,6 +1799,11 @@ describe('registerCloudCommands', () => { 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 () => { diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index 0f4ea1324..b4bbef5fd 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -1023,8 +1023,10 @@ export function registerCloudCommands(program: Command, overrides: Partial ({ })); vi.mock('../lib/workspace-session.js', () => ({ - persistWorkspaceSession: vi.fn(), + // Returns a result object describing what the write changed beyond the key. + persistWorkspaceSession: vi.fn(() => ({})), validateWorkspaceSessionName: vi.fn((name: string) => { const trimmed = name.trim(); if (!trimmed) throw new Error('Workspace name is required.'); @@ -186,6 +187,33 @@ 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 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..ebd5a0075 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -5,10 +5,32 @@ 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, + 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 { + if (!result.clearedEnrolledNodeId) { + return; + } + deps.log( + `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.' + ); +} + function parsePositiveInteger(value: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { @@ -148,8 +170,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 +189,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/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 568738bc0..26e2045c2 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -113,15 +113,15 @@ describe('workspace session persistence', () => { expect(readWorkspaceStore(env).workspaces).toEqual({}); }); - it('preserves the enrolled Fleet node id when switching the pinned workspace', () => { + 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' }); - persistWorkspaceSession({ - workspaceKey: 'rk_live_other', - name: 'other', + const result = persistWorkspaceSession({ + workspaceKey: 'rk_live_enrolled', + name: 'enrolled', projectDataDir, env, }); @@ -129,9 +129,30 @@ describe('workspace session persistence', () => { // 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_other', + 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', () => { diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts index be5bc3bc9..9d6e388df 100644 --- a/packages/cli/src/cli/lib/workspace-session.ts +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -29,12 +29,24 @@ 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; +} + /** * 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,11 +55,19 @@ export function persistWorkspaceSession(options: PersistWorkspaceSessionOptions) const name = options.name === undefined ? undefined : validateWorkspaceSessionName(options.name); const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; - // The enrolled Fleet node is a property of this machine+project, not of the - // workspace being selected. Dropping it here silently manufactured the broken - // state `node up` warns about: a pin with no node id, which makes the next - // start ignore the enrollment store entirely. - const enrolledNodeId = readProjectWorkspaceSession(projectDataDir)?.enrolledNodeId; + 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 } : {}), }); @@ -56,4 +76,8 @@ export function persistWorkspaceSession(options: PersistWorkspaceSessionOptions) setWorkspaceKey(name, workspaceKey, options.env); switchWorkspace(name, options.env); } + + return existing?.enrolledNodeId && !enrolledNodeId + ? { clearedEnrolledNodeId: existing.enrolledNodeId } + : {}; } From ccff79685245585f86ec479d5801484f15d2a97e Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 10:21:31 +0200 Subject: [PATCH 3/5] docs(cli): document withNodeDefaults Closes CodeRabbit's docstring-coverage pre-merge warning on the one undocumented function in a file this branch already touches. Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli/commands/node.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index 1e44d0c72..febc49231 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -32,6 +32,12 @@ 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 { From 9ab642809fabd8dc206b6d799c292359f36f8e5a Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 10:40:36 +0200 Subject: [PATCH 4/5] fix(cli): report a cleared enrollment from every pin-changing caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit and cubic caught the same gap in the previous commit: only `workspace switch|join` consumed the new `clearedEnrolledNodeId`. The other three writers of the pin discarded it and reported success while the project's fleet enrollment was dropped, leaving the next `node up` warning as the first mention — the same silence this branch exists to remove. - `workspace create` carries it in its JSON output (`clearedEnrolledNodeId` plus a `warning`), so the report cannot break a parsing caller. - MCP `create_workspace` and `set_workspace_key` return it through the `warning` field they already had for persistence failures. - `describeClearedEnrollment` is now the single wording shared by all of them, and both test suites bind the real implementation via `importOriginal` rather than a stand-in, so output cannot drift past the assertions. `workspace create` is the sharpest case: a freshly minted key never matches an existing pin, so it always clears. Refs #1432 Co-Authored-By: Claude Opus 5 --- .../src/cli/agent-relay-mcp.startup.test.ts | 25 +++++++++++++-- packages/cli/src/cli/agent-relay-mcp.ts | 12 +++++-- .../cli/src/cli/commands/workspace.test.ts | 31 ++++++++++++++++++- packages/cli/src/cli/commands/workspace.ts | 27 +++++++++------- packages/cli/src/cli/lib/workspace-session.ts | 20 ++++++++++++ 5 files changed, 99 insertions(+), 16 deletions(-) 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..8e1385269 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, @@ -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 = @@ -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/workspace.test.ts b/packages/cli/src/cli/commands/workspace.test.ts index ea6f4406e..e57c5e302 100644 --- a/packages/cli/src/cli/commands/workspace.test.ts +++ b/packages/cli/src/cli/commands/workspace.test.ts @@ -8,9 +8,13 @@ vi.mock('@agent-relay/cloud', () => ({ switchWorkspace: vi.fn(), })); -vi.mock('../lib/workspace-session.js', () => ({ +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.'); @@ -202,6 +206,31 @@ describe('registerWorkspaceCommands', () => { 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', diff --git a/packages/cli/src/cli/commands/workspace.ts b/packages/cli/src/cli/commands/workspace.ts index ebd5a0075..e727c00f2 100644 --- a/packages/cli/src/cli/commands/workspace.ts +++ b/packages/cli/src/cli/commands/workspace.ts @@ -6,6 +6,7 @@ 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 { + describeClearedEnrollment, persistWorkspaceSession, validateWorkspaceSessionName, type PersistWorkspaceSessionResult, @@ -21,14 +22,10 @@ function reportClearedEnrollment( result: PersistWorkspaceSessionResult, deps: WorkspaceCommandDependencies ): void { - if (!result.clearedEnrolledNodeId) { - return; + const message = describeClearedEnrollment(result); + if (message) { + deps.log(message); } - deps.log( - `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.' - ); } function parsePositiveInteger(value: string): number { @@ -105,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), + } + : {}), }); }); }); diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts index 9d6e388df..80b9ffa5f 100644 --- a/packages/cli/src/cli/lib/workspace-session.ts +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -37,6 +37,26 @@ export interface PersistWorkspaceSessionResult { 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 From f8c644838e3b383b84707eff9410c358f104fc40 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Thu, 6 Aug 2026 10:53:55 +0200 Subject: [PATCH 5/5] docs(mcp): describe both cases that produce a workspace-tool warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made `create_workspace` and `set_workspace_key` return a cleared-enrollment message through their existing `warning` field, but left the tool descriptions saying `warning` appears only when persistence failed. An MCP consumer reading that would take a successful create that dropped an enrolled node for a failed save — and a fresh key never matches an existing pin, so that case fires on every create over one. Both descriptions now name both cases and say the text distinguishes them. Refs #1432 Co-Authored-By: Claude Opus 5 --- packages/cli/src/cli/agent-relay-mcp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/agent-relay-mcp.ts b/packages/cli/src/cli/agent-relay-mcp.ts index 8e1385269..d24af51b3 100644 --- a/packages/cli/src/cli/agent-relay-mcp.ts +++ b/packages/cli/src/cli/agent-relay-mcp.ts @@ -410,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'), }, @@ -465,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'),