diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8.trace.json b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8.trace.json new file mode 100644 index 000000000..af5ee3c1b --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8.trace.json @@ -0,0 +1,248 @@ +{ + "version": "1.0.0", + "id": "71e42df2-d556-49b6-afe5-4789300d40c8", + "timestamp": "2026-08-26T00:34:27.910Z", + "trajectory": "traj_swo9tkg9voc8", + "files": [ + { + "path": "CHANGELOG.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 5, + "end_line": 15, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/README.md", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 89, + "end_line": 102, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/cloud.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1789, + "end_line": 1865, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 1926, + "end_line": 1932, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/cloud.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 460, + "end_line": 488, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 1019, + "end_line": 1025, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 1037, + "end_line": 1043, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/node.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 197, + "end_line": 320, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 338, + "end_line": 355, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 359, + "end_line": 366, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/commands/node.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 90, + "end_line": 102, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 117, + "end_line": 123, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 137, + "end_line": 152, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 171, + "end_line": 180, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 198, + "end_line": 227, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 235, + "end_line": 247, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 255, + "end_line": 261, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 288, + "end_line": 341, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/enrollment-pin.test.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 28, + "end_line": 87, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 98, + "end_line": 104, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "packages/cli/src/cli/lib/enrollment-pin.ts", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 11, + "end_line": 35, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + }, + { + "start_line": 66, + "end_line": 92, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "tests/relayflows/cases/1613-matching-fleet-enrollment-identity/case.json", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 20, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + }, + { + "path": "tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs", + "conversations": [ + { + "contributor": { + "type": "ai" + }, + "ranges": [ + { + "start_line": 1, + "end_line": 170, + "revision": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/summary.md b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/summary.md new file mode 100644 index 000000000..b25f77765 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/summary.md @@ -0,0 +1,39 @@ +# Trajectory: Finish PR 1613 enrollment workspace identity proof + +> **Status:** ✅ Completed +> **Task:** PR-1613 +> **Confidence:** 90% +> **Started:** August 26, 2026 at 02:33 AM +> **Completed:** August 26, 2026 at 02:34 AM + +--- + +## Summary + +Rebased PR 1613, addressed all three review findings, added deterministic exact-base/head RelayFlow coverage, and validated focused tests plus build and formatting. + +**Approach:** Preserved the public node-up workflow, hardened matching-workspace enrollment selection and legacy-pin verification, and added an external exact-checkout harness. + +--- + +## Key Decisions + +### Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case +- **Chose:** Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case +- **Reasoning:** This deterministically exercises the public node-up identity selection on both exact checkouts while a harness-owned broker seam prevents external daemon or Cloud side effects. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case: Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case + +--- + +## Artifacts + +**Commits:** 3bafddd68 +**Files changed:** 10 diff --git a/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/trajectory.json b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/trajectory.json new file mode 100644 index 000000000..bc30fb34d --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-08/traj_swo9tkg9voc8/trajectory.json @@ -0,0 +1,71 @@ +{ + "id": "traj_swo9tkg9voc8", + "version": 1, + "task": { + "title": "Finish PR 1613 enrollment workspace identity proof", + "source": { + "system": "plain", + "id": "PR-1613" + } + }, + "status": "completed", + "startedAt": "2026-08-26T00:33:55.506Z", + "completedAt": "2026-08-26T00:34:27.540Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-08-26T00:33:58.993Z" + } + ], + "chapters": [ + { + "id": "chap_rncyio9dzw7v", + "title": "Work", + "agentName": "default", + "startedAt": "2026-08-26T00:33:58.993Z", + "endedAt": "2026-08-26T00:34:27.540Z", + "events": [ + { + "ts": 1787704438998, + "type": "decision", + "content": "Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case: Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case", + "raw": { + "question": "Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case", + "chosen": "Use the built production node command with real temporary enrollment and project-pin stores for the RelayFlow case", + "alternatives": [], + "reasoning": "This deterministically exercises the public node-up identity selection on both exact checkouts while a harness-owned broker seam prevents external daemon or Cloud side effects." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Rebased PR 1613, addressed all three review findings, added deterministic exact-base/head RelayFlow coverage, and validated focused tests plus build and formatting.", + "approach": "Preserved the public node-up workflow, hardened matching-workspace enrollment selection and legacy-pin verification, and added an external exact-checkout harness.", + "confidence": 0.9 + }, + "commits": [ + "3bafddd68" + ], + "filesChanged": [ + "CHANGELOG.md", + "packages/cli/README.md", + "packages/cli/src/cli/commands/cloud.test.ts", + "packages/cli/src/cli/commands/cloud.ts", + "packages/cli/src/cli/commands/node.test.ts", + "packages/cli/src/cli/commands/node.ts", + "packages/cli/src/cli/lib/enrollment-pin.test.ts", + "packages/cli/src/cli/lib/enrollment-pin.ts", + "tests/relayflows/cases/1613-matching-fleet-enrollment-identity/case.json", + "tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs" + ], + "projectId": "AgentWorkforce/relay", + "tags": [], + "_trace": { + "startRef": "10fc5ce1c4b6f0ac557b8cc0a2d3febc1cc6b2e6", + "endRef": "3bafddd686b219a31e4a9d99a5c291b8b61ddd16", + "traceId": "71e42df2-d556-49b6-afe5-4789300d40c8" + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b7c92f3..de3687080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `agent-relay node up` retains a stored Cloud fleet-node identity when the repository pin and enrollment name the same Relay workspace, including with a matching explicit workspace flag or environment key. A genuinely different explicit key still wins with a warning, and enrollment-time pin linking now preserves and checks the recorded workspace ID. ## [11.8.4] - 2026-08-25 diff --git a/packages/cli/README.md b/packages/cli/README.md index e69c1ec18..bc74df1ed 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -89,9 +89,14 @@ Workspace source: repository pin (.agentworkforce/relay/workspace-key.json) A Cloud enrollment (`RELAY_NODE_TOKEN`, or a record in the Fleet enrollment store) selects the node's _identity_, not its workspace, so it never appears on -the ladder. If a stored enrollment addresses a different workspace than the -repository pin, `node up` refuses to start and names both source files and -workspace IDs, never their keys. +the ladder. When the repository pin's recorded workspace ID matches the stored +enrollment, `node up` retains that enrolled node identity even if the pin has no +`enrolledNodeId`; the pin still supplies the workspace key. The same applies to +an explicit flag or environment key that is byte-identical to that pin. A +genuinely different explicit key still wins and warns that the enrollment is +being skipped. Without an explicit key, a stored enrollment that addresses a +different workspace than the repository pin stops startup and names both source +files and workspace IDs, never their keys. `workspace create`, `join`, and `switch` select a named workspace globally and pin it to the current project. A changed selection records the old name, so an diff --git a/packages/cli/src/cli/commands/cloud.test.ts b/packages/cli/src/cli/commands/cloud.test.ts index acb639da0..db7d02c2a 100644 --- a/packages/cli/src/cli/commands/cloud.test.ts +++ b/packages/cli/src/cli/commands/cloud.test.ts @@ -1789,21 +1789,77 @@ describe('registerCloudCommands', () => { status: 'linked', nodeId: 'node_abc', pinPath: '/repo/.agentworkforce/relay/workspace-key.json', + workspaceVerified: true, })) 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' }); + expect(linkEnrolledNodeToProjectPin).toHaveBeenCalledWith({ + nodeId: 'node_abc', + relayWorkspaceId: 'rw_123', + }); 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'); + expect(output).not.toContain('was not verified'); + }); + + it('cloud enroll warns when a linked legacy pin has no verified workspace id', 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', + workspaceVerified: false, + })) 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('workspace was not verified'); + expect(warned).toContain('node_abc'); + expect(warned).toContain('/repo/.agentworkforce/relay/workspace-key.json'); + }); + + it('cloud enroll warns and leaves a pin for a different workspace unchanged', async () => { + cloudMocks.enrollFleetNode.mockResolvedValueOnce({ + nodeId: 'node_new', + nodeName: 'kjglaptop', + nodeToken: 'nt_secret', + relayWorkspaceId: 'rw_enrolled', + 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: 'workspace-conflict', + nodeId: 'node_new', + relayWorkspaceId: 'rw_enrolled', + pinnedWorkspaceId: 'rw_pinned', + 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('rw_pinned'); + expect(warned).toContain('rw_enrolled'); + expect(warned).toContain('left unchanged'); }); it('cloud enroll warns instead of repointing a pin that names another node', async () => { @@ -1870,6 +1926,7 @@ describe('registerCloudCommands', () => { status: 'linked', nodeId: 'node_abc', pinPath: '/repo/.agentworkforce/relay/workspace-key.json', + workspaceVerified: true, })) as unknown as CloudDependencies['linkEnrolledNodeToProjectPin']; const log = vi.fn(); const { program } = createHarness({ log, linkEnrolledNodeToProjectPin }); diff --git a/packages/cli/src/cli/commands/cloud.ts b/packages/cli/src/cli/commands/cloud.ts index b4bbef5fd..fb567ee77 100644 --- a/packages/cli/src/cli/commands/cloud.ts +++ b/packages/cli/src/cli/commands/cloud.ts @@ -460,16 +460,29 @@ async function mintFleetNodeEnrollment( */ function reconcileEnrollmentPin( nodeId: string, + relayWorkspaceId: string, deps: Pick ): EnrolledNodePinResult | undefined { try { - const result = deps.linkEnrolledNodeToProjectPin({ nodeId }); + const result = deps.linkEnrolledNodeToProjectPin({ nodeId, relayWorkspaceId }); 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.` ); + } else if (result.status === 'workspace-conflict') { + deps.warn( + `This project's workspace pin (${result.pinPath}) addresses workspace ${result.pinnedWorkspaceId}, ` + + `but the enrolled node belongs to ${result.relayWorkspaceId}, so the pin was left unchanged and ` + + "'relay node up' here will not serve the new node. Select the enrolled workspace before starting it." + ); + } else if ((result.status === 'linked' || result.status === 'unchanged') && !result.workspaceVerified) { + deps.warn( + `Linked node ${result.nodeId} to this project's workspace pin (${result.pinPath}), but the pin has no ` + + 'recorded workspace ID, so the enrolled workspace was not verified. Re-select the workspace to ' + + "record its ID before relying on an unqualified 'relay node up'." + ); } return result; } catch (err) { @@ -1006,7 +1019,7 @@ export function registerCloudCommands(program: Command, overrides: Partial { ); }); - it('skips enrollment pickup when --workspace-key is passed', async () => { + it('keeps enrollment pickup when --workspace-key matches the pinned enrollment workspace', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord ) as unknown as NodeCommandDependencies['resolveEnrollment']; - const { program, env } = createNodeHarness({ env: {}, resolveEnrollment }); + const { program, env } = createNodeHarness({ + env: {}, + resolveEnrollment, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + workspaceId: 'rw_123', + enrolledNodeId: 'node_abc', + })), + }); + + await program.parseAsync(['node', 'up', '--workspace-key', 'rk_enrolled'], { from: 'user' }); + + expect(resolveEnrollment).toHaveBeenCalledWith(expect.objectContaining({ nodeId: 'node_abc' })); + expect(env.RELAY_NODE_TOKEN).toBe('nt_secret'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('keeps enrollment pickup when RELAY_WORKSPACE_KEY matches the pinned enrollment workspace', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const { program, env } = createNodeHarness({ + env: { RELAY_WORKSPACE_KEY: 'rk_enrolled' }, + resolveEnrollment, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + workspaceId: 'rw_123', + })), + }); + + await program.parseAsync(['node', 'up'], { from: 'user' }); + + expect(resolveEnrollment).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'rw_123' })); + expect(env.RELAY_NODE_TOKEN).toBe('nt_secret'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('lets a genuinely different explicit workspace win and warns that enrollment was skipped', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const listFleetEnrollments = vi.fn(() => [ + enrollmentRecord, + ]) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, env, warn } = createNodeHarness({ + env: {}, + resolveEnrollment, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + workspaceId: 'rw_123', + })), + }); await program.parseAsync(['node', 'up', '--workspace-key', 'rk_other'], { from: 'user' }); expect(resolveEnrollment).not.toHaveBeenCalled(); expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(env.AGENT_RELAY_ENROLLED_NODE_ID).toBeUndefined(); + expect(warn.mock.calls.flat().join('\n')).toContain('explicit workspace key differs'); expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); - it('skips enrollment pickup when RELAY_WORKSPACE_KEY is set in the env', async () => { + it('lets an explicit pinned workspace win over a stale enrolled-node association', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord ) as unknown as NodeCommandDependencies['resolveEnrollment']; - const { program, env } = createNodeHarness({ - env: { RELAY_WORKSPACE_KEY: 'rk_env' }, + const listFleetEnrollments = vi.fn(() => [ + enrollmentRecord, + ]) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, env, error, warn } = createNodeHarness({ + env: {}, resolveEnrollment, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_explicit', + workspaceId: 'rw_other', + enrolledNodeId: 'node_abc', + })), + }); + + await program.parseAsync(['node', 'up', '--workspace-key', 'rk_explicit'], { from: 'user' }); + + expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(env.AGENT_RELAY_ENROLLED_NODE_ID).toBeUndefined(); + expect(error).not.toHaveBeenCalled(); + expect(warn.mock.calls.flat().join('\n')).toContain('explicit workspace key differs'); + expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); + }); + + it('lets a different RELAY_WORKSPACE_KEY win without restoring a stale enrolled node id', async () => { + const resolveEnrollment = vi.fn( + () => enrollmentRecord + ) as unknown as NodeCommandDependencies['resolveEnrollment']; + const listFleetEnrollments = vi.fn(() => [ + enrollmentRecord, + ]) as unknown as NodeCommandDependencies['listFleetEnrollments']; + const { program, env, warn } = createNodeHarness({ + env: { RELAY_WORKSPACE_KEY: 'rk_other' }, + resolveEnrollment, + listFleetEnrollments, + resolveProjectWorkspaceSession: vi.fn(() => ({ + workspaceKey: 'rk_enrolled', + workspaceId: 'rw_123', + enrolledNodeId: 'node_abc', + })), }); await program.parseAsync(['node', 'up'], { from: 'user' }); expect(resolveEnrollment).not.toHaveBeenCalled(); expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(env.AGENT_RELAY_ENROLLED_NODE_ID).toBeUndefined(); + expect(warn.mock.calls.flat().join('\n')).toContain('explicit workspace key differs'); expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); @@ -246,17 +338,18 @@ describe('registerNodeCommands', () => { await program.parseAsync(['node', 'up'], { from: 'user' }); expect(resolveEnrollment).not.toHaveBeenCalled(); - expect(resolveProjectWorkspaceSession).not.toHaveBeenCalled(); + expect(resolveProjectWorkspaceSession).toHaveBeenCalledTimes(1); expect(env.RELAY_WORKSPACE_KEY).toBe('rk_alias'); expect(env.RELAY_NODE_TOKEN).toBeUndefined(); }); - it('never adopts an enrollment for a project that pinned its own workspace', async () => { + it('adopts an enrollment for a project pin whose workspace id matches', async () => { const resolveEnrollment = vi.fn( () => enrollmentRecord ) as unknown as NodeCommandDependencies['resolveEnrollment']; const resolveProjectWorkspaceSession = vi.fn(() => ({ workspaceKey: 'rk_project_session', + workspaceId: 'rw_123', })); const { program, env } = createNodeHarness({ env: {}, @@ -266,11 +359,8 @@ describe('registerNodeCommands', () => { await program.parseAsync(['node', 'up'], { from: 'user' }); - // A pin without an enrolled node id never reaches for the machine-global - // enrollment store, and no node token is applied — so `runUpCommand`'s - // precedence ladder resolves the repository pin unopposed. - expect(resolveEnrollment).not.toHaveBeenCalled(); - expect(env.RELAY_NODE_TOKEN).toBeUndefined(); + expect(resolveEnrollment).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'rw_123' })); + expect(env.RELAY_NODE_TOKEN).toBe('nt_secret'); expect(brokerMocks.runUpCommand).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/cli/commands/node.ts b/packages/cli/src/cli/commands/node.ts index 0fa000832..960973f90 100644 --- a/packages/cli/src/cli/commands/node.ts +++ b/packages/cli/src/cli/commands/node.ts @@ -90,13 +90,13 @@ export function registerNodeCommands( ); } -/** Normalize workspace env aliases and report whether `node up` received an explicit workspace. */ +/** Normalize workspace env aliases and return the explicit workspace key, when present. */ function prepareExplicitWorkspaceForNodeUp( options: UpCommandOptions, deps: NodeCommandDependencies -): boolean { +): string | undefined { const envWorkspaceKey = promoteWorkspaceKeyEnvAlias(deps.core.env); - return Boolean(options.workspaceKey?.trim() || envWorkspaceKey); + return options.workspaceKey?.trim() || envWorkspaceKey; } /** @@ -117,7 +117,7 @@ function reportWorkspaceSourceConflict( ): boolean { const pinnedWorkspaceId = session?.workspaceId?.trim(); const enrolledWorkspaceId = record.relayWorkspaceId?.trim(); - if (!pinnedWorkspaceId || !enrolledWorkspaceId || pinnedWorkspaceId === enrolledWorkspaceId) { + if (!workspaceSourcesConflict(record, session)) { return false; } @@ -137,6 +137,16 @@ function reportWorkspaceSourceConflict( return true; } +/** Whether the project pin and enrollment carry known, different workspace ids. */ +function workspaceSourcesConflict( + record: NonNullable>, + session: ProjectWorkspaceSession | undefined +): boolean { + const pinnedWorkspaceId = session?.workspaceId?.trim(); + const enrolledWorkspaceId = record.relayWorkspaceId?.trim(); + return Boolean(pinnedWorkspaceId && enrolledWorkspaceId && pinnedWorkspaceId !== enrolledWorkspaceId); +} + /** Apply stored enrollment credentials and return the enrolled node name, when present. */ function applyEnrollment( record: NonNullable>, @@ -161,10 +171,10 @@ function applyEnrollment( /** * 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. + * A legacy pin without either a workspace id or enrolled node id cannot be + * reconciled safely, so the broker starts in the pinned workspace and this + * machine does not serve a stored Cloud fleet node. Pins with a workspace id + * are matched in {@link resolveEnrollmentForProject} instead. */ function warnPinShadowsFleetEnrollments(deps: NodeCommandDependencies): void { let count: number | undefined; @@ -188,6 +198,30 @@ function warnPinShadowsFleetEnrollments(deps: NodeCommandDependencies): void { ); } +/** + * Report that an explicit workspace selection cannot be proven to match the + * repository's enrolled workspace. The explicit key still wins; this is the + * must-not-fire guard against silently carrying a node token into a different + * workspace. + */ +function warnExplicitWorkspaceShadowsFleetEnrollments(deps: NodeCommandDependencies): void { + let count: number | undefined; + try { + count = deps.listFleetEnrollments(deps.core.env).length; + } catch { + count = undefined; + } + if (count === 0) { + return; + } + const subject = + count === undefined ? 'stored Cloud fleet enrollments' : `${count} stored Cloud fleet enrollment(s)`; + deps.warn( + `The explicit workspace key differs from this project's enrolled workspace selection, so ${subject} will be ignored. ` + + 'The explicit workspace still wins and this machine will not serve a stored Cloud fleet-node identity.' + ); +} + /** Resolve the enrollment associated with a project session, avoiding ambiguous global fallback. */ function resolveEnrollmentForProject( session: ProjectWorkspaceSession | undefined, @@ -201,6 +235,13 @@ function resolveEnrollmentForProject( env, }); } + if (session?.workspaceId) { + return deps.resolveEnrollment({ + ...(env.RELAY_BASE_URL ? { baseUrl: env.RELAY_BASE_URL } : {}), + workspaceId: session.workspaceId, + env, + }); + } if (session) { warnPinShadowsFleetEnrollments(deps); return undefined; @@ -214,7 +255,7 @@ function resolveEnrollmentForProject( /** * Apply the node identity for this start. * - * Workspace selection is NOT decided here — `runUpCommand` walks the shared + * Workspace selection is not decided here — `runUpCommand` walks the shared * precedence ladder (flag → env → repository pin → machine-global active) after * this returns. This function only settles which node identity the broker runs * as, so an enrollment can no longer suppress the repository's workspace. @@ -247,34 +288,54 @@ async function runNodeUp(options: UpCommandOptions, deps: NodeCommandDependencie // bind an ephemeral API port atomically unless the operator explicitly // selected a stable broker base port. env.AGENT_RELAY_BROKER_PORT ??= '0'; - // An explicit workspace key (flag or env) is a direct workspace choice; the - // enrollment store records workspace ids, not keys, so a stored enrollment - // cannot be matched against it — skip pickup entirely rather than risk - // starting the broker with a token from a different workspace. + // An explicit workspace key (flag or env) is a direct workspace choice. It + // may retain a stored enrollment only when it is the same key as the project + // pin whose workspace id/node id proves the association. const explicitWorkspaceKey = prepareExplicitWorkspaceForNodeUp(options, deps); let enrolledNodeName: string | undefined; if (env.RELAY_NODE_TOKEN?.trim() && env.RELAY_NODE_ID?.trim()) { env.AGENT_RELAY_ENROLLED_NODE_ID ??= env.RELAY_NODE_ID.trim(); } - if (!env.RELAY_NODE_TOKEN && !explicitWorkspaceKey) { + if (!env.RELAY_NODE_TOKEN) { const projectSession = deps.resolveProjectWorkspaceSession(); + let projectSessionForIdentity = projectSession; let record: ReturnType | undefined; - try { - record = resolveEnrollmentForProject(projectSession, deps); - } catch (err) { - // A missing store resolves to undefined (fine); only an ambiguous - // multi-match throws, which must surface as a clear CLI error. - deps.error(err instanceof Error ? err.message : String(err)); - deps.exit(1); - return; + const explicitMatchesProject = + explicitWorkspaceKey !== undefined && + projectSession !== undefined && + explicitWorkspaceKey === projectSession.workspaceKey.trim(); + if (explicitWorkspaceKey && !explicitMatchesProject) { + // A raw workspace key does not reveal its workspace id. Only retain an + // enrollment when the explicit key is byte-identical to the project pin + // whose persisted workspaceId/enrolledNodeId establishes the match. + warnExplicitWorkspaceShadowsFleetEnrollments(deps); + projectSessionForIdentity = undefined; + } else { + try { + record = resolveEnrollmentForProject(projectSession, deps); + } catch (err) { + // A missing store resolves to undefined (fine); only an ambiguous + // multi-match throws, which must surface as a clear CLI error. + deps.error(err instanceof Error ? err.message : String(err)); + deps.exit(1); + return; + } } - if (record && reportWorkspaceSourceConflict(record, projectSession, deps)) { - deps.exit(1); - return; + if (record && workspaceSourcesConflict(record, projectSession)) { + if (!explicitWorkspaceKey) { + reportWorkspaceSourceConflict(record, projectSession, deps); + deps.exit(1); + return; + } + // Explicit workspace selection remains authoritative. Drop only the + // mismatched node credentials, warn, and let runUpCommand use the key. + warnExplicitWorkspaceShadowsFleetEnrollments(deps); + record = undefined; + projectSessionForIdentity = undefined; } // Serve under the enrolled name (mirrors the old `fleet serve // --enrollment-token` behavior where --name beat the enrollment name). - enrolledNodeName = applyResolvedNodeSession(record, projectSession, deps); + enrolledNodeName = applyResolvedNodeSession(record, projectSessionForIdentity, deps); } const nodeName = options.brokerName ?? enrolledNodeName; diff --git a/packages/cli/src/cli/lib/enrollment-pin.test.ts b/packages/cli/src/cli/lib/enrollment-pin.test.ts index 2be08d64a..7e01229a0 100644 --- a/packages/cli/src/cli/lib/enrollment-pin.test.ts +++ b/packages/cli/src/cli/lib/enrollment-pin.test.ts @@ -28,13 +28,60 @@ describe('linkEnrolledNodeToProjectPin', () => { const result = linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir }); - expect(result).toMatchObject({ status: 'linked', nodeId: 'node_abc' }); + expect(result).toMatchObject({ + status: 'linked', + nodeId: 'node_abc', + workspaceVerified: false, + }); expect(readProjectWorkspaceSession(dataDir)).toEqual({ workspaceKey: 'rk_live_pinned', enrolledNodeId: 'node_abc', }); }); + it('links a pin when its workspace id matches the enrollment and preserves that id', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned', { workspaceId: 'rw_same' }); + + const result = linkEnrolledNodeToProjectPin({ + nodeId: 'node_abc', + relayWorkspaceId: 'rw_same', + projectDataDir: dataDir, + }); + + expect(result).toMatchObject({ + status: 'linked', + nodeId: 'node_abc', + workspaceVerified: true, + }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_live_pinned', + enrolledNodeId: 'node_abc', + workspaceId: 'rw_same', + }); + }); + + it('does not link a pin whose workspace id differs from the enrollment', () => { + const dataDir = projectDataDir(); + writeProjectWorkspaceKey(dataDir, 'rk_live_pinned', { workspaceId: 'rw_other' }); + + const result = linkEnrolledNodeToProjectPin({ + nodeId: 'node_abc', + relayWorkspaceId: 'rw_enrolled', + projectDataDir: dataDir, + }); + + expect(result).toMatchObject({ + status: 'workspace-conflict', + relayWorkspaceId: 'rw_enrolled', + pinnedWorkspaceId: 'rw_other', + }); + expect(readProjectWorkspaceSession(dataDir)).toEqual({ + workspaceKey: 'rk_live_pinned', + workspaceId: 'rw_other', + }); + }); + it('leaves an unpinned project alone', () => { const dataDir = projectDataDir(); @@ -51,6 +98,7 @@ describe('linkEnrolledNodeToProjectPin', () => { expect(linkEnrolledNodeToProjectPin({ nodeId: 'node_abc', projectDataDir: dataDir })).toMatchObject({ status: 'unchanged', nodeId: 'node_abc', + workspaceVerified: false, }); }); diff --git a/packages/cli/src/cli/lib/enrollment-pin.ts b/packages/cli/src/cli/lib/enrollment-pin.ts index 364129895..e7c11c87b 100644 --- a/packages/cli/src/cli/lib/enrollment-pin.ts +++ b/packages/cli/src/cli/lib/enrollment-pin.ts @@ -11,15 +11,25 @@ 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 } + | { status: 'unchanged'; nodeId: string; pinPath: string; workspaceVerified: boolean } /** The pin now names this node, so `node up` serves it from this project. */ - | { status: 'linked'; nodeId: string; pinPath: string } + | { status: 'linked'; nodeId: string; pinPath: string; workspaceVerified: boolean } /** The pin names a different node; it is left untouched for the operator to resolve. */ - | { status: 'conflict'; nodeId: string; pinnedNodeId: string; pinPath: string }; + | { status: 'conflict'; nodeId: string; pinnedNodeId: string; pinPath: string } + /** The pin and enrollment name different Relay workspaces; the pin is left untouched. */ + | { + status: 'workspace-conflict'; + nodeId: string; + relayWorkspaceId: string; + pinnedWorkspaceId: string; + pinPath: string; + }; export interface LinkEnrolledNodeToProjectPinOptions { /** Node id from the enrollment record just persisted. */ nodeId: string; + /** Relay workspace id from the enrollment record just persisted. */ + relayWorkspaceId?: string; /** Project root whose pin should be reconciled. Defaults to the current project. */ projectRoot?: string; /** Explicit project Relay data directory. Takes precedence over `projectRoot`. */ @@ -56,13 +66,27 @@ export function linkEnrolledNodeToProjectPin( } const pinPath = projectWorkspaceKeyPath(dataDir); + const relayWorkspaceId = options.relayWorkspaceId?.trim(); + const pinnedWorkspaceId = session.workspaceId?.trim(); + if (relayWorkspaceId && pinnedWorkspaceId && relayWorkspaceId !== pinnedWorkspaceId) { + return { + status: 'workspace-conflict', + nodeId, + relayWorkspaceId, + pinnedWorkspaceId, + pinPath, + }; + } if (session.enrolledNodeId === nodeId) { - return { status: 'unchanged', nodeId, pinPath }; + return { status: 'unchanged', nodeId, pinPath, workspaceVerified: Boolean(pinnedWorkspaceId) }; } if (session.enrolledNodeId) { return { status: 'conflict', nodeId, pinnedNodeId: session.enrolledNodeId, pinPath }; } - writeProjectWorkspaceKey(dataDir, session.workspaceKey, { enrolledNodeId: nodeId }); - return { status: 'linked', nodeId, pinPath }; + writeProjectWorkspaceKey(dataDir, session.workspaceKey, { + enrolledNodeId: nodeId, + ...(pinnedWorkspaceId ? { workspaceId: pinnedWorkspaceId } : {}), + }); + return { status: 'linked', nodeId, pinPath, workspaceVerified: Boolean(pinnedWorkspaceId) }; } diff --git a/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/case.json b/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/case.json new file mode 100644 index 000000000..a9972c02b --- /dev/null +++ b/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/case.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "id": "1613-matching-fleet-enrollment-identity", + "kind": "bugfix", + "title": "Preserve matching Fleet enrollment identity", + "runner": { + "command": ["node", "tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs"] + }, + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "bug", + "signature": "matching_workspace_enrollment_identity_dropped" + }, + "head": { + "outcome": "fixed", + "signature": "matching_workspace_enrollment_identity_preserved" + } + } +} diff --git a/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs b/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs new file mode 100644 index 000000000..91846b1d3 --- /dev/null +++ b/tests/relayflows/cases/1613-matching-fleet-enrollment-identity/run.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const CASE_ID = '1613-matching-fleet-enrollment-identity'; +const arm = process.env.RELAY_PR_PROOF_ARM; +const targetDir = process.env.RELAY_PR_PROOF_TARGET_DIR; +const resultPath = process.env.RELAY_PR_PROOF_RESULT_PATH; +const childEnv = { ...process.env }; +delete childEnv.CODEX_MANAGED_BY_NPM; + +assert.ok(arm === 'base' || arm === 'head', 'RELAY_PR_PROOF_ARM must be base or head'); +assert.ok(targetDir, 'RELAY_PR_PROOF_TARGET_DIR is required'); +assert.ok(resultPath, 'RELAY_PR_PROOF_RESULT_PATH is required'); + +const expectedSha = + arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; +assert.ok(expectedSha, `missing expected ${arm} SHA`); +const targetSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { + encoding: 'utf8', +}).trim(); +assert.equal(targetSha, expectedSha, `target checkout does not match exact ${arm} SHA`); + +async function run(command, args, env = childEnv) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: targetDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const append = (chunk) => { + output = `${output}${chunk}`.slice(-16_000); + }; + child.stdout.on('data', append); + child.stderr.on('data', append); + child.once('error', reject); + child.once('close', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} ${args.join(' ')} failed (${code ?? signal}):\n${output}`)); + }); + }); +} + +await run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund', '--include=optional'], { + ...childEnv, + NODE_OPTIONS: '--max-old-space-size=256', +}); +await run('npm', ['run', 'build:core']); + +const scratch = await mkdtemp(path.join(tmpdir(), `relayflow-${CASE_ID}-`)); +const projectRoot = path.join(scratch, 'project'); +const projectDataDir = path.join(projectRoot, '.agentworkforce', 'relay'); +const relayHome = path.join(scratch, 'relay-home'); +await mkdir(projectDataDir, { recursive: true }); +await mkdir(relayHome, { recursive: true }); + +const workspaceKey = 'rk_live_relayflow_matching_enrollment'; +const workspaceId = '50587328-441d-4acb-b8f3-dbe1b3c5de99'; +const nodeId = 'node_relayflow_matching_enrollment'; +const nodeToken = 'nt_relayflow_matching_enrollment'; +const relaycastUrl = 'https://agentrelay.com'; +const proofEnv = { + AGENT_RELAY_HOME: relayHome, + AGENT_RELAY_TELEMETRY_DISABLED: '1', +}; + +try { + const targetRequire = createRequire(path.join(targetDir, 'package.json')); + const commanderPath = targetRequire.resolve('commander'); + const { Command } = await import(pathToFileURL(commanderPath).href); + const cloud = await import(pathToFileURL(path.join(targetDir, 'packages/cloud/dist/index.js')).href); + const { withDefaults } = await import( + pathToFileURL(path.join(targetDir, 'packages/cli/dist/cli/commands/core.js')).href + ); + const { registerNodeCommands } = await import( + pathToFileURL(path.join(targetDir, 'packages/cli/dist/cli/commands/node.js')).href + ); + + cloud.upsertFleetNodeEnrollment( + { + nodeId, + nodeName: 'relayflow-matching-node', + nodeToken, + relayWorkspaceId: workspaceId, + relaycastUrl, + websocketUrl: `${relaycastUrl}/v1/node/ws`, + enrolledAt: '2026-08-25T00:00:00.000Z', + }, + proofEnv + ); + cloud.writeProjectWorkspaceKey(projectDataDir, workspaceKey, { + workspaceId, + enrolledNodeId: nodeId, + }); + + let brokerEnv; + const relay = { + workspaceKey, + workspaceId, + apiPort: 43161, + brokerPid: process.pid, + spawn: async () => undefined, + getStatus: async () => ({ status: 'running' }), + shutdown: async () => undefined, + }; + const core = withDefaults({ + env: proofEnv, + getProjectPaths: () => ({ projectRoot, dataDir: projectDataDir }), + loadTeamsConfig: () => null, + execCommand: async () => ({ stdout: '', stderr: '' }), + createRelay: async () => { + brokerEnv = { ...proofEnv }; + return relay; + }, + onSignal: () => undefined, + holdOpen: async () => undefined, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + const program = new Command(); + program.exitOverride(); + registerNodeCommands(program, { core }); + await program.parseAsync(['node', 'up', '--workspace-key', workspaceKey], { from: 'user' }); + + assert.ok(brokerEnv, 'production node up command did not reach broker creation'); + const identityPreserved = + brokerEnv.RELAY_NODE_ID === nodeId && + brokerEnv.RELAY_NODE_TOKEN === nodeToken && + brokerEnv.AGENT_RELAY_ENROLLED_NODE_ID === nodeId; + if (arm === 'base') { + assert.equal(identityPreserved, false, 'base unexpectedly preserved the matching Fleet enrollment'); + assert.equal(brokerEnv.RELAY_NODE_ID, undefined); + assert.equal(brokerEnv.RELAY_NODE_TOKEN, undefined); + assert.equal(brokerEnv.AGENT_RELAY_ENROLLED_NODE_ID, undefined); + await writeFile( + resultPath, + `${JSON.stringify({ + version: 1, + caseId: CASE_ID, + arm, + outcome: 'bug', + signature: 'matching_workspace_enrollment_identity_dropped', + details: + 'The public node up command reached broker creation without the persisted Fleet node credentials even though the explicit workspace key matched the pinned workspace.', + })}\n` + ); + } else { + assert.equal(identityPreserved, true, 'head did not preserve the matching Fleet enrollment'); + await writeFile( + resultPath, + `${JSON.stringify({ + version: 1, + caseId: CASE_ID, + arm, + outcome: 'fixed', + signature: 'matching_workspace_enrollment_identity_preserved', + details: + 'The public node up command passed the persisted Fleet node credentials to broker creation when the explicit workspace key matched the pinned workspace.', + })}\n` + ); + } +} finally { + await rm(scratch, { recursive: true, force: true }); +}