From 1f8c374e146df1b082c07f7a6572988c29430e2e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 12 Sep 2026 23:09:23 +0200 Subject: [PATCH 01/41] feat(fleet): infer repository and persist sandbox routing Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 12 +- packages/cli/README.md | 64 +++ packages/cli/src/cli/commands/fleet.test.ts | 396 +++++++++++++++++- packages/cli/src/cli/commands/fleet.ts | 118 ++++-- packages/cli/src/cli/commands/local-agent.ts | 55 ++- .../src/cli/lib/fleet-attach-target.test.ts | 70 ++++ .../cli/src/cli/lib/fleet-attach-target.ts | 80 ++++ packages/cli/src/cli/lib/sandbox-repo.test.ts | 254 +++++++++++ packages/cli/src/cli/lib/sandbox-repo.ts | 236 +++++++++++ packages/cli/src/cli/lib/sdk-client.test.ts | 51 ++- packages/cli/src/cli/lib/sdk-client.ts | 44 +- packages/cloud/src/fleet-sandbox.test.ts | 51 +++ packages/cloud/src/fleet-sandbox.ts | 80 +++- packages/cloud/src/index.ts | 5 + .../cloud/src/project-workspace-key.test.ts | 124 +++++- packages/cloud/src/project-workspace-key.ts | 74 +++- packages/cloud/src/workspace-key.ts | 6 + packages/cloud/src/workspace-store.test.ts | 127 ++++++ packages/cloud/src/workspace-store.ts | 221 ++++++++++ 19 files changed, 1990 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/cli/lib/fleet-attach-target.test.ts create mode 100644 packages/cli/src/cli/lib/fleet-attach-target.ts create mode 100644 packages/cli/src/cli/lib/sandbox-repo.test.ts create mode 100644 packages/cli/src/cli/lib/sandbox-repo.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c8e928e45..d74a6cd4f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,17 @@ 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 - Minor] + +### Added + +- `fleet spawn --sandbox` attests a clean, pushed GitHub checkout at its exact + commit before dispatch and maps nested local directories to the sandbox clone. +- `node agent attach ` automatically routes to a unique live Fleet node; + ambiguous placements require `--node`. +- Temporary isolated Relaycast credentials are stored in the machine-local + 0600 credential store, while project metadata keeps only a non-secret + reference. ## [12.1.1] - 2026-09-15 diff --git a/packages/cli/README.md b/packages/cli/README.md index 757f2dd910..ebea31a740 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -219,6 +219,9 @@ agent-relay fleet spawn codex \ --name e2b-worker \ --task "Review the current workspace and wait for follow-up." +# A uniquely placed sandbox worker can be attached without node or route flags. +agent-relay node agent attach e2b-worker --mode drive + agent-relay message dm send api-worker "Detailed task instructions" # wait is the default: it queues for the recipient's next safe idle boundary and # can remain unread while that recipient is busy. steer requests immediate @@ -253,6 +256,67 @@ provisioning ends with an unknown outcome, rerun the command with the warning's `--sandbox-id` to replay the same Cloud identity instead of adopting another fleet node. +When the invocation runs inside a GitHub checkout, sandbox provisioning also +attests the checkout's exact `HEAD` and clones it under +`/srv/agent-workforce/`. The checkout must have no tracked changes or +untracked source files, and the exact commit must be reachable from an origin +remote. Relay's generated `.agentworkforce/relay/workspace-key.json`, +`connection.json`, and `runtime.json` metadata are permitted. Dirty checkouts and +commits known to be ahead of their origin upstream fail locally. For detached +or otherwise unverified commits, Cloud must fetch and verify the exact SHA +before dispatch; an unreachable commit produces a push-and-retry error. The temporary isolated +Relaycast credential stays in the machine store under +`~/.agentworkforce/relay`; the project file stores only a non-secret reference. + +`node agent attach ` automatically routes to the unique live fleet node +advertising that worker. If more than one live node advertises the name, the +command refuses to guess; pass `--node ` explicitly. Supplying +`--broker-url`, `--api-key`, or `--state-dir` keeps attach local and bypasses +automatic Fleet routing. `node agent message flush|hold|auto ` uses the +same unique-node lookup when no local broker flags are supplied. Fleet list and +release commands reuse the persisted project route; if that remote session is +unavailable, the command reports the routing failure instead of selecting a +same-named local worker. + +From a clean repository already pinned to a Relay workspace, the ordinary path is: + +```bash +agent-relay fleet spawn codex \ + --name cloud-zero-config \ + --task "Inspect this repository and report its current commit" \ + --sandbox +agent-relay node agent attach cloud-zero-config --mode drive +agent-relay fleet agent list +agent-relay fleet release cloud-zero-config +``` + +Invoking spawn from `packages/web` places the worker in that same relative +directory in the remote checkout. Private repositories use the pinned +workspace's connected GitHub access; a repository-access error means that +connection must be granted access to the repository. No GitHub token or +workspace key needs to be copied into the task or checkout. + +The Git checkout and Relayfile mirror are separate trees. Workspace `.skills` +are exposed through the agent CLIs' usual skill directories, and the worker's +task context identifies the mirror for other workspace records. Never move +`.git` into that mirror or clone a second repository there. + +Detaching leaves the worker running. Only one drive session can own a worker +at a time; detach the current driver before driving it in another shell, or +use `--mode view` to observe. Releasing a worker does not delete its sandbox. +To resume its retained sandbox, repeat spawn with the reported +`--sandbox-id `; the retained checkout must still have the same clean HEAD. +A failed resume preserves retained work. Delete an unused sandbox in Cloud +Fleet to stop future provider usage; monthly accounting reservations remain +until their normal reset. + +If the workspace is not pinned yet, use `agent-relay workspace rebind ` +with an existing stored workspace. A missing or mismatched stored route +credential requires rerunning sandbox provisioning for that workspace. +`--base-url`, `--workspace-id`, `--node`, provider selection, and `--cwd` remain +advanced overrides. Outside Git, the existing mount-based sandbox behavior is +preserved. + Large workspaces should select only the live subtree an agent needs. Pass one or more explicit directory roots after `--sandbox-relayfile-path`; Cloud validates the `/path/**` form and materializes those roots before the agent diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 70d7d2d5d9..319746da8a 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -123,6 +123,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -165,6 +166,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -195,6 +197,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -239,6 +242,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, core: { getProjectPaths: () => ({ projectRoot: '/p', dataDir: '/p/.agentworkforce/relay', teamDir: '/p' }), exit: vi.fn(), @@ -285,6 +289,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, core: { getProjectPaths: () => ({ projectRoot: '/p', dataDir: '/p/.agentworkforce/relay', teamDir: '/p' }), exit: vi.fn(), @@ -349,6 +354,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, core: { getProjectPaths: () => ({ projectRoot: '/p', dataDir: '/p/.agentworkforce/relay', teamDir: '/p' }), exit: vi.fn(), @@ -434,6 +440,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, @@ -489,6 +496,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, @@ -531,6 +539,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, @@ -582,6 +591,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ nodes })) as never, @@ -626,6 +636,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: vi.fn() as never, @@ -767,6 +778,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: vi.fn() as never, @@ -839,6 +851,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: vi.fn() as never, @@ -927,6 +940,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1030,11 +1044,256 @@ describe('fleet command support', () => { relayfileMountPath: '/workspace', }, invocation: { invocationId: 'inv_sandbox' }, - attachCommand: - "agent-relay node agent attach 'sandbox-worker' --node 'e2b-codex' --mode drive --base-url 'https://agent37-cast.agentrelay.com'", + attachCommand: "agent-relay node agent attach 'sandbox-worker' --mode drive", }); }); + it('infers the Git root for sandbox repos and forwards only the public revision attestation', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + const repositorySelection = { + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: '/local/cloud', + workerCwd: '/srv/agent-workforce/cloud/packages/web', + }; + const resolveSandboxRepository = vi.fn(() => repositorySelection); + const selectionOptions: Record[] = []; + const persistWorkspaceRelaycastTarget = vi.fn(() => true); + const placement = { + spawn: vi.fn(async () => ({ invocationId: 'inv_inferred', node: { name: 'cloud-node' } })), + }; + const register = vi.fn(async () => ({ token: 'at_live_launcher' })); + const release = vi.fn(async () => ({ released: true, deleted: true })); + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { + info: vi.fn(async () => ({ id: 'rw_abc' })), + register, + release, + }, + })); + const ensureCloudFleetSandbox = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-1', + nodeName: 'cloud-node', + sandboxId: 'sandbox-inferred', + providerSandboxId: 'provider-sandbox-1', + relayWorkspaceId: 'rw_abc', + relaycastTarget: AGENT37_RELAYCAST_TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + repoRevisions: { 'AgentWorkforce/cloud': revision }, + })); + const logs: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository, + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: vi.fn(), + exit: vi.fn() as never, + }, + ensureCloudFleetSandbox, + resolveWorkspaceSelection: (options) => { + selectionOptions.push(options as unknown as Record); + return { + key: 'rk_live_test', + source: 'project', + origin: '/local/cloud/.agentworkforce/relay/workspace-key.json', + workspaceId: 'rw_abc', + }; + }, + persistWorkspaceRelaycastTarget, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--workspace-id', + 'rw_abc', + '--name', + 'cloud-worker', + '--task', + 'Review the Cloud web package', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + + expect(resolveSandboxRepository).toHaveBeenCalled(); + expect(selectionOptions[0]).toMatchObject({ projectRoot: '/local/cloud' }); + const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0] as Record; + expect(ensureInput).toMatchObject({ + repos: ['AgentWorkforce/cloud'], + repoRevisions: { 'AgentWorkforce/cloud': revision }, + }); + expect(JSON.stringify(ensureInput)).not.toContain('/local/cloud'); + expect(JSON.stringify(ensureInput)).not.toContain('/srv/agent-workforce'); + expect(placement.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + worker_cwd: '/srv/agent-workforce/cloud/packages/web', + task: expect.stringContaining('Relayfile records are available at /workspace'), + }), + }) + ); + expect(persistWorkspaceRelaycastTarget).toHaveBeenCalled(); + expect(JSON.parse(logs[0]!).attachCommand).toBe( + "agent-relay node agent attach 'cloud-worker' --mode drive" + ); + }); + + it.each([ + ['missing', undefined], + ['mismatched', { 'AgentWorkforce/cloud': 'fedcba9876543210fedcba9876543210fedcba98' }], + ] as const)( + 'rejects a %s Cloud repository attestation and cleans up a fresh sandbox', + async (_label, actual) => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + const resolveSandboxRepository = vi.fn(() => ({ + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: '/local/cloud', + workerCwd: '/srv/agent-workforce/cloud/packages/web', + })); + const deleteCloudFleetSandbox = vi.fn(async () => undefined); + const createAgentRelay = vi.fn(); + const ensureCloudFleetSandbox = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-1', + nodeName: 'cloud-node', + sandboxId: 'sandbox-attestation-failure', + providerSandboxId: 'provider-sandbox-1', + relayWorkspaceId: 'rw_abc', + relaycastTarget: AGENT37_RELAYCAST_TARGET, + relayfileMounted: true, + ...(actual === undefined ? {} : { repoRevisions: actual }), + })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository, + sdk: { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: (() => { + throw new Error('__exit__'); + }) as never, + }, + ensureCloudFleetSandbox, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_test', + source: 'project', + origin: 'test', + workspaceId: 'rw_abc', + }), + persistWorkspaceRelaycastTarget: () => true, + deleteCloudFleetSandbox, + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await expect( + program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--workspace-id', + 'rw_abc', + '--name', + 'cloud-worker', + '--task', + 'Work', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + expect(deleteCloudFleetSandbox).toHaveBeenCalledWith({ + cloudWorkspaceId: 'cloud-workspace', + sandboxId: 'sandbox-attestation-failure', + providerId: 'agent37', + }); + expect(createAgentRelay).not.toHaveBeenCalled(); + } + ); + + it('fails before Cloud when local repository inference is unavailable', async () => { + const ensureCloudFleetSandbox = vi.fn(); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository: () => { + throw new Error('Cannot inspect the local Git checkout.'); + }, + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: (() => { + throw new Error('__exit__'); + }) as never, + }, + ensureCloudFleetSandbox: ensureCloudFleetSandbox as never, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await expect( + program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--name', + 'cloud-worker', + '--task', + 'Work', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + expect(ensureCloudFleetSandbox).not.toHaveBeenCalled(); + }); + it('fleet spawn --sandbox reuses an Agent37 target without a Relayfile mount', async () => { const placement = { spawn: vi.fn(async () => ({ invocationId: 'inv_reused', node: { name: 'agent37-codex' } })), @@ -1058,6 +1317,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1113,6 +1373,91 @@ describe('fleet command support', () => { expect(release).toHaveBeenCalled(); }); + it('preserves a retained sandbox when targeted spawn fails after attestation', async () => { + const deleteCloudFleetSandbox = vi.fn(async () => undefined); + const placement = { + spawn: vi.fn(async () => { + throw new Error('worker dispatch failed'); + }), + }; + const register = vi.fn(async () => ({ token: 'at_live_launcher' })); + const release = vi.fn(async () => ({ released: true, deleted: true })); + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { + info: vi.fn(async () => ({ id: 'rw_abc' })), + register, + release, + }, + })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: (() => { + throw new Error('__exit__'); + }) as never, + }, + ensureCloudFleetSandbox: vi.fn(async () => ({ + outcome: 'provisioned' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-1', + nodeName: 'agent37-codex', + sandboxId: REPLAY_SANDBOX_ID, + providerSandboxId: 'provider-sandbox-1', + relayWorkspaceId: 'rw_abc', + relaycastTarget: AGENT37_RELAYCAST_TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + providerId: 'agent37' as const, + })), + deleteCloudFleetSandbox, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_test', + source: 'flag', + origin: 'test', + workspaceId: 'rw_abc', + }), + persistWorkspaceRelaycastTarget: () => true, + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await expect( + program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--sandbox-id', + REPLAY_SANDBOX_ID, + '--sandbox-name', + REPLAY_SANDBOX_NAME, + '--name', + 'sandbox-worker', + '--task', + 'Work', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + + expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledWith(expect.objectContaining({ deleteAgent: true })); + }); + it('applies and persists a returned target for a reused non-Agent37 provider', async () => { vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_canonical_ambient'); const placement = { @@ -1139,6 +1484,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1222,6 +1568,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1306,6 +1653,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: vi.fn(() => ({ @@ -1391,6 +1739,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: vi.fn(() => ({ @@ -1482,6 +1831,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1546,6 +1896,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -1606,6 +1957,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn() } } })) as never, createWorkspaceRelay: vi.fn(() => ({ @@ -1680,6 +2032,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn() } } })) as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1741,6 +2094,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -1831,6 +2185,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn(async () => ({ invocationId: 'inv_generated' })) } }, @@ -1889,6 +2244,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -1946,6 +2302,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -1998,6 +2355,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -2056,7 +2414,7 @@ describe('fleet command support', () => { expect(warnings.join('\n')).toContain(`--sandbox-id '${REPLAY_SANDBOX_ID}'`); }); - it('deletes only the checkpointed Daytona ID after a matched malformed provisioned response', async () => { + it('preserves the caller Daytona ID after a matched malformed provisioned response', async () => { const warnings: string[] = []; const deleteCloudFleetSandbox = vi.fn(async () => undefined); const createWorkspaceRelay = vi.fn(); @@ -2072,6 +2430,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -2118,12 +2477,7 @@ describe('fleet command support', () => { ) ).rejects.toThrow('__exit__'); - expect(deleteCloudFleetSandbox).toHaveBeenCalledTimes(1); - expect(deleteCloudFleetSandbox).toHaveBeenCalledWith({ - cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', - sandboxId: REPLAY_SANDBOX_ID, - providerId: 'daytona', - }); + expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); expect(createWorkspaceRelay).not.toHaveBeenCalled(); expect(ensureCloudFleetSandbox).toHaveBeenCalledWith( expect.objectContaining({ @@ -2134,12 +2488,13 @@ describe('fleet command support', () => { expect(warnings).toEqual([]); }); - it('deletes only the checkpointed Daytona ID after a matched malformed timeout response', async () => { + it('preserves the caller Daytona ID after a matched malformed timeout response', async () => { const warnings: string[] = []; const deleteCloudFleetSandbox = vi.fn(async () => undefined); const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2194,12 +2549,7 @@ describe('fleet command support', () => { ) ).rejects.toThrow('__exit__'); - expect(deleteCloudFleetSandbox).toHaveBeenCalledTimes(1); - expect(deleteCloudFleetSandbox).toHaveBeenCalledWith({ - cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', - sandboxId: REPLAY_SANDBOX_ID, - providerId: 'daytona', - }); + expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); expect(warnings).toEqual([]); }); @@ -2208,6 +2558,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -2270,6 +2621,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -2334,6 +2686,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn(() => ({ @@ -2418,6 +2771,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: vi.fn() as never, @@ -2465,6 +2819,7 @@ describe('fleet command support', () => { program.exitOverride(); const errors: unknown[] = []; registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: vi.fn() as never, @@ -2511,6 +2866,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2598,6 +2954,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2647,6 +3004,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2702,6 +3060,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -2785,6 +3144,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -2848,6 +3208,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2886,6 +3247,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2927,6 +3289,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, core: { getProjectPaths: () => ({ projectRoot: '/p', dataDir: '/p/.agentworkforce/relay', teamDir: '/p' }), exit: vi.fn(), @@ -2963,6 +3326,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, error: (...args: unknown[]) => errors.push(args.join(' ')), log: () => undefined, warn: () => undefined, diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 07cafb6858..70da404951 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -31,6 +31,7 @@ import { isAvailableFleetNode } from '../lib/fleet-live-agents.js'; import { declaredWorkforceMetadata } from '../lib/registration-metadata.js'; import { redactSecrets } from '../lib/redact.js'; import { attributableReleaseReason } from '../lib/release-reason.js'; +import { resolveSandboxRepository, type SandboxRepositorySelection } from '../lib/sandbox-repo.js'; import { spawnPlacementReceipt } from '../lib/spawn-lifecycle.js'; import { resolveAgentToken, @@ -61,6 +62,30 @@ function spawnInvocationWithPlacement(invocation: Record): Reco return { ...invocation, placement: spawnPlacementReceipt(invocation) }; } +function assertSandboxRepositoryRevision( + sandbox: EnsureCloudFleetSandboxResult, + selection: SandboxRepositorySelection | undefined +): void { + if (!selection) return; + const expected = { [selection.repository]: selection.revision }; + if (sandbox.outcome === 'provisioning_timeout') { + throw new Error( + 'Cloud did not verify the requested repository revision before the sandbox became ready.' + ); + } + const actual = sandbox.repoRevisions?.[selection.repository]; + if (actual !== selection.revision || Object.keys(sandbox.repoRevisions ?? {}).length !== 1) { + throw new Error( + `Cloud did not echo the requested repository revision for ${selection.repository}; update Cloud before retrying this sandbox launch.` + ); + } + // Keep the shape check explicit at the CLI boundary too: injected/test + // implementations and older Cloud clients must not bypass the attestation. + if (JSON.stringify(sandbox.repoRevisions) !== JSON.stringify(expected)) { + throw new Error(`Cloud returned an unexpected repository revision for ${selection.repository}.`); + } +} + // The targeted spawn path (relay.messaging.placement.spawn) returns an // invocation whose `placement` is the SDK's own evidence object // (`state: 'accepted' | 'ready'`, `confirmed`). `spawnPlacementReceipt` @@ -110,6 +135,7 @@ export interface FleetCommandDependencies { sdk: SdkCommandDeps; createFleetWorkspaceClient: (options: SdkClientOptions) => RelayWorkspaceThinClient; resolveWorkspaceSelection: typeof resolveWorkspaceSelection; + resolveSandboxRepository: typeof resolveSandboxRepository; persistWorkspaceRelaycastTarget: typeof persistWorkspaceRelaycastTarget; ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; deleteCloudFleetSandbox: typeof deleteCloudFleetSandbox; @@ -130,6 +156,7 @@ function withFleetDefaults(overrides: Partial = {}): F return createWorkspaceClient({ workspaceKey, baseUrl }); }, resolveWorkspaceSelection, + resolveSandboxRepository, persistWorkspaceRelaycastTarget, ensureCloudFleetSandbox, deleteCloudFleetSandbox, @@ -262,6 +289,10 @@ export function registerFleetCommands( const useSandbox = options.sandbox === true; const sandboxName = optionalText(options.sandboxName, 'Sandbox name'); const sandboxIdOption = optionalText(options.sandboxId, 'Sandbox ID'); + // An explicit sandbox identity is a retained/replayable resource. Never + // delete it as collateral when a later verification or dispatch step + // fails; only clean up sandboxes whose identity this invocation minted. + const shouldCleanupSandbox = sandboxIdOption === undefined; const explicitWorkspaceId = optionalText(options.workspaceId, 'Workspace ID'); if (sandboxIdOption !== undefined && !CLOUD_SANDBOX_ID_PATTERN.test(sandboxIdOption)) { throw new Error('--sandbox-id must match lowercase sbx_ using an RFC 4122 UUID.'); @@ -324,15 +355,25 @@ export function registerFleetCommands( } let sandbox: EnsureCloudFleetSandboxResult | undefined; + let sandboxRepository: SandboxRepositorySelection | undefined; let workspaceRelay: ReturnType | undefined; let relaycastClientOptions = clientOptions; let legacyWorkspaceClientOptions = clientOptions; if (useSandbox) { + const projectRoot = deps.core.getProjectPaths().projectRoot; + sandboxRepository = deps.resolveSandboxRepository( + projectRoot, + optionalText(options.cwd, 'Worker cwd') + ); + if (sandboxRepository) workerCwd = sandboxRepository.workerCwd; // Cloud must be the first network authority for a sandbox invocation. // A canonical Relaycast info call would both leak the workspace key and // make it impossible to prove that Cloud's isolated target is the one // subsequently used for registration and dispatch. - const workspaceSelection = deps.resolveWorkspaceSelection(clientOptions); + const workspaceSelection = deps.resolveWorkspaceSelection({ + ...clientOptions, + ...(sandboxRepository ? { projectRoot: sandboxRepository.projectRoot } : {}), + }); legacyWorkspaceClientOptions = { ...clientOptions, ...(sandboxProvider === 'agent37' ? {} : { ignorePersistedRelaycastTarget: true }), @@ -344,7 +385,7 @@ export function registerFleetCommands( // service and defeats the zero-shared-traffic canary proof. if (!relayWorkspaceId && sandboxProvider === undefined) { throw new Error( - 'Sandbox provisioning without --sandbox-provider requires a persisted Relay workspace identity; run `relay workspace pin` or pass --workspace-id.' + 'Sandbox provisioning without --sandbox-provider requires a persisted Relay workspace identity; run `relay workspace rebind ` or pass --workspace-id.' ); } if (!relayWorkspaceId && sandboxProvider !== undefined && sandboxProvider !== 'agent37') { @@ -355,7 +396,7 @@ export function registerFleetCommands( if (!relayWorkspaceId) { throw new Error( sandboxProvider === 'agent37' - ? 'Agent37 sandbox provisioning requires a persisted Relay workspace identity; run `relay workspace pin` or pass --workspace-id.' + ? 'Agent37 sandbox provisioning requires a persisted Relay workspace identity; run `relay workspace rebind ` or pass --workspace-id.' : 'The current Relay workspace did not report an ID for Cloud provisioning.' ); } @@ -401,9 +442,15 @@ export function registerFleetCommands( workloadProfile, waitTimeoutMs: 90_000, ...(effectiveSandboxName === undefined ? {} : { name: effectiveSandboxName }), + ...(sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), + ...(sandboxRepository + ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } } + : {}), }); + assertSandboxRepositoryRevision(sandbox, sandboxRepository); } catch (error) { if ( + shouldCleanupSandbox && error instanceof CloudFleetSandboxProvisionError && error.confirmedProvisioned && error.cloudWorkspaceId && @@ -431,6 +478,7 @@ export function registerFleetCommands( } so a sandbox is not left running.` ); } else if ( + shouldCleanupSandbox && error instanceof CloudFleetSandboxProvisionError && error.cloudWorkspaceId && error.sandboxId @@ -449,6 +497,21 @@ export function registerFleetCommands( ); }); } + if (shouldCleanupSandbox && sandbox && sandbox.outcome !== 'reused') { + await deps + .deleteCloudFleetSandbox({ + cloudWorkspaceId: sandbox.cloudWorkspaceId, + sandboxId: sandbox.sandboxId, + ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }), + }) + .catch((cleanupError) => { + deps.warn( + `Sandbox repository verification failed and cleanup also failed: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }` + ); + }); + } throw error; } if (sandbox.outcome !== 'provisioning_timeout' && sandbox.relaycastTarget) { @@ -496,7 +559,7 @@ export function registerFleetCommands( ); } } catch (error) { - if (sandbox.outcome === 'provisioned') { + if (shouldCleanupSandbox && sandbox.outcome === 'provisioned') { await deps .deleteCloudFleetSandbox({ cloudWorkspaceId: sandbox.cloudWorkspaceId, @@ -521,19 +584,21 @@ export function registerFleetCommands( relaycastClientOptions = legacyWorkspaceClientOptions; } if (sandbox.outcome === 'provisioning_timeout') { - await deps - .deleteCloudFleetSandbox({ - cloudWorkspaceId: sandbox.cloudWorkspaceId, - sandboxId: sandbox.sandboxId, - ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }), - }) - .catch((error) => { - deps.warn( - `The timed-out sandbox could not be cleaned up automatically: ${ - error instanceof Error ? error.message : String(error) - }` - ); - }); + if (shouldCleanupSandbox) { + await deps + .deleteCloudFleetSandbox({ + cloudWorkspaceId: sandbox.cloudWorkspaceId, + sandboxId: sandbox.sandboxId, + ...(sandbox.providerId === undefined ? {} : { providerId: sandbox.providerId }), + }) + .catch((error) => { + deps.warn( + `The timed-out sandbox could not be cleaned up automatically: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + } throw new Error( `Sandbox node '${sandbox.nodeName}' did not become ready within ${sandbox.waitedMs}ms.` ); @@ -542,7 +607,7 @@ export function registerFleetCommands( mountSandboxRelayfile && (sandbox.outcome !== 'provisioned' || sandbox.relayfileMounted !== true) ) { - if (sandbox.outcome === 'provisioned') { + if (shouldCleanupSandbox && sandbox.outcome === 'provisioned') { await deps .deleteCloudFleetSandbox({ cloudWorkspaceId: sandbox.cloudWorkspaceId, @@ -619,7 +684,13 @@ export function registerFleetCommands( input: { name, cli, - task, + task: + sandbox && + sandboxRepository && + mountSandboxRelayfile && + (sandbox.outcome === 'provisioned' || sandbox.outcome === 'reused') + ? `${task}\n\nAgent Relay sandbox context: Relayfile records are available at ${sandbox.outcome === 'provisioned' ? (sandbox.relayfileMountPath ?? '/workspace') : '/workspace'}. The source checkout is separate; use ${workerCwd ?? 'the worker checkout'} for repository files and the mount for Relayfile records.` + : task, ...(channel ? { channels: [channel] } : {}), ...(model ? { model } : {}), ...(workerCwd ? { worker_cwd: workerCwd } : {}), @@ -642,19 +713,14 @@ export function registerFleetCommands( ...(sandbox ? { sandbox: printableSandbox, - attachCommand: - `agent-relay node agent attach ${shellQuote(name)} ` + - `--node ${shellQuote(targetNode)} --mode drive` + - ((sandbox.outcome === 'provisioned' || sandbox.outcome === 'reused') && - sandbox.relaycastTarget - ? ` --base-url ${shellQuote(sandbox.relaycastTarget.baseUrl)}` - : ''), + attachCommand: `agent-relay node agent attach ${shellQuote(name)} --mode drive`, } : {}), invocation: spawnInvocationWithMergedPlacement(invocation as unknown as Record), }); } catch (error) { if ( + shouldCleanupSandbox && sandbox?.outcome === 'provisioned' && !(error instanceof RelayPlacementError && error.state === 'unconfirmed_may_be_running') ) { diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index 9f3ae3599b..f8398024ee 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -25,6 +25,7 @@ import { import { resolvedSpawnRuntime, spawnAgentWithClient } from '../lib/client-factory.js'; import { describeError } from '../lib/describe-error.js'; import { defaultExit } from '../lib/exit.js'; +import { resolveFleetAttachTarget, type FleetAttachResolution } from '../lib/fleet-attach-target.js'; import { redeemJoinTicket } from '../lib/join-ticket.js'; import { describeClearedEnrollment, persistWorkspaceSession } from '../lib/workspace-session.js'; @@ -191,6 +192,7 @@ export interface LocalAgentDependencies { node: string, options: FleetNodeAttachCliOptions ) => Promise; + resolveFleetAttachTarget: (name: string) => Promise; cwd: () => string; readConnectionFile: (stateDir: string) => unknown; getDefaultStateDir: () => string; @@ -217,6 +219,7 @@ function withDefaults(overrides: Partial = {}): LocalAge attach: runAttach, attachRemote: attachRemoteNode, attachNode: attachFleetNode, + resolveFleetAttachTarget, log: (...args: unknown[]) => console.log(...args), error: (...args: unknown[]) => console.error(...args), exit: defaultExit, @@ -618,7 +621,25 @@ async function withDeliveryModeClient( sessionMode: AttachMode, run: (client: ReturnType) => Promise ): Promise { - const node = typeof opts.node === 'string' && opts.node.trim() ? opts.node.trim() : undefined; + let node = typeof opts.node === 'string' && opts.node.trim() ? opts.node.trim() : undefined; + let targetBaseUrl: string | undefined; + if ( + !node && + opts.brokerUrl === undefined && + opts.apiKey === undefined && + opts.stateDir === undefined + ) { + const fleetTarget = await deps.resolveFleetAttachTarget(name); + if (fleetTarget.error) { + deps.error(`Error: ${fleetTarget.error}`); + deps.exit(1); + return undefined; + } + if (fleetTarget.target) { + node = fleetTarget.target.node; + targetBaseUrl = fleetTarget.target.baseUrl; + } + } if (!node) { let captured: T | undefined; await runLocalBroker(deps, brokerOptionsFromOpts(opts), async (client) => { @@ -650,6 +671,7 @@ async function withDeliveryModeClient( mode: sessionMode, env: deps.env, fetch: deps.fetch, + ...(targetBaseUrl ? { baseUrl: targetBaseUrl } : {}), ...(workspaceKey ? { workspaceKey } : {}), }); return await run( @@ -968,6 +990,37 @@ export function registerLocalAgentCommands( if (code !== 0) deps.exit(code); return; } + // A sandbox worker has no local broker. Resolve a unique live fleet + // placement before falling back to the local connection contract so a + // flag-free attach follows the worker automatically. + if ( + options.brokerUrl === undefined && + options.apiKey === undefined && + options.stateDir === undefined + ) { + const fleetTarget = await deps.resolveFleetAttachTarget(name); + if (fleetTarget.error) { + deps.error(`Error: ${fleetTarget.error}`); + deps.exit(1); + return; + } + if (fleetTarget.target) { + try { + const code = await deps.attachNode(name, mode, fleetTarget.target.node, { + baseUrl: fleetTarget.target.baseUrl, + json: options.json as boolean | undefined, + reasoning: options.reasoning as boolean | undefined, + diagnostics: options.diagnostics as boolean | undefined, + }); + if (code !== 0) deps.exit(code); + } catch (error) { + const message = describeError(error); + deps.error(message.startsWith('Error:') ? message : `Error: ${message}`); + deps.exit(1); + } + return; + } + } const code = await deps.attach(name, mode, { brokerUrl: options.brokerUrl as string | undefined, apiKey: options.apiKey as string | undefined, diff --git a/packages/cli/src/cli/lib/fleet-attach-target.test.ts b/packages/cli/src/cli/lib/fleet-attach-target.test.ts new file mode 100644 index 0000000000..75a428c66c --- /dev/null +++ b/packages/cli/src/cli/lib/fleet-attach-target.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveFleetAttachTarget } from './fleet-attach-target.js'; + +const node = (name: string, agents: string[]) => ({ + name, + nodeId: `${name}-id`, + status: 'online' as const, + live: true, + handlersLive: true, + capabilities: [{ name: 'relay:live-agents:v1', metadata: { names: agents } }], +}); + +describe('resolveFleetAttachTarget', () => { + it('returns the unique live node and persisted route', async () => { + await expect( + resolveFleetAttachTarget( + 'sandbox-worker', + () => ({ nodes: { list: async () => [node('sandbox-1', ['sandbox-worker'])] } }) as never, + () => ({ baseUrl: 'https://agent37-cast.agentrelay.com' }) + ) + ).resolves.toEqual({ + target: { node: 'sandbox-1', baseUrl: 'https://agent37-cast.agentrelay.com' }, + }); + }); + + it('reports ambiguity instead of guessing a node', async () => { + await expect( + resolveFleetAttachTarget( + 'worker', + () => ({ nodes: { list: async () => [node('one', ['worker']), node('two', ['worker'])] } }) as never, + () => ({}) + ) + ).resolves.toMatchObject({ error: expect.stringContaining('multiple fleet nodes') }); + }); + + it('redacts transport error details before returning persisted-session diagnostics', async () => { + const result = await resolveFleetAttachTarget( + 'worker', + (() => { + throw new Error('request failed for https://relay.example/?api_key=rk_live_fake_secret'); + }) as never, + () => ({}), + () => ({ + key: 'rk_live_workspace', + source: 'project', + origin: '/tmp/project/.agentworkforce/relay/workspace-key.json', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + }) + ); + + expect(result.error).toContain('persisted remote Fleet session'); + expect(result.error).not.toContain('rk_live_fake_secret'); + }); + + it('does not leak parse errors while reading the project session', async () => { + const result = await resolveFleetAttachTarget( + 'worker', + (() => ({ nodes: { list: async () => [] } })) as never, + () => ({}), + (() => { + throw new Error('invalid session token=rk_live_parse_secret'); + }) as never + ); + + expect(result.error).toContain('could not read the persisted workspace session'); + expect(result.error).not.toContain('rk_live_parse_secret'); + }); +}); diff --git a/packages/cli/src/cli/lib/fleet-attach-target.ts b/packages/cli/src/cli/lib/fleet-attach-target.ts new file mode 100644 index 0000000000..2ea805f724 --- /dev/null +++ b/packages/cli/src/cli/lib/fleet-attach-target.ts @@ -0,0 +1,80 @@ +import type { AgentRelay, RelayNode } from '@agent-relay/sdk'; + +import { describeError } from './describe-error.js'; +import { isAvailableFleetNode, readRemoteLiveAgents } from './fleet-live-agents.js'; +import { createWorkspaceRelay, resolveWorkspaceSelection, resolveWorkspaceTransport } from './sdk-client.js'; + +export interface FleetAttachTarget { + node: string; + baseUrl?: string; +} + +export interface FleetAttachResolution { + target?: FleetAttachTarget; + error?: string; +} + +export type CreateFleetRelay = () => AgentRelay; +export type ResolveFleetTransport = () => { baseUrl?: string }; +export type ResolveFleetSelection = typeof resolveWorkspaceSelection; + +/** Resolve a unique live fleet node for an agent name without exposing credentials. */ +export async function resolveFleetAttachTarget( + agentName: string, + createRelay: CreateFleetRelay = createWorkspaceRelay, + resolveTransport: ResolveFleetTransport = resolveWorkspaceTransport, + readSelection: ResolveFleetSelection = resolveWorkspaceSelection +): Promise { + let selection: ReturnType; + try { + selection = readSelection(); + } catch (error) { + return { + error: + `Fleet attach routing could not read the persisted workspace session for '${agentName}'. ` + + `Pass --node explicitly or repair the project session. (${describeError(error)})`, + }; + } + const knownRemoteSession = Boolean(selection?.relaycastRoute && selection.relaycastBaseUrl); + try { + const relay = createRelay(); + const nodes = await relay.nodes.list(); + const matches = nodes.filter( + (node) => + isAvailableFleetNode(node) && + readRemoteLiveAgents(node).agents.some((agent) => agent.name === agentName) + ); + if (matches.length === 0) { + return knownRemoteSession + ? { error: `Agent '${agentName}' has no live Fleet placement on the persisted remote session.` } + : {}; + } + if (matches.length > 1) { + const labels = matches.map(nodeLabel).filter(Boolean).join(', '); + return { + error: `Agent '${agentName}' is running on multiple fleet nodes (${labels}). Pass --node to choose one.`, + }; + } + const node = matches[0]; + const label = nodeLabel(node); + if (!label) return { error: `Agent '${agentName}' is on a fleet node without a usable node identity.` }; + const transport = resolveTransport(); + return { target: { node: label, ...(transport.baseUrl ? { baseUrl: transport.baseUrl } : {}) } }; + } catch (error) { + // Automatic routing is opportunistic. A missing credential or unavailable + // control plane must preserve the established local attach error path. + return knownRemoteSession + ? { + error: + `The persisted remote Fleet session could not resolve '${agentName}'. ` + + `Check fleet liveness or pass --node explicitly. (${describeError(error)})`, + } + : {}; + } +} + +function nodeLabel(node: RelayNode): string | undefined { + return [node.name, node.nodeId, node.id].find( + (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 + ); +} diff --git a/packages/cli/src/cli/lib/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts new file mode 100644 index 0000000000..11a134da4d --- /dev/null +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { resolveSandboxRepository } from './sandbox-repo.js'; + +function gitMock(): ReturnType { + return vi.fn((_command: string, args: readonly string[]) => { + if (args.includes('--show-toplevel')) return '/checkout\n'; + if (args.includes('get-url')) return 'git@github.com:AgentWorkforce/relay.git\n'; + if (args.includes('HEAD')) return '0123456789abcdef0123456789abcdef01234567\n'; + if (args.includes('status')) return ''; + if (args.includes('@{u}')) return 'origin/main\n'; + if (args.includes('merge-base')) return ''; + throw new Error('unexpected git invocation'); + }); +} + +describe('resolveSandboxRepository', () => { + it('returns a public repo, exact revision, and sandbox-relative cwd', () => { + const result = resolveSandboxRepository('/checkout', '/checkout/packages/cli', { + execFileSync: gitMock() as never, + }); + expect(result).toEqual({ + repository: 'AgentWorkforce/relay', + repositoryName: 'relay', + revision: '0123456789abcdef0123456789abcdef01234567', + projectRoot: '/checkout', + workerCwd: '/srv/agent-workforce/relay/packages/cli', + }); + }); + + it('rejects an arbitrary local cwd outside the checkout', () => { + expect(() => + resolveSandboxRepository('/checkout', '/Users/operator/private', { execFileSync: gitMock() as never }) + ).toThrow(/outside the checked-out repository/); + }); + + it('rejects tracked edits but permits the generated workspace pin', () => { + const dirty = gitMock(); + dirty.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('status')) return ' M src/index.ts\0?? .agentworkforce/relay/workspace-key.json\0'; + return (gitMock() as never)(_command, args); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: dirty as never })).toThrow( + /clean checkout.*src\/index\.ts/ + ); + }); + + it('rejects untracked source files', () => { + const dirty = gitMock(); + dirty.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('status')) return '?? scratch.ts\0'; + return (gitMock() as never)(_command, args); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: dirty as never })).toThrow( + /clean checkout.*scratch\.ts/ + ); + }); + + it('rejects untracked user configuration beside generated relay metadata', () => { + for (const file of ['.agentworkforce/relay/config.json', '.agentworkforce/relay/teams.json']) { + const dirty = gitMock(); + dirty.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('status')) return `?? ${file}\0`; + return (gitMock() as never)(_command, args); + }); + expect(() => + resolveSandboxRepository('/checkout', undefined, { execFileSync: dirty as never }) + ).toThrow(/clean checkout/); + } + }); + + it('rejects non-HTTPS/SSH GitHub remote URLs', () => { + const run = gitMock(); + run.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('get-url')) return 'ftp://github.com/AgentWorkforce/relay.git\n'; + return (gitMock() as never)(_command, args); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: run as never })).toThrow( + /GitHub owner\/name/ + ); + }); + + it('rejects a branch whose exact HEAD is ahead of its upstream', () => { + const ahead = gitMock(); + ahead.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('merge-base')) throw new Error('not ancestor'); + return (gitMock() as never)(_command, args); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: ahead as never })).toThrow( + /not pushed/ + ); + }); + + it('rejects a Git checkout with no usable origin', () => { + const run = vi.fn((_command: string, args: readonly string[]) => { + if (args.includes('--show-toplevel')) return '/checkout\n'; + throw new Error('no origin'); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: run as never })).toThrow( + /no usable origin/ + ); + }); + + it('falls back only for a confirmed non-Git directory', () => { + const outside = vi.fn(() => { + throw { status: 128, stderr: 'fatal: not a git repository (or any of the parent directories): .git' }; + }); + expect( + resolveSandboxRepository('/plain', undefined, { cwd: () => '/plain', execFileSync: outside as never }) + ).toBeUndefined(); + for (const failure of [ + { status: 128, stderr: 'fatal: detected dubious ownership' }, + { code: 'ETIMEDOUT' }, + { code: 'ENOENT' }, + ]) { + const run = vi.fn(() => { + throw failure; + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: run as never })).toThrow( + /Cannot inspect/ + ); + } + }); + + it('accepts SSH URL origins and does not require an upstream for detached HEAD', () => { + const run = gitMock(); + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('get-url')) return 'ssh://git@github.com/AgentWorkforce/relay.git\n'; + if (args.includes('@{u}')) throw new Error('detached'); + return gitMock()(command, args); + }); + expect( + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ?.repository + ).toBe('AgentWorkforce/relay'); + }); + + it('honors a selected Relay project when the shell is outside Git', () => { + const run = gitMock(); + run.mockImplementation((command: string, args: readonly string[]) => { + if (args[1] === '/outside') throw { status: 128, stderr: 'fatal: not a git repository' }; + return gitMock()(command, args); + }); + expect( + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/outside', execFileSync: run as never }) + ).toMatchObject({ projectRoot: '/checkout', workerCwd: '/srv/agent-workforce/relay' }); + }); + + it('keeps the porcelain XY columns and rejects tracked changes to generated metadata', () => { + const run = gitMock(); + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('status')) return ' M .agentworkforce/relay/workspace-key.json\0'; + return gitMock()(command, args); + }); + expect(() => + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ).toThrow('".agentworkforce/relay/workspace-key.json"'); + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('status')) return '?? .agentworkforce/relay/workspace-key.json\0'; + return gitMock()(command, args); + }); + expect( + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ?.revision + ).toMatch(/^[a-f0-9]{40}$/); + }); + + it('maps a symlinked nested invocation to the actual Git root and relative directory', () => { + const realpath = (value: string) => (value === '/linked/packages/cli' ? '/checkout/packages/cli' : value); + expect( + resolveSandboxRepository('/linked/packages/cli', undefined, { + cwd: () => '/linked/packages/cli', + execFileSync: gitMock() as never, + realpathSync: realpath as never, + }) + ).toMatchObject({ projectRoot: '/checkout', workerCwd: '/srv/agent-workforce/relay/packages/cli' }); + }); + + it('retains repository inference with an explicit remote cwd override', () => { + expect( + resolveSandboxRepository('/checkout', '/workspace/context', { + cwd: () => '/checkout', + execFileSync: gitMock() as never, + }) + ).toMatchObject({ repository: 'AgentWorkforce/relay', workerCwd: '/workspace/context' }); + expect(() => + resolveSandboxRepository('/checkout', '/workspace/../../Users/private', { + cwd: () => '/checkout', + execFileSync: gitMock() as never, + }) + ).toThrow(/escapes/); + }); + + it('infers a real nested checkout and refuses dirty or unpushed work', () => { + const fixture = realpathSync(mkdtempSync(path.join(tmpdir(), 'relay-sandbox-repo-'))); + const checkout = path.join(fixture, 'cloud'); + const remote = path.join(fixture, 'remote.git'); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + }; + const run = ((file: string, args: string[], options: object = {}) => + execFileSync(file, args, { ...options, env, timeout: 10_000 })) as typeof execFileSync; + const git = (...args: string[]) => + execFileSync('git', ['-C', checkout, ...args], { env, encoding: 'utf8', timeout: 10_000 }).trim(); + try { + mkdirSync(checkout); + git('init', '--initial-branch=main'); + git('config', 'user.name', 'Sandbox Fixture'); + git('config', 'user.email', 'fixture@example.test'); + mkdirSync(path.join(checkout, 'packages', 'web'), { recursive: true }); + writeFileSync(path.join(checkout, 'packages', 'web', 'index.ts'), 'export const version = 1;\n'); + git('add', '.'); + git('-c', 'commit.gpgsign=false', 'commit', '-m', 'initial'); + execFileSync('git', ['init', '--bare', remote], { env, stdio: 'ignore', timeout: 10_000 }); + git('remote', 'add', 'origin', remote); + git('push', '--set-upstream', 'origin', 'main'); + git('remote', 'set-url', 'origin', 'git@github.com:AgentWorkforce/cloud.git'); + const revision = git('rev-parse', 'HEAD'); + const nested = path.join(checkout, 'packages', 'web'); + const deps = { execFileSync: run, cwd: () => nested }; + expect(resolveSandboxRepository(nested, undefined, deps)).toEqual({ + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: checkout, + workerCwd: '/srv/agent-workforce/cloud/packages/web', + }); + mkdirSync(path.join(checkout, '.agentworkforce', 'relay'), { recursive: true }); + writeFileSync(path.join(checkout, '.agentworkforce', 'relay', 'workspace-key.json'), '{}\n'); + expect(resolveSandboxRepository(nested, undefined, deps)?.revision).toBe(revision); + writeFileSync(path.join(checkout, 'packages', 'web', 'index.ts'), 'export const version = 2;\n'); + expect(() => resolveSandboxRepository(nested, undefined, deps)).toThrow( + /clean checkout.*packages\/web\/index.ts/ + ); + git('add', 'packages/web/index.ts'); + git('-c', 'commit.gpgsign=false', 'commit', '-m', 'local only'); + expect(() => resolveSandboxRepository(nested, undefined, deps)).toThrow(/not pushed/); + git('checkout', '--detach', revision); + expect(resolveSandboxRepository(nested, undefined, deps)?.revision).toBe(revision); + expect( + resolveSandboxRepository(fixture, undefined, { execFileSync: run, cwd: () => fixture }) + ).toBeUndefined(); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/cli/lib/sandbox-repo.ts b/packages/cli/src/cli/lib/sandbox-repo.ts new file mode 100644 index 0000000000..2425eb2a53 --- /dev/null +++ b/packages/cli/src/cli/lib/sandbox-repo.ts @@ -0,0 +1,236 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { realpathSync } from 'node:fs'; + +const REVISION_PATTERN = /^[0-9a-f]{40}$/; +const REPOSITORY_PART_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +export interface SandboxRepositorySelection { + repository: string; + repositoryName: string; + revision: string; + projectRoot: string; + workerCwd: string; +} + +export interface SandboxRepositoryDependencies { + execFileSync?: typeof execFileSync; + realpathSync?: typeof realpathSync; + cwd?: () => string; +} + +/** + * Resolve the public repository identity and exact HEAD for a sandbox launch. + * Only a GitHub-style owner/name and a 40-character commit are returned; local + * paths never cross the Cloud request boundary. + */ +export function resolveSandboxRepository( + projectRoot: string, + requestedCwd: string | undefined, + deps: SandboxRepositoryDependencies = {} +): SandboxRepositorySelection | undefined { + const run = deps.execFileSync ?? execFileSync; + const resolveRealpath = deps.realpathSync ?? realpathSync; + const callerCwd = deps.cwd?.() ?? process.cwd(); + const remoteCwd = + requestedCwd && /^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) + ? path.posix.normalize(requestedCwd) + : undefined; + if (remoteCwd && !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(remoteCwd)) { + throw new Error('--cwd escapes the remote sandbox checkout roots.'); + } + const invocationCwd = path.resolve(callerCwd, remoteCwd ? '.' : (requestedCwd ?? '.')); + let gitRoot: string; + try { + gitRoot = run('git', ['-C', invocationCwd, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, LC_ALL: 'C' }, + timeout: 10_000, + }) + .trim() + .replace(/[\\/]$/, ''); + } catch (error) { + const failure = error as { status?: number; stderr?: string | Buffer }; + if (failure.status === 128 && failure.stderr?.toString().includes('not a git repository')) { + // An explicitly selected Relay project can be outside the shell cwd. + // Prefer the actual invocation repository, then honor that project root + // only when Git confirms that the invocation itself is outside a repo. + if (!requestedCwd && path.resolve(projectRoot) !== invocationCwd) { + return resolveSandboxRepository(projectRoot, undefined, { ...deps, cwd: () => projectRoot }); + } + return undefined; + } + throw new Error( + 'Cannot inspect the local Git checkout. Verify Git is installed and this repository is accessible, then retry.' + ); + } + if (!gitRoot) throw new Error('Git did not report a repository root; repair the checkout before retrying.'); + const root = path.resolve(gitRoot); + // `git -C` follows symlinks while path.relative does not. Canonicalizing + // both ends prevents a symlinked package invocation from becoming an + // apparently outside checkout path (or from producing the wrong worker + // subdirectory). Tests with synthetic paths retain the lexical fallback. + const canonicalRoot = (() => { + try { + return resolveRealpath(root); + } catch { + return root; + } + })(); + const localCwd = (() => { + const lexical = invocationCwd; + try { + return resolveRealpath(lexical); + } catch { + return lexical; + } + })(); + + let remote: string; + let revision: string; + try { + remote = run('git', ['-C', root, 'remote', 'get-url', 'origin'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }).trim(); + } catch { + throw new Error('Sandbox checkout has no usable origin remote; configure origin before retrying.'); + } + try { + revision = run('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }) + .trim() + .toLowerCase(); + } catch { + throw new Error('Sandbox checkout has no committed HEAD; create or check out a commit before retrying.'); + } + const repository = parseRepository(remote); + if (!repository) { + throw new Error('Sandbox checkout origin must be a GitHub owner/name repository.'); + } + if (!REVISION_PATTERN.test(revision)) { + throw new Error( + 'Sandbox checkout HEAD is not a complete commit SHA; check out a committed revision first.' + ); + } + + let status: string; + try { + status = run('git', ['-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }); + } catch { + throw new Error('Cannot verify that the Git checkout is clean. Repair the checkout and retry.'); + } + const disallowed = status + .split('\0') + .filter(Boolean) + .filter((entry) => { + const code = entry.slice(0, 2); + const file = entry.slice(3); + return code !== '??' || !isGeneratedRelayMetadata(file); + }); + if (disallowed.length > 0) { + throw new Error( + `Sandbox requires a clean checkout. Commit or stash local changes before retrying; changed path: ${JSON.stringify(disallowed[0].slice(3))}` + ); + } + + let upstream: string | undefined; + try { + upstream = run('git', ['-C', root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }).trim(); + } catch { + upstream = undefined; + } + if (upstream?.startsWith('origin/')) { + try { + run('git', ['-C', root, 'merge-base', '--is-ancestor', 'HEAD', upstream], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }); + } catch { + throw new Error( + `Sandbox commit ${revision} is not pushed to ${upstream}. Push the exact commit before retrying.` + ); + } + } + + const relative = path.relative(canonicalRoot, localCwd); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error( + '--cwd is outside the checked-out repository; use a repository-relative cwd or an explicit remote sandbox path.' + ); + } + + const repositoryName = repository.slice(repository.indexOf('/') + 1); + const remoteRoot = `/srv/agent-workforce/${repositoryName}`; + const relativePosix = relative.split(path.sep).filter(Boolean).join('/'); + return { + repository, + repositoryName, + revision, + projectRoot: canonicalRoot, + workerCwd: remoteCwd ?? (relativePosix ? `${remoteRoot}/${relativePosix}` : remoteRoot), + }; +} + +function isGeneratedRelayMetadata(entry: string): boolean { + const normalized = entry.replaceAll('\\', '/'); + if (!normalized.startsWith('.agentworkforce/relay/')) return false; + const relative = normalized.slice('.agentworkforce/relay/'.length); + return ( + relative === 'workspace-key.json' || + relative === 'connection.json' || + relative === 'runtime.json' || + /^broker-[^/]+\.lock$/.test(relative) + ); +} + +function parseRepository(remote: string): string | undefined { + const value = remote.trim(); + let owner: string | undefined; + let name: string | undefined; + const scp = value.match(/^([^@/]+)@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/); + if (scp) { + if (scp[2].toLowerCase() !== 'github.com') return undefined; + owner = scp[3]; + name = scp[4]; + } else { + try { + const parsed = new URL(value); + if (!['https:', 'ssh:'].includes(parsed.protocol)) return undefined; + if (parsed.hostname.toLowerCase() !== 'github.com') return undefined; + if (parsed.password || (parsed.username && !(parsed.protocol === 'ssh:' && parsed.username === 'git'))) + return undefined; + if (parsed.search || parsed.hash || parsed.port) return undefined; + const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').split('/'); + if (parts.length !== 2) return undefined; + owner = parts[0]; + name = parts[1].replace(/\.git$/, ''); + } catch { + return undefined; + } + } + if ( + !owner || + !name || + !REPOSITORY_PART_PATTERN.test(owner) || + !REPOSITORY_PART_PATTERN.test(name) || + owner.includes('..') || + name.includes('..') + ) + return undefined; + return `${owner}/${name}`; +} diff --git a/packages/cli/src/cli/lib/sdk-client.test.ts b/packages/cli/src/cli/lib/sdk-client.test.ts index 6c1d639045..2d1aba4709 100644 --- a/packages/cli/src/cli/lib/sdk-client.test.ts +++ b/packages/cli/src/cli/lib/sdk-client.test.ts @@ -206,6 +206,7 @@ describe('sdk client option resolution', () => { relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_agent37', + relaycastApiKeyRef: expect.stringMatching(/^[0-9a-f]{64}$/), }); const replayOptions = { @@ -254,6 +255,7 @@ describe('sdk client option resolution', () => { relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_fresh_agent37', + relaycastApiKeyRef: expect.stringMatching(/^[0-9a-f]{64}$/), }); } ); @@ -282,6 +284,7 @@ describe('sdk client option resolution', () => { relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_store_agent37', + relaycastApiKeyRef: expect.stringMatching(/^[0-9a-f]{64}$/), }); }); @@ -354,7 +357,7 @@ describe('sdk client option resolution', () => { expect(readProjectWorkspaceSession(projectDataDir())?.relaycastApiKey).toBe('rk_live_newer_route'); }); - it('keeps legacy persisted targets usable when no separate Relaycast key exists', () => { + it('fails closed when an isolated target has no external Relaycast credential', () => { writeProjectWorkspaceKey(projectDataDir(), 'rk_live_legacy_agent37', { workspaceId: 'rw_abc', relaycastRoute: 'agent37-isolated', @@ -362,19 +365,55 @@ describe('sdk client option resolution', () => { }); const options = { env: { AGENT_RELAY_HOME: dir } }; - expect(resolveWorkspaceKey(options)).toBe('rk_live_legacy_agent37'); - expect(resolveBaseUrl(options)).toBe('https://agent37-cast.agentrelay.com'); + expect(() => resolveWorkspaceTransport(options)).toThrow(/credential is unavailable or mismatched/); }); - it('rejects a separate Relaycast key without a complete persisted route', () => { + it('fails closed when the persisted route credential reference is tampered', () => { + writeProjectWorkspaceKey(projectDataDir(), 'rk_live_canonical', { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_agent37', + }); + const file = path.join(projectDataDir(), 'workspace-key.json'); + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + parsed.relaycastApiKeyRef = 'wrong-scope'; + fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`); + + expect(() => resolveWorkspaceTransport({ env: { AGENT_RELAY_HOME: dir } })).toThrow( + /credential is unavailable or mismatched/ + ); + }); + + it('fails closed when the persisted credential workspace binding is tampered', () => { + writeProjectWorkspaceKey(projectDataDir(), 'rk_live_canonical', { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_agent37', + }); + const file = path.join(projectDataDir(), 'workspace-key.json'); + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + parsed.workspaceId = 'rw_other'; + fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`); + + expect(() => resolveWorkspaceTransport({ env: { AGENT_RELAY_HOME: dir } })).toThrow( + /credential is unavailable or mismatched/ + ); + }); + + it('drops a raw Relaycast key without a complete persisted route', () => { writeProjectWorkspaceKey(projectDataDir(), 'rk_live_canonical', { workspaceId: 'rw_abc', relaycastApiKey: 'rk_live_agent37', }); const options = { env: { AGENT_RELAY_HOME: dir } }; - expect(() => resolveWorkspaceKey(options)).toThrow(/persisted Relaycast workspace route is incomplete/); - expect(() => resolveBaseUrl(options)).toThrow(/persisted Relaycast workspace route is incomplete/); + expect(readProjectWorkspaceSession(projectDataDir())).toEqual({ + workspaceKey: 'rk_live_canonical', + workspaceId: 'rw_abc', + }); + expect(resolveWorkspaceKey(options)).toBe('rk_live_canonical'); }); it('rejects a persisted route that is not the exact server-owned origin', () => { diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index d6ee0a7777..a6004b6f56 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -1,3 +1,4 @@ +import { lstatSync } from 'node:fs'; import path from 'node:path'; import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk'; @@ -15,6 +16,8 @@ export interface SdkClientOptions { token?: string; baseUrl?: string; env?: NodeJS.ProcessEnv; + /** Explicit project root for nested invocations such as packages/web. */ + projectRoot?: string; /** Use the canonical gateway instead of a persisted server-selected route. */ ignorePersistedRelaycastTarget?: boolean; } @@ -40,12 +43,40 @@ export type WorkspaceTransport = { /** Resolve the selected key and any previously persisted Relay workspace identity. */ export function resolveWorkspaceSelection(options: SdkClientOptions = {}): WorkspaceSelection | undefined { + const projectRoot = options.projectRoot ?? inferGitProjectRoot(env(options)); return resolveCloudWorkspaceSelection({ workspaceKey: options.workspaceKey, env: env(options), + ...(projectRoot ? { projectRoot } : {}), }); } +/** Resolve the repository root for nested package invocations. */ +function inferGitProjectRoot(environment: NodeJS.ProcessEnv): string | undefined { + // The config package intentionally honours this override for worktrees and + // subprojects. Do not replace an operator-selected namespace with Git's + // answer. + if (environment.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim()) return undefined; + // Follow-up commands only need the project boundary, not Git execution. + // Recognize both ordinary repositories and worktree .git files so a missing + // Git binary or a Git subprocess failure cannot select a different workspace. + let directory = process.cwd(); + while (true) { + try { + const marker = lstatSync(path.join(directory, '.git')); + if (marker.isDirectory() || marker.isFile()) return directory; + throw new Error('Invalid Git project marker.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error('Cannot resolve the repository workspace; check access to its Git project marker.'); + } + } + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + /** * Resolve the workspace key and report which source it came from. Precedence: * explicit flag → `RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` env → the key the local @@ -77,6 +108,7 @@ function selectionForTransport(options: SdkClientOptions): WorkspaceSelection | relaycastRoute: _relaycastRoute, relaycastBaseUrl: _relaycastBaseUrl, relaycastApiKey: _relaycastApiKey, + relaycastApiKeyRef: _relaycastApiKeyRef, ...canonicalSelection } = selection; return canonicalSelection; @@ -125,8 +157,18 @@ export function resolveWorkspaceTransport(options: SdkClientOptions = {}): Works ); } const baseUrl = resolveBaseUrlForSelection(selection, options); + // Project-session loading already validates the reference against the + // project/workspace/route/base tuple. Never re-read a ref here: doing so + // would let a tampered ref bypass that binding and pair an unrelated key + // with this route. + const routeCredential = trimOrUndefined(selection.relaycastApiKey); + if (selection.relaycastRoute === 'agent37-isolated' && !routeCredential) { + throw new Error( + 'The persisted isolated Relaycast credential is unavailable or mismatched; rerun the sandbox command to mint a fresh route.' + ); + } return { - workspaceKey: trimOrUndefined(selection.relaycastApiKey) ?? selection.key, + workspaceKey: routeCredential ?? selection.key, ...(baseUrl ? { baseUrl } : {}), source: selection.source, }; diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 4807c2cd5c..a91289a37b 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -857,6 +857,10 @@ describe('Cloud fleet sandbox client', () => { relayWorkspaceId: 'rw_abc', relaycastTarget: RELAYCAST_TARGET, relayfileMounted: true, + repoRevisions: { + 'AgentWorkforce/factory': '0123456789abcdef0123456789abcdef01234567', + 'AgentWorkforce/relay': '89abcdef0123456789abcdef0123456789abcdef', + }, }, { status: 201 } ), @@ -868,6 +872,10 @@ describe('Cloud fleet sandbox client', () => { requiredCapability: 'spawn:codex', forceProvision: true, repos: ['AgentWorkforce/factory', 'AgentWorkforce/relay'], + repoRevisions: { + 'AgentWorkforce/factory': '0123456789abcdef0123456789abcdef01234567', + 'AgentWorkforce/relay': '89abcdef0123456789abcdef0123456789abcdef', + }, }); const ensureCall = mocks.authorizedApiFetch.mock.calls[1]; @@ -876,9 +884,52 @@ describe('Cloud fleet sandbox client', () => { requiredCapability: 'spawn:codex', forceProvision: true, repos: ['AgentWorkforce/factory', 'AgentWorkforce/relay'], + repoRevisions: { + 'AgentWorkforce/factory': '0123456789abcdef0123456789abcdef01234567', + 'AgentWorkforce/relay': '89abcdef0123456789abcdef0123456789abcdef', + }, }); }); + it.each([ + ['missing', undefined], + ['mismatched', { 'AgentWorkforce/cloud': 'fedcba9876543210fedcba9876543210fedcba98' }], + ] as const)('rejects a %s echoed repository revision', async (_label, echoed) => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + mocks.authorizedApiFetch + .mockResolvedValueOnce({ + response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json( + { + outcome: 'provisioned', + nodeId: 'node-1', + nodeName: SANDBOX_NAME, + sandboxId: SANDBOX_ID, + providerSandboxId: 'provider-sandbox-1', + relayWorkspaceId: 'rw_abc', + relaycastTarget: RELAYCAST_TARGET, + relayfileMounted: true, + ...(echoed === undefined ? {} : { repoRevisions: echoed }), + }, + { status: 201 } + ), + auth, + }); + + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos: ['AgentWorkforce/cloud'], + repoRevisions: { 'AgentWorkforce/cloud': revision }, + forceProvision: true, + }) + ).rejects.toThrow(/did not echo the requested repository revisions/); + }); + it('forwards bounded Relayfile mount paths into the ensure request body', async () => { mocks.authorizedApiFetch .mockResolvedValueOnce({ diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 6b58151ed8..be440acc48 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -108,6 +108,8 @@ export type EnsureCloudFleetSandboxInput = { * PR #3212 implements the ensure-side; this helper just plumbs it through. */ repos?: readonly string[]; + /** Exact lowercase HEAD attestation expected for each requested repository. */ + repoRevisions?: Readonly>; }; export type CloudFleetSandboxWorkloadProfile = @@ -137,6 +139,8 @@ type CloudFleetSandboxReadyBase = { relayfileMounted: boolean; relayfileMountPath?: string; providerId?: CloudFleetSandboxProviderId; + /** Repository HEADs verified by Cloud for this sandbox. */ + repoRevisions?: Readonly>; }; /** Daytona responses always carry the independently attested provider UUID. */ @@ -160,6 +164,8 @@ export type CloudFleetSandboxReused = { providerId?: CloudFleetSandboxProviderId; /** Closed server-owned Relaycast contract when Cloud returned one. Required for Agent37. */ relaycastTarget?: CloudFleetRelaycastTarget; + /** Repository HEADs verified by Cloud for this sandbox. */ + repoRevisions?: Readonly>; }; type CloudFleetSandboxProvisioningTimeoutBase = { @@ -342,6 +348,68 @@ function requiredString(payload: JsonRecord, key: string, context: string): stri return value; } +const REPOSITORY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/; +const REPOSITORY_REVISION_PATTERN = /^[0-9a-f]{40}$/; + +function validateRepoRevisions( + repos: readonly string[] | undefined, + repoRevisions: Readonly> | undefined +): Record | undefined { + if (repoRevisions === undefined) return undefined; + const entries = Object.entries(repoRevisions); + if (entries.length === 0) return undefined; + const allowedRepos = new Set(repos ?? []); + for (const [repo, revision] of entries) { + if (!REPOSITORY_KEY_PATTERN.test(repo) || repo.includes('..')) { + throw new Error(`Cloud fleet sandbox repository key '${repo}' must use owner/name form.`); + } + if (!allowedRepos.has(repo)) { + throw new Error(`Cloud fleet sandbox repository revision '${repo}' is not present in repos.`); + } + if (!REPOSITORY_REVISION_PATTERN.test(revision)) { + throw new Error(`Cloud fleet sandbox revision for '${repo}' must be exactly 40 lowercase hexadecimal characters.`); + } + } + return Object.fromEntries(entries); +} + +function readRepoRevisions(payload: JsonRecord): Record | undefined { + const value = payload.repoRevisions; + if (value === undefined) return undefined; + if (!isObject(value)) throw new Error('Cloud fleet sandbox response has invalid repoRevisions.'); + const revisions: Record = {}; + for (const [repo, revision] of Object.entries(value)) { + if ( + !REPOSITORY_KEY_PATTERN.test(repo) || + repo.includes('..') || + typeof revision !== 'string' || + !REPOSITORY_REVISION_PATTERN.test(revision) + ) { + throw new Error('Cloud fleet sandbox response has invalid repoRevisions.'); + } + revisions[repo] = revision; + } + return revisions; +} + +function assertRepoRevisions( + payload: JsonRecord, + expected: Readonly> | undefined +): Record | undefined { + const actual = readRepoRevisions(payload); + if (expected === undefined) return actual; + if ( + actual === undefined || + Object.keys(actual).length !== Object.keys(expected).length || + Object.entries(expected).some(([repo, revision]) => actual[repo] !== revision) + ) { + throw new Error( + 'Cloud did not echo the requested repository revisions; update Cloud before using --sandbox with a pinned checkout.' + ); + } + return actual; +} + function validateSandboxIdentity(input: EnsureCloudFleetSandboxInput): { sandboxId?: string; name?: string; @@ -476,7 +544,8 @@ function normalizeEnsureResult( cloudWorkspaceId: string, expectedSandboxId?: string, expectedNodeName?: string, - requestedProviderId?: CloudFleetSandboxProviderId + requestedProviderId?: CloudFleetSandboxProviderId, + expectedRepoRevisions?: Readonly> ): EnsureCloudFleetSandboxResult { if (!isObject(payload)) throw new Error('Cloud fleet sandbox response was not valid JSON.'); // A caller-declared identity is the cleanup authority. Validate it before @@ -508,6 +577,7 @@ function normalizeEnsureResult( const sandboxId = requiredString(payload, 'sandboxId', 'Cloud fleet sandbox'); const providerSandboxId = normalizeProviderSandboxId(payload, providerId); const relayWorkspaceId = requiredString(payload, 'relayWorkspaceId', 'Cloud fleet sandbox'); + const repoRevisions = assertRepoRevisions(payload, expectedRepoRevisions); const relaycastTarget = payload.relaycastTarget === undefined ? undefined : normalizeRelaycastTarget(payload.relaycastTarget); if (relaycastTarget !== undefined && relaycastTarget.workspaceId !== relayWorkspaceId) { @@ -525,6 +595,7 @@ function normalizeEnsureResult( ...(relaycastTarget === undefined ? {} : { relaycastTarget }), relayfileMounted: payload.relayfileMounted, ...(providerId === undefined ? {} : { providerId }), + ...(repoRevisions === undefined ? {} : { repoRevisions }), ...(readString(payload, 'relayfileMountPath') ? { relayfileMountPath: readString(payload, 'relayfileMountPath') } : {}), @@ -535,6 +606,7 @@ function normalizeEnsureResult( const relaycastTarget = payload.relaycastTarget === undefined ? undefined : normalizeRelaycastTarget(payload.relaycastTarget); assertProviderRelaycastTarget(providerId, relaycastTarget); + const repoRevisions = assertRepoRevisions(payload, expectedRepoRevisions); return { outcome, cloudWorkspaceId, @@ -545,6 +617,7 @@ function normalizeEnsureResult( maxAgents: readNumber(payload, 'maxAgents') ?? null, ...(providerId === undefined ? {} : { providerId }), ...(relaycastTarget === undefined ? {} : { relaycastTarget }), + ...(repoRevisions === undefined ? {} : { repoRevisions }), }; } @@ -586,6 +659,7 @@ export async function ensureCloudFleetSandbox( if (input.relayfilePaths !== undefined && input.relayfilePaths.length === 0) { throw new Error('At least one Relayfile subtree path is required when relayfilePaths is provided.'); } + const repoRevisions = validateRepoRevisions(input.repos, input.repoRevisions); const session = await ensureCloudSession({ apiUrl: options.apiUrl || defaultApiUrl(), @@ -615,6 +689,7 @@ export async function ensureCloudFleetSandbox( ...(input.workloadProfile !== undefined ? { workloadProfile: input.workloadProfile } : {}), ...(input.waitTimeoutMs !== undefined ? { waitTimeoutMs: input.waitTimeoutMs } : {}), ...(input.repos !== undefined && input.repos.length > 0 ? { repos: [...input.repos] } : {}), + ...(repoRevisions === undefined ? {} : { repoRevisions }), }), }, { interactive: false } @@ -688,7 +763,8 @@ export async function ensureCloudFleetSandbox( resolved.cloudWorkspaceId, sandboxIdentity.sandboxId, sandboxIdentity.name, - input.providerId + input.providerId, + repoRevisions ); } catch (error) { const confirmedProvisioned = confirmsProvisionedSandboxIdentity( diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index a110409687..b1b5ae45a0 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -148,6 +148,11 @@ export { validateWorkspaceName, workspaceStorePath, writeWorkspaceStore, + readRelaycastCredential, + relaycastCredentialRef, + relaycastCredentialStorePath, + writeRelaycastCredential, + type RelaycastCredential, type WorkspaceStore, } from './workspace-store.js'; diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index ea894f1334..d23148df08 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -10,6 +10,7 @@ import { readProjectWorkspaceSession, resolveWorkspaceKeyWithSource, resolveWorkspaceSelection, + writeProjectWorkspaceTargetIfSelectionCurrent, writeProjectWorkspaceKey, } from './project-workspace-key.js'; import { setWorkspaceKey } from './workspace-store.js'; @@ -36,6 +37,81 @@ describe('project workspace key resolution', () => { expect(fs.statSync(projectWorkspaceKeyPath(dataDir)).mode & 0o777).toBe(0o600); }); + it('keeps a temporary Relaycast key out of the project file', () => { + const previousHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = home; + try { + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { workspaceId: 'rw_abc' }); + const selection = resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }); + expect( + writeProjectWorkspaceTargetIfSelectionCurrent(dataDir, selection!, { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_temporary', + }) + ).toBe(true); + const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8'); + expect(raw).not.toContain('rk_live_temporary'); + expect(readProjectWorkspaceSession(dataDir)?.relaycastApiKey).toBe('rk_live_temporary'); + } finally { + if (previousHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previousHome; + } + }); + + it('fails closed when a project reference is paired with another route scope', () => { + const previousHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = home; + try { + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { workspaceId: 'rw_abc' }); + const selection = resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }); + expect( + writeProjectWorkspaceTargetIfSelectionCurrent(dataDir, selection!, { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_temporary', + }) + ).toBe(true); + const file = projectWorkspaceKeyPath(dataDir); + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record; + parsed.relaycastApiKeyRef = 'wrong-scope'; + fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`); + expect(readProjectWorkspaceSession(dataDir)?.relaycastApiKey).toBeUndefined(); + } finally { + if (previousHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previousHome; + } + }); + + it('rotates a bound route credential without retaining the old key in project metadata', () => { + const previousHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = home; + try { + const target = { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated' as const, + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + }; + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { + ...target, + relaycastApiKey: 'rk_live_old', + }); + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { + ...target, + relaycastApiKey: 'rk_live_new', + }); + const raw = fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8'); + expect(raw).not.toContain('rk_live_old'); + expect(raw).not.toContain('rk_live_new'); + expect(readProjectWorkspaceSession(dataDir)?.relaycastApiKey).toBe('rk_live_new'); + } finally { + if (previousHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previousHome; + } + }); + it('does not remove a replacement lock when the original holder finishes', () => { const lockDir = `${projectWorkspaceKeyPath(dataDir)}.lock`; let replaced = false; @@ -185,27 +261,35 @@ describe('workspace precedence ladder diagnostics', () => { }); it('carries a persisted Relaycast target when an explicit key matches the project pin', () => { - writeProjectWorkspaceKey(dataDir, 'rk_canonical', { - workspaceId: 'rw_pinned', - relaycastRoute: 'agent37-isolated', - relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', - relaycastApiKey: 'rk_agent37', - }); + const previousHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = home; + try { + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { + workspaceId: 'rw_pinned', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_agent37', + }); - expect( - resolveWorkspaceSelection({ - workspaceKey: 'rk_canonical', - projectDataDir: dataDir, - env: { AGENT_RELAY_HOME: home }, - }) - ).toMatchObject({ - key: 'rk_canonical', - source: 'flag', - workspaceId: 'rw_pinned', - relaycastRoute: 'agent37-isolated', - relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', - relaycastApiKey: 'rk_agent37', - }); + expect( + resolveWorkspaceSelection({ + workspaceKey: 'rk_canonical', + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home }, + }) + ).toMatchObject({ + key: 'rk_canonical', + source: 'flag', + workspaceId: 'rw_pinned', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_agent37', + relaycastApiKeyRef: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + } finally { + if (previousHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previousHome; + } }); it.each(['flag', 'env'] as const)( diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 6dbee8fe0c..3922337e6a 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -4,7 +4,13 @@ import path from 'node:path'; import { getProjectPaths } from '@agent-relay/config'; -import { readWorkspaceStore, workspaceStorePath } from './workspace-store.js'; +import { + readRelaycastCredential, + readWorkspaceStore, + relaycastCredentialRef, + writeRelaycastCredential, + workspaceStorePath, +} from './workspace-store.js'; const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; const PROJECT_WORKSPACE_LOCK_SUFFIX = '.lock'; @@ -38,6 +44,8 @@ export interface ProjectWorkspaceSession { relaycastBaseUrl?: string; /** Route-scoped transport credential; the canonical Cloud workspace key remains `workspaceKey`. */ relaycastApiKey?: string; + /** Reference to a machine-local route credential; never contains key material. */ + relaycastApiKeyRef?: string; } export type ProjectWorkspaceSessionMetadata = Omit; @@ -75,6 +83,7 @@ export interface WorkspaceSelection { relaycastRoute?: 'canonical' | 'agent37-isolated'; relaycastBaseUrl?: string; relaycastApiKey?: string; + relaycastApiKeyRef?: string; /** Project session directory that can durably carry a server-selected target. */ projectDataDir?: string; /** Whether that project session existed when this selection was captured. */ @@ -111,7 +120,24 @@ export function readProjectWorkspaceSession( ? parsed.relaycastRoute : undefined; const relaycastBaseUrl = trimOrUndefined(parsed.relaycastBaseUrl); - const relaycastApiKey = trimOrUndefined(parsed.relaycastApiKey); + const relaycastApiKeyRef = trimOrUndefined(parsed.relaycastApiKeyRef); + const legacyApiKey = trimOrUndefined(parsed.relaycastApiKey); + const expectedRef = + workspaceId && relaycastRoute && relaycastBaseUrl + ? relaycastCredentialRef(dataDir, workspaceId, relaycastRoute, relaycastBaseUrl) + : undefined; + const storedCredential = + relaycastApiKeyRef && expectedRef === relaycastApiKeyRef + ? readRelaycastCredential(relaycastApiKeyRef) + : undefined; + const relaycastApiKey = relaycastApiKeyRef + ? storedCredential && + storedCredential.workspaceId === workspaceId && + storedCredential.route === relaycastRoute && + storedCredential.baseUrl === relaycastBaseUrl + ? storedCredential.apiKey + : undefined + : legacyApiKey; return { workspaceKey, ...(enrolledNodeId ? { enrolledNodeId } : {}), @@ -119,6 +145,7 @@ export function readProjectWorkspaceSession( ...(relaycastRoute ? { relaycastRoute } : {}), ...(relaycastBaseUrl ? { relaycastBaseUrl } : {}), ...(relaycastApiKey ? { relaycastApiKey } : {}), + ...(relaycastApiKeyRef ? { relaycastApiKeyRef } : {}), }; } catch { return undefined; @@ -138,6 +165,7 @@ export function writeProjectWorkspaceKey( relaycastRoute?: 'canonical' | 'agent37-isolated'; relaycastBaseUrl?: string; relaycastApiKey?: string; + relaycastApiKeyRef?: string; } = {} ): void { const key = trimOrUndefined(workspaceKey); @@ -157,6 +185,16 @@ function writeProjectWorkspaceKeyUnlocked( const relaycastRoute = options.relaycastRoute; const relaycastBaseUrl = trimOrUndefined(options.relaycastBaseUrl); const relaycastApiKey = trimOrUndefined(options.relaycastApiKey); + let relaycastApiKeyRef = trimOrUndefined(options.relaycastApiKeyRef); + if (relaycastApiKey && workspaceId && relaycastRoute && relaycastBaseUrl) { + relaycastApiKeyRef = relaycastCredentialRef(dataDir, workspaceId, relaycastRoute, relaycastBaseUrl); + writeRelaycastCredential(relaycastApiKeyRef, { + workspaceId, + route: relaycastRoute, + baseUrl: relaycastBaseUrl, + apiKey: relaycastApiKey, + }); + } fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); // Worker threads share a PID, so include a per-write nonce as well as the PID. @@ -168,7 +206,9 @@ function writeProjectWorkspaceKeyUnlocked( ...(workspaceId ? { workspaceId } : {}), ...(relaycastRoute ? { relaycastRoute } : {}), ...(relaycastBaseUrl ? { relaycastBaseUrl } : {}), - ...(relaycastApiKey ? { relaycastApiKey } : {}), + // Route credentials are always externalized above. Never serialize a + // raw Relaycast key into repository metadata, even for incomplete input. + ...(relaycastApiKeyRef ? { relaycastApiKeyRef } : {}), } satisfies ProjectWorkspaceSession, null, 2 @@ -353,19 +393,32 @@ export function writeProjectWorkspaceTargetIfSelectionCurrent( current.workspaceId !== selection.workspaceId || current.relaycastRoute !== selection.relaycastRoute || current.relaycastBaseUrl !== selection.relaycastBaseUrl || - current.relaycastApiKey !== selection.relaycastApiKey) + current.relaycastApiKey !== selection.relaycastApiKey || + current.relaycastApiKeyRef !== selection.relaycastApiKeyRef) ) { return false; } if (!current && (selection.projectSessionPresent === true || selection.source === 'project')) { return false; } + const credentialRef = relaycastCredentialRef( + dataDir, + target.workspaceId, + target.relaycastRoute, + target.relaycastBaseUrl + ); + writeRelaycastCredential(credentialRef, { + workspaceId: target.workspaceId, + route: target.relaycastRoute, + baseUrl: target.relaycastBaseUrl, + apiKey: target.relaycastApiKey, + }); writeProjectWorkspaceKeyUnlocked(dataDir, selection.key, { ...(current?.enrolledNodeId ? { enrolledNodeId: current.enrolledNodeId } : {}), workspaceId: target.workspaceId, relaycastRoute: target.relaycastRoute, relaycastBaseUrl: target.relaycastBaseUrl, - relaycastApiKey: target.relaycastApiKey, + relaycastApiKeyRef: credentialRef, }); return true; }); @@ -399,6 +452,7 @@ export function writeProjectWorkspaceKeyPreservingSession( ...(existing?.relaycastRoute ? { relaycastRoute: existing.relaycastRoute } : {}), ...(existing?.relaycastBaseUrl ? { relaycastBaseUrl: existing.relaycastBaseUrl } : {}), ...(existing?.relaycastApiKey ? { relaycastApiKey: existing.relaycastApiKey } : {}), + ...(existing?.relaycastApiKeyRef ? { relaycastApiKeyRef: existing.relaycastApiKeyRef } : {}), } : {}; @@ -415,6 +469,9 @@ export function writeProjectWorkspaceKeyPreservingSession( ...(trimOrUndefined(options.relaycastApiKey) ? { relaycastApiKey: trimOrUndefined(options.relaycastApiKey) } : {}), + ...(trimOrUndefined(options.relaycastApiKeyRef) + ? { relaycastApiKeyRef: trimOrUndefined(options.relaycastApiKeyRef) } + : {}), }); }); } @@ -459,6 +516,9 @@ export function resolveWorkspaceSelection( ...(project?.workspaceKey === flag && project.relaycastApiKey ? { relaycastApiKey: project.relaycastApiKey } : {}), + ...(project?.workspaceKey === flag && project.relaycastApiKeyRef + ? { relaycastApiKeyRef: project.relaycastApiKeyRef } + : {}), ...((!project || project.workspaceKey === flag) && dataDir ? { projectDataDir: dataDir, projectSessionPresent: project !== undefined } : {}), @@ -484,6 +544,9 @@ export function resolveWorkspaceSelection( ...(project?.workspaceKey === envKey && project.relaycastApiKey ? { relaycastApiKey: project.relaycastApiKey } : {}), + ...(project?.workspaceKey === envKey && project.relaycastApiKeyRef + ? { relaycastApiKeyRef: project.relaycastApiKeyRef } + : {}), ...((!project || project.workspaceKey === envKey) && dataDir ? { projectDataDir: dataDir, projectSessionPresent: project !== undefined } : {}), @@ -500,6 +563,7 @@ export function resolveWorkspaceSelection( ...(project.relaycastRoute ? { relaycastRoute: project.relaycastRoute } : {}), ...(project.relaycastBaseUrl ? { relaycastBaseUrl: project.relaycastBaseUrl } : {}), ...(project.relaycastApiKey ? { relaycastApiKey: project.relaycastApiKey } : {}), + ...(project.relaycastApiKeyRef ? { relaycastApiKeyRef: project.relaycastApiKeyRef } : {}), ...(dataDir ? { projectDataDir: dataDir, projectSessionPresent: true } : {}), }; } diff --git a/packages/cloud/src/workspace-key.ts b/packages/cloud/src/workspace-key.ts index 79d0761e11..f252a67672 100644 --- a/packages/cloud/src/workspace-key.ts +++ b/packages/cloud/src/workspace-key.ts @@ -16,3 +16,9 @@ export { type WorkspaceKeySource, type WorkspaceSelection, } from './project-workspace-key.js'; +export { + readRelaycastCredential, + relaycastCredentialRef, + writeRelaycastCredential, + type RelaycastCredential, +} from './workspace-store.js'; diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 5e9c45dc0c..9ca963428c 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -1,15 +1,22 @@ import fs from 'node:fs'; +import { spawn } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; +import ts from 'typescript'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + type RelaycastCredential, readWorkspaceStore, + readRelaycastCredential, + relaycastCredentialRef, + relaycastCredentialStorePath, resolveActiveWorkspaceKey, setActiveWorkspace, setWorkspaceKey, workspaceStorePath, + writeRelaycastCredential, } from './workspace-store.js'; let dir: string; @@ -62,6 +69,126 @@ describe('workspace store', () => { expect(mode).toBe(0o600); }); + it('stores route credentials outside the project with a scoped reference', () => { + const ref = relaycastCredentialRef( + '/checkout/.agentworkforce/relay', + 'rw_abc', + 'agent37-isolated', + 'https://agent37-cast.agentrelay.com' + ); + writeRelaycastCredential(ref, { + workspaceId: 'rw_abc', + route: 'agent37-isolated', + baseUrl: 'https://agent37-cast.agentrelay.com', + apiKey: 'rk_live_route', + }); + expect(readRelaycastCredential(ref)).toMatchObject({ workspaceId: 'rw_abc', apiKey: 'rk_live_route' }); + expect(fs.statSync(relaycastCredentialStorePath()).mode & 0o777).toBe(0o600); + }); + + it('preserves concurrent credential writes and never exposes a partial JSON read', async () => { + writeRelaycastCredential('sentinel', { + workspaceId: 'rw_sentinel', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_sentinel', + }); + const source = fs.readFileSync(new URL('./workspace-store.ts', import.meta.url), 'utf8'); + const worker = path.join(dir, 'workspace-store-worker.mjs'); + fs.writeFileSync( + worker, + `${ + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, + }).outputText + }\n${[ + 'const mode = process.argv[2];', + 'const id = process.argv[3] ?? "reader";', + 'if (mode === "reader") {', + ' for (let index = 0; index < 500; index += 1) {', + ' if (!readRelaycastCredential("sentinel")) process.exit(3);', + ' }', + ' process.exit(0);', + '}', + 'for (let index = 0; index < 12; index += 1) {', + ' writeRelaycastCredential(`${id}-${index}`, { workspaceId: `${id}-${index}`, route: "canonical", baseUrl: "https://relay.example", apiKey: `rk_live_${id}_${index}` });', + '}', + 'process.exit(0);', + ].join('\n')}`, + { mode: 0o600 } + ); + + const run = (mode: 'reader' | 'writer', id: string): Promise => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [worker, mode, id], { + env: { AGENT_RELAY_HOME: dir, NODE_ENV: 'test' }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`credential ${mode} worker ${id} exited ${code}: ${stderr}`)); + }); + }); + + await Promise.all([ + ...Array.from({ length: 8 }, (_, index) => run('writer', `writer-${index}`)), + run('reader', 'reader-a'), + run('reader', 'reader-b'), + ]); + + const stored = JSON.parse(fs.readFileSync(relaycastCredentialStorePath(), 'utf8')) as { + credentials: Record; + }; + expect(Object.keys(stored.credentials)).toHaveLength(1 + 8 * 12); + expect(stored.credentials.sentinel.apiKey).toBe('rk_live_sentinel'); + expect(readRelaycastCredential('writer-7-11')?.apiKey).toBe('rk_live_writer-7_11'); + }, 30_000); + + it('reclaims a stale credential lock left by an exited writer', () => { + const lock = `${relaycastCredentialStorePath()}.lock`; + const token = 'dead-owner-token'; + fs.mkdirSync(lock, { recursive: true, mode: 0o700 }); + fs.writeFileSync(path.join(lock, token), JSON.stringify({ version: 1, pid: 999_999_999, token }), { + mode: 0o600, + flag: 'wx', + }); + const staleAt = new Date(Date.now() - 60_000); + fs.utimesSync(lock, staleAt, staleAt); + + writeRelaycastCredential('after-stale-lock', { + workspaceId: 'rw_after_stale', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_after_stale', + }); + + expect(readRelaycastCredential('after-stale-lock')?.apiKey).toBe('rk_live_after_stale'); + expect(fs.existsSync(lock)).toBe(false); + }); + + it('scopes route references to the selected endpoint', () => { + expect( + relaycastCredentialRef( + '/checkout/.agentworkforce/relay', + 'rw_abc', + 'agent37-isolated', + 'https://one.example' + ) + ).not.toBe( + relaycastCredentialRef( + '/checkout/.agentworkforce/relay', + 'rw_abc', + 'agent37-isolated', + 'https://two.example' + ) + ); + }); + it('rejects reserved object-property workspace names', () => { expect(() => setWorkspaceKey('__proto__', 'rk_bad')).toThrow(/Invalid workspace name/); expect(({} as Record).polluted).toBeUndefined(); diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 11ae269738..59037e560b 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; @@ -14,6 +15,13 @@ export interface WorkspaceStore { workspaces: Record; } +export interface RelaycastCredential { + workspaceId: string; + route: 'canonical' | 'agent37-isolated'; + baseUrl: string; + apiKey: string; +} + const RESERVED_WORKSPACE_NAMES = new Set(['__proto__', 'prototype', 'constructor']); export function workspaceStorePath(env: NodeJS.ProcessEnv = process.env): string { @@ -21,6 +29,219 @@ export function workspaceStorePath(env: NodeJS.ProcessEnv = process.env): string return path.join(dir, 'workspaces.json'); } +export function relaycastCredentialStorePath(env: NodeJS.ProcessEnv = process.env): string { + const dir = env.AGENT_RELAY_HOME ?? path.join(os.homedir(), '.agentworkforce/relay'); + return path.join(dir, 'relaycast-credentials.json'); +} + +export function relaycastCredentialRef( + projectDataDir: string, + workspaceId: string, + route: string, + baseUrl?: string +): string { + let endpoint = baseUrl?.trim() ?? ''; + try { + endpoint = new URL(endpoint).origin; + } catch { + // The caller performs route-origin validation; retain a deterministic + // value here for malformed legacy metadata and let that validation fail + // closed at read/transport time. + } + return createHash('sha256') + .update(`${path.resolve(projectDataDir)}\0${workspaceId}\0${route}\0${endpoint}`) + .digest('hex'); +} + +export function readRelaycastCredential( + ref: string, + env: NodeJS.ProcessEnv = process.env +): RelaycastCredential | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(relaycastCredentialStorePath(env), 'utf8')) as { + credentials?: Record; + }; + const value = parsed.credentials?.[ref]; + if (!value || typeof value.apiKey !== 'string') return undefined; + return value; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return undefined; + return undefined; + } +} + +export function writeRelaycastCredential( + ref: string, + credential: RelaycastCredential, + env: NodeJS.ProcessEnv = process.env +): void { + const file = relaycastCredentialStorePath(env); + withRelaycastCredentialLock(file, () => { + let store: { credentials: Record } = { credentials: {} }; + try { + store = JSON.parse(fs.readFileSync(file, 'utf8')) as typeof store; + } catch (error) { + if (!(isNodeError(error) && error.code === 'ENOENT')) throw error; + } + store.credentials ??= {}; + store.credentials[ref] = credential; + writeRelaycastCredentialAtomically(file, store); + }); +} + +const RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS = 10_000; +const RELAYCAST_CREDENTIAL_LOCK_STALE_MS = 30_000; +const RELAYCAST_CREDENTIAL_LOCK_RETRY_MS = 10; +const RELAYCAST_CREDENTIAL_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4)); +const RELAYCAST_CREDENTIAL_LOCK_OWNER_VERSION = 1; + +interface RelaycastCredentialLockOwner { + version: number; + pid: number; + token: string; +} + +function withRelaycastCredentialLock(file: string, fn: () => T): T { + const directory = path.dirname(file); + const lock = `${file}.lock`; + const ownerToken = randomUUID(); + const ownerPath = path.join(lock, ownerToken); + const startedAt = Date.now(); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + while (true) { + try { + fs.mkdirSync(lock, { mode: 0o700 }); + try { + fs.writeFileSync( + ownerPath, + JSON.stringify({ + version: RELAYCAST_CREDENTIAL_LOCK_OWNER_VERSION, + pid: process.pid, + token: ownerToken, + }), + { mode: 0o600, flag: 'wx' } + ); + } catch (error) { + fs.rmSync(ownerPath, { force: true }); + try { + fs.rmdirSync(lock); + } catch (cleanupError) { + if (!(isNodeError(cleanupError) && cleanupError.code === 'ENOENT')) throw cleanupError; + } + throw error; + } + break; + } catch (error) { + if (!(isNodeError(error) && error.code === 'EEXIST')) throw error; + } + try { + if (Date.now() - fs.statSync(lock).mtimeMs >= RELAYCAST_CREDENTIAL_LOCK_STALE_MS) { + const observedLock = inspectRelaycastCredentialLock(lock); + if (!observedLock.ownerIsAlive) { + for (const entry of observedLock.entries) { + fs.rmSync(path.join(lock, entry), { force: true }); + } + try { + fs.rmdirSync(lock); + } catch (error) { + if (isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY')) { + continue; + } + throw error; + } + continue; + } + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') continue; + throw error; + } + if (Date.now() - startedAt >= RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS) { + throw new Error('Timed out waiting for the Relaycast credential store lock.'); + } + Atomics.wait(RELAYCAST_CREDENTIAL_LOCK_WAIT, 0, 0, RELAYCAST_CREDENTIAL_LOCK_RETRY_MS); + } + try { + return fn(); + } finally { + if (fs.existsSync(ownerPath)) { + fs.rmSync(ownerPath, { force: true }); + try { + fs.rmdirSync(lock); + } catch (error) { + if (isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY')) { + // Another writer may have replaced the lock after our marker was + // removed. Leave that replacement untouched. + } else { + throw error; + } + } + } + } +} + +function inspectRelaycastCredentialLock(lock: string): { + entries: string[]; + ownerIsAlive: boolean; +} { + const entries = fs.readdirSync(lock); + let sawLiveOwner = false; + for (const entry of entries) { + let owner: Partial; + try { + owner = JSON.parse( + fs.readFileSync(path.join(lock, entry), 'utf8') + ) as Partial; + } catch { + continue; + } + const pid = owner.pid; + if ( + owner.version !== RELAYCAST_CREDENTIAL_LOCK_OWNER_VERSION || + typeof pid !== 'number' || + !Number.isInteger(pid) || + pid <= 0 || + typeof owner.token !== 'string' || + owner.token !== entry + ) { + continue; + } + try { + process.kill(pid, 0); + sawLiveOwner = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') sawLiveOwner = true; + } + } + return { entries, ownerIsAlive: sawLiveOwner }; +} + +function writeRelaycastCredentialAtomically( + file: string, + store: { credentials: Record } +): void { + const temporary = `${file}.tmp.${process.pid}.${randomUUID()}`; + let descriptor: number | undefined; + try { + descriptor = fs.openSync(temporary, 'wx', 0o600); + fs.writeSync(descriptor, `${JSON.stringify(store, null, 2)}\n`); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.chmodSync(temporary, 0o600); + fs.renameSync(temporary, file); + fs.chmodSync(file, 0o600); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + try { + fs.unlinkSync(temporary); + } catch (cleanupError) { + if (!(isNodeError(cleanupError) && cleanupError.code === 'ENOENT')) throw cleanupError; + } + throw error; + } +} + function isNodeError(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && 'code' in err; } From 0acb66ae76812c563c21f80f18490fc7510f5128 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 12 Sep 2026 21:10:47 +0000 Subject: [PATCH 02/41] style: auto-format with Prettier Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/commands/local-agent.ts | 13 ++----------- packages/cloud/src/fleet-sandbox.ts | 4 +++- packages/cloud/src/project-workspace-key.test.ts | 10 ++++++++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index f8398024ee..d67643a0a7 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -623,12 +623,7 @@ async function withDeliveryModeClient( ): Promise { let node = typeof opts.node === 'string' && opts.node.trim() ? opts.node.trim() : undefined; let targetBaseUrl: string | undefined; - if ( - !node && - opts.brokerUrl === undefined && - opts.apiKey === undefined && - opts.stateDir === undefined - ) { + if (!node && opts.brokerUrl === undefined && opts.apiKey === undefined && opts.stateDir === undefined) { const fleetTarget = await deps.resolveFleetAttachTarget(name); if (fleetTarget.error) { deps.error(`Error: ${fleetTarget.error}`); @@ -993,11 +988,7 @@ export function registerLocalAgentCommands( // A sandbox worker has no local broker. Resolve a unique live fleet // placement before falling back to the local connection contract so a // flag-free attach follows the worker automatically. - if ( - options.brokerUrl === undefined && - options.apiKey === undefined && - options.stateDir === undefined - ) { + if (options.brokerUrl === undefined && options.apiKey === undefined && options.stateDir === undefined) { const fleetTarget = await deps.resolveFleetAttachTarget(name); if (fleetTarget.error) { deps.error(`Error: ${fleetTarget.error}`); diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index be440acc48..6b5420fdab 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -367,7 +367,9 @@ function validateRepoRevisions( throw new Error(`Cloud fleet sandbox repository revision '${repo}' is not present in repos.`); } if (!REPOSITORY_REVISION_PATTERN.test(revision)) { - throw new Error(`Cloud fleet sandbox revision for '${repo}' must be exactly 40 lowercase hexadecimal characters.`); + throw new Error( + `Cloud fleet sandbox revision for '${repo}' must be exactly 40 lowercase hexadecimal characters.` + ); } } return Object.fromEntries(entries); diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index d23148df08..ffb1b339fc 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -42,7 +42,10 @@ describe('project workspace key resolution', () => { process.env.AGENT_RELAY_HOME = home; try { writeProjectWorkspaceKey(dataDir, 'rk_canonical', { workspaceId: 'rw_abc' }); - const selection = resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }); + const selection = resolveWorkspaceSelection({ + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home }, + }); expect( writeProjectWorkspaceTargetIfSelectionCurrent(dataDir, selection!, { workspaceId: 'rw_abc', @@ -65,7 +68,10 @@ describe('project workspace key resolution', () => { process.env.AGENT_RELAY_HOME = home; try { writeProjectWorkspaceKey(dataDir, 'rk_canonical', { workspaceId: 'rw_abc' }); - const selection = resolveWorkspaceSelection({ projectDataDir: dataDir, env: { AGENT_RELAY_HOME: home } }); + const selection = resolveWorkspaceSelection({ + projectDataDir: dataDir, + env: { AGENT_RELAY_HOME: home }, + }); expect( writeProjectWorkspaceTargetIfSelectionCurrent(dataDir, selection!, { workspaceId: 'rw_abc', From 5d01a9b98fd6c7b912ce95d8e1e3f7b17018cb0f Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 12 Sep 2026 23:48:33 +0200 Subject: [PATCH 03/41] fix(sandbox): align project routing and harden persisted credentials Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 4 +- packages/cli/README.md | 10 +- packages/cli/src/cli/commands/fleet.test.ts | 218 ++++++++++++++++++ packages/cli/src/cli/commands/fleet.ts | 54 ++++- .../cli/src/cli/commands/local-agent.test.ts | 16 ++ packages/cli/src/cli/commands/local-agent.ts | 5 + .../src/cli/lib/fleet-attach-target.test.ts | 27 ++- .../cli/src/cli/lib/fleet-attach-target.ts | 9 +- packages/cli/src/cli/lib/sdk-client.test.ts | 54 +++++ packages/cli/src/cli/lib/sdk-client.ts | 30 +-- .../cli/src/cli/lib/workspace-session.test.ts | 47 +++- packages/cli/src/cli/lib/workspace-session.ts | 6 +- packages/cloud/src/fleet-sandbox.test.ts | 12 + packages/cloud/src/fleet-sandbox.ts | 6 +- packages/cloud/src/index.ts | 2 +- .../cloud/src/project-workspace-key.test.ts | 50 ++++ packages/cloud/src/project-workspace-key.ts | 138 ++++++----- packages/cloud/src/workspace-store.test.ts | 24 +- packages/cloud/src/workspace-store.ts | 41 +++- packages/cloud/src/workspaces.test.ts | 98 +++++++- packages/cloud/src/workspaces.ts | 34 +++ packages/config/src/project-namespace.test.ts | 49 ++++ packages/config/src/project-namespace.ts | 28 ++- .../cases/1763-zero-config-sandbox/case.json | 21 ++ .../cases/1763-zero-config-sandbox/run.mjs | 141 +++++++++++ 25 files changed, 1001 insertions(+), 123 deletions(-) create mode 100644 packages/config/src/project-namespace.test.ts create mode 100644 tests/relayflows/cases/1763-zero-config-sandbox/case.json create mode 100644 tests/relayflows/cases/1763-zero-config-sandbox/run.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d74a6cd4f1..592cf0c3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 commit before dispatch and maps nested local directories to the sandbox clone. - `node agent attach ` automatically routes to a unique live Fleet node; ambiguous placements require `--node`. -- Temporary isolated Relaycast credentials are stored in the machine-local - 0600 credential store, while project metadata keeps only a non-secret - reference. +- `fleet spawn --sandbox` keeps temporary routing credentials out of project files. ## [12.1.1] - 2026-09-15 diff --git a/packages/cli/README.md b/packages/cli/README.md index ebea31a740..2149c66775 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -241,8 +241,9 @@ absent, it creates and removes a short-lived launcher identity automatically. Automatic placement and release need only the workspace key. The sandbox path provisions a fresh hosted instance and makes the Relayfile -mount mandatory by default, so the spawned worker starts in `/workspace` and -sees the same synced Relayfile workspace. Use `--sandbox-provider daytona` or +mount mandatory by default. Outside Git, the worker starts in `/workspace`; +inside Git it starts in the corresponding repository checkout described below. +Both paths see the same synced Relayfile workspace. Use `--sandbox-provider daytona` or `--sandbox-provider e2b` to require an operator-enabled provider; omit the flag to let Cloud's sandbox router choose. Pass `--no-sandbox-relayfile` only when a deliberately bare sandbox is desired. If provisioning times out or the spawn @@ -317,6 +318,11 @@ credential requires rerunning sandbox provisioning for that workspace. advanced overrides. Outside Git, the existing mount-based sandbox behavior is preserved. +Pins created before workspace IDs were recorded are resolved automatically +through Cloud at spawn time. The key travels in an authenticated POST body, +never a URL. Nested packages share the repository pin; an existing subproject +pin or `AGENT_RELAY_PROJECT` remains an explicit workspace override. + Large workspaces should select only the live subtree an agent needs. Pass one or more explicit directory roots after `--sandbox-relayfile-path`; Cloud validates the `/path/**` form and materializes those roots before the agent diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 319746da8a..3e2557cc73 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1032,6 +1032,8 @@ describe('fleet command support', () => { ); expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); expect(createWorkspaceRelay).toHaveBeenNthCalledWith(1, { + projectRoot: process.cwd(), + token: undefined, workspaceKey: 'rk_live_agent37_target', baseUrl: 'https://agent37-cast.agentrelay.com', }); @@ -1160,6 +1162,214 @@ describe('fleet command support', () => { ); }); + it('keeps cross-repo --cwd inference on the actual checkout while using the explicit project workspace', async () => { + vi.stubEnv('AGENT_RELAY_PROJECT', '/workspace-project'); + const revision = '0123456789abcdef0123456789abcdef01234567'; + const resolveSandboxRepository = vi.fn(() => ({ + repository: 'AgentWorkforce/legacyprovider', + repositoryName: 'legacyprovider', + revision, + projectRoot: '/actual/legacyprovider', + workerCwd: '/srv/agent-workforce/legacyprovider/packages/web', + })); + const placement = { + spawn: vi.fn(async () => ({ invocationId: 'inv_cross_repo', node: { name: 'legacy-node' } })), + }; + const register = vi.fn(async () => ({ token: 'at_live_legacy' })); + const release = vi.fn(async () => ({ released: true, deleted: true })); + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { register, release }, + })); + const ensureCloudFleetSandbox = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'e2b' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-legacy', + nodeName: 'legacy-node', + sandboxId: 'sandbox-cross-repo', + providerSandboxId: 'provider-cross-repo', + relayWorkspaceId: 'rw_abc', + relayfileMounted: false, + repoRevisions: { 'AgentWorkforce/legacyprovider': revision }, + })); + const resolveWorkspaceSelection = vi.fn(() => ({ + key: 'rk_live_test', + source: 'project' as const, + origin: '/workspace-project/.agentworkforce/relay/workspace-key.json', + workspaceId: 'rw_abc', + })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository, + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as never, + }, + ensureCloudFleetSandbox, + resolveWorkspaceSelection: resolveWorkspaceSelection as never, + persistWorkspaceRelaycastTarget: vi.fn(() => true), + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'e2b', + '--no-sandbox-relayfile', + '--workspace-id', + 'rw_abc', + '--cwd', + '../legacyprovider/packages/web', + '--name', + 'legacy-worker', + '--task', + 'Review the legacy provider', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + + expect(resolveSandboxRepository).toHaveBeenCalledWith(process.cwd(), '../legacyprovider/packages/web'); + expect(resolveWorkspaceSelection).toHaveBeenCalledWith( + expect.objectContaining({ projectRoot: '/workspace-project' }) + ); + expect(createWorkspaceRelay).toHaveBeenCalledWith( + expect.objectContaining({ + projectRoot: '/workspace-project', + ignorePersistedRelaycastTarget: true, + }) + ); + expect(ensureCloudFleetSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + repos: ['AgentWorkforce/legacyprovider'], + repoRevisions: { 'AgentWorkforce/legacyprovider': revision }, + }) + ); + expect(placement.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + worker_cwd: '/srv/agent-workforce/legacyprovider/packages/web', + }), + }) + ); + }); + + it('resolves a key-only project pin through Cloud before provisioning a default sandbox', async () => { + const target = { ...AGENT37_RELAYCAST_TARGET, workspaceId: 'rw_pinned' }; + const resolveWorkspaceByKey = vi.fn(async (key: string) => ({ + name: 'Pinned workspace', + key, + cloudWorkspaceId: 'cloud-pinned', + relaycastWorkspaceId: 'rw_pinned', + relayfileWorkspaceId: 'rf_pinned', + relayauthWorkspaceId: 'ra_pinned', + apiUrl: 'https://cloud.example.test', + urls: {}, + })); + const placement = { + spawn: vi.fn(async () => ({ invocationId: 'inv_key_only', node: { name: 'pinned-node' } })), + }; + const register = vi.fn(async () => ({ token: 'at_live_pinned' })); + const release = vi.fn(async () => ({ released: true, deleted: true })); + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { + info: vi.fn(async () => ({ id: 'rw_pinned' })), + register, + release, + }, + })); + const persistWorkspaceRelaycastTarget = vi.fn(() => true); + const ensureCloudFleetSandbox = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-pinned', + nodeId: 'node-pinned', + nodeName: 'pinned-node', + sandboxId: 'sandbox-key-only', + providerSandboxId: 'provider-key-only', + relayWorkspaceId: 'rw_pinned', + relaycastTarget: target, + relayfileMounted: true, + relayfileMountPath: '/workspace', + })); + const createAgentRelay = vi.fn(() => ({ messaging: { placement } })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, + resolveWorkspaceByKey: resolveWorkspaceByKey as never, + sdk: { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as never, + }, + ensureCloudFleetSandbox, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_pinned', + source: 'project', + origin: '/project/.agentworkforce/relay/workspace-key.json', + }), + persistWorkspaceRelaycastTarget, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--name', + 'pinned-worker', + '--task', + 'Use the selected workspace', + '--workspace-key', + 'rk_live_pinned', + ], + { from: 'user' } + ); + + expect(resolveWorkspaceByKey).toHaveBeenCalledWith('rk_live_pinned'); + expect(ensureCloudFleetSandbox).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'cloud-pinned', workloadProfile: 'long-running-agent' }) + ); + expect(createWorkspaceRelay).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceKey: AGENT37_RELAYCAST_TARGET.relaycastApiKey, + baseUrl: target.baseUrl, + }) + ); + expect(persistWorkspaceRelaycastTarget).toHaveBeenCalledWith( + expect.objectContaining({ key: 'rk_live_pinned' }), + target + ); + expect(createAgentRelay).toHaveBeenCalledWith({ + token: 'at_live_pinned', + baseUrl: target.baseUrl, + }); + }); + it.each([ ['missing', undefined], ['mismatched', { 'AgentWorkforce/cloud': 'fedcba9876543210fedcba9876543210fedcba98' }], @@ -1363,6 +1573,8 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: AGENT37_RELAYCAST_TARGET.relaycastApiKey, baseUrl: AGENT37_RELAYCAST_TARGET.baseUrl, }); @@ -1530,6 +1742,8 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: CANONICAL_RELAYCAST_TARGET.relaycastApiKey, baseUrl: CANONICAL_RELAYCAST_TARGET.baseUrl, }); @@ -1626,6 +1840,7 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), workspaceKey: 'rk_live_test', token: undefined, baseUrl: undefined, @@ -1876,6 +2091,7 @@ describe('fleet command support', () => { const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0]; expect(persistWorkspaceRelaycastTarget).not.toHaveBeenCalled(); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), workspaceKey: 'rk_live_test', token: undefined, baseUrl: undefined, @@ -2074,6 +2290,8 @@ describe('fleet command support', () => { expect.objectContaining({ workspaceId: cloudWorkspaceId }) ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: target.relaycastApiKey, baseUrl: target.baseUrl, }); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 70da404951..5b555aea9e 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -1,10 +1,12 @@ import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import { InvalidArgumentError, type Command } from 'commander'; import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, ensureCloudFleetSandbox, + resolveWorkspaceByKey, type CloudFleetSandboxProviderId, type EnsureCloudFleetSandboxResult, } from '@agent-relay/cloud'; @@ -70,7 +72,7 @@ function assertSandboxRepositoryRevision( const expected = { [selection.repository]: selection.revision }; if (sandbox.outcome === 'provisioning_timeout') { throw new Error( - 'Cloud did not verify the requested repository revision before the sandbox became ready.' + `Sandbox node '${sandbox.nodeName}' did not become ready within ${sandbox.waitedMs}ms; the repository revision was not verified.` ); } const actual = sandbox.repoRevisions?.[selection.repository]; @@ -86,6 +88,14 @@ function assertSandboxRepositoryRevision( } } +function pathContains(parent: string, child: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(child)); + return ( + relative === '' || + (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +} + // The targeted spawn path (relay.messaging.placement.spawn) returns an // invocation whose `placement` is the SDK's own evidence object // (`state: 'accepted' | 'ready'`, `confirmed`). `spawnPlacementReceipt` @@ -136,6 +146,7 @@ export interface FleetCommandDependencies { createFleetWorkspaceClient: (options: SdkClientOptions) => RelayWorkspaceThinClient; resolveWorkspaceSelection: typeof resolveWorkspaceSelection; resolveSandboxRepository: typeof resolveSandboxRepository; + resolveWorkspaceByKey: typeof resolveWorkspaceByKey; persistWorkspaceRelaycastTarget: typeof persistWorkspaceRelaycastTarget; ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; deleteCloudFleetSandbox: typeof deleteCloudFleetSandbox; @@ -157,6 +168,7 @@ function withFleetDefaults(overrides: Partial = {}): F }, resolveWorkspaceSelection, resolveSandboxRepository, + resolveWorkspaceByKey, persistWorkspaceRelaycastTarget, ensureCloudFleetSandbox, deleteCloudFleetSandbox, @@ -360,25 +372,53 @@ export function registerFleetCommands( let relaycastClientOptions = clientOptions; let legacyWorkspaceClientOptions = clientOptions; if (useSandbox) { - const projectRoot = deps.core.getProjectPaths().projectRoot; + const coreProjectRoot = deps.core.getProjectPaths().projectRoot; + const hasExplicitProjectOverride = Boolean( + deps.core.env?.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim() + ); + // AGENT_RELAY_PROJECT selects the workspace namespace, while repository + // inference must remain anchored to the actual checkout from which the + // command was invoked. This also lets --cwd point at a sibling checkout. + const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot; sandboxRepository = deps.resolveSandboxRepository( - projectRoot, + repositoryRootHint, optionalText(options.cwd, 'Worker cwd') ); if (sandboxRepository) workerCwd = sandboxRepository.workerCwd; + const workspaceProjectRoot = hasExplicitProjectOverride + ? coreProjectRoot + : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot) + ? coreProjectRoot + : (sandboxRepository?.projectRoot ?? coreProjectRoot); + const sandboxClientOptions = { + ...clientOptions, + projectRoot: workspaceProjectRoot, + }; + relaycastClientOptions = sandboxClientOptions; // Cloud must be the first network authority for a sandbox invocation. // A canonical Relaycast info call would both leak the workspace key and // make it impossible to prove that Cloud's isolated target is the one // subsequently used for registration and dispatch. const workspaceSelection = deps.resolveWorkspaceSelection({ - ...clientOptions, - ...(sandboxRepository ? { projectRoot: sandboxRepository.projectRoot } : {}), + ...sandboxClientOptions, }); legacyWorkspaceClientOptions = { - ...clientOptions, + ...sandboxClientOptions, ...(sandboxProvider === 'agent37' ? {} : { ignorePersistedRelaycastTarget: true }), }; let relayWorkspaceId = explicitWorkspaceId ?? workspaceSelection?.workspaceId?.trim(); + // Older/rebound project pins contain only the canonical key. Resolve + // that exact selection with Cloud using a POST body, never a key URL + // or an ambient active workspace. Successful target persistence below + // records the identity for the next invocation. + if ( + !relayWorkspaceId && + workspaceSelection?.key && + (sandboxProvider === undefined || sandboxProvider === 'agent37') + ) { + const resolved = await deps.resolveWorkspaceByKey(workspaceSelection.key); + relayWorkspaceId = resolved.cloudWorkspaceId; + } // Legacy providers remain backward compatible: they may resolve the // workspace from canonical Relaycast. Agent37 may not, because even a // read there mutates rate-limit/presence accounting on the shared @@ -537,7 +577,7 @@ export function registerFleetCommands( ); } relaycastClientOptions = { - ...clientOptions, + ...relaycastClientOptions, workspaceKey: target.relaycastApiKey, baseUrl: target.baseUrl, }; diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index 8e827dc12d..b8b873f813 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -934,6 +934,22 @@ describe('local agent subtree', () => { expect(log).toHaveBeenCalledWith(JSON.stringify({ name: 'claude', flushed: 2 }, null, 2)); }); + it.each(['flush', 'hold', 'auto'])( + 'message %s rejects an explicit workspace key without a node', + async (mode) => { + const connectLocal = vi.fn(); + const { program, error } = harness({ connectLocal }); + await program.parseAsync( + ['local', 'agent', 'message', mode, 'worker', '--workspace-key', 'rk_live_other'], + { from: 'user' } + ); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('--workspace-key requires an explicit --node') + ); + expect(connectLocal).not.toHaveBeenCalled(); + } + ); + it('message hold and auto switch local broker delivery mode', async () => { const client = { setInboundDeliveryMode: vi.fn(async (_name: string, mode: string) => ({ mode, flushed: 0 })), diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index d67643a0a7..1d55fd8bee 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -622,6 +622,11 @@ async function withDeliveryModeClient( run: (client: ReturnType) => Promise ): Promise { let node = typeof opts.node === 'string' && opts.node.trim() ? opts.node.trim() : undefined; + if (!node && opts.workspaceKey !== undefined) { + deps.error('Error: --workspace-key requires an explicit --node.'); + deps.exit(1); + return undefined; + } let targetBaseUrl: string | undefined; if (!node && opts.brokerUrl === undefined && opts.apiKey === undefined && opts.stateDir === undefined) { const fleetTarget = await deps.resolveFleetAttachTarget(name); diff --git a/packages/cli/src/cli/lib/fleet-attach-target.test.ts b/packages/cli/src/cli/lib/fleet-attach-target.test.ts index 75a428c66c..0f1ec29e36 100644 --- a/packages/cli/src/cli/lib/fleet-attach-target.test.ts +++ b/packages/cli/src/cli/lib/fleet-attach-target.test.ts @@ -20,7 +20,7 @@ describe('resolveFleetAttachTarget', () => { () => ({ baseUrl: 'https://agent37-cast.agentrelay.com' }) ) ).resolves.toEqual({ - target: { node: 'sandbox-1', baseUrl: 'https://agent37-cast.agentrelay.com' }, + target: { node: 'sandbox-1-id', baseUrl: 'https://agent37-cast.agentrelay.com' }, }); }); @@ -34,6 +34,31 @@ describe('resolveFleetAttachTarget', () => { ).resolves.toMatchObject({ error: expect.stringContaining('multiple fleet nodes') }); }); + it('uses the unique id when another node shares the same name', async () => { + const selected = { ...node('shared', ['worker']), nodeId: 'selected-id' }; + const other = { ...node('shared', []), nodeId: 'other-id' }; + expect( + await resolveFleetAttachTarget( + 'worker', + () => ({ nodes: { list: async () => [other, selected] } }) as never, + () => ({}), + () => undefined + ) + ).toEqual({ target: { node: 'selected-id' } }); + }); + + it('fails closed for incomplete persisted remote metadata', async () => { + const result = await resolveFleetAttachTarget( + 'worker', + (() => { + throw new Error('invalid target'); + }) as never, + () => ({}), + () => ({ key: 'rk_live_pin', source: 'project', origin: 'test', relaycastApiKeyRef: 'stale-ref' }) + ); + expect(result.error).toContain('persisted remote Fleet session'); + }); + it('redacts transport error details before returning persisted-session diagnostics', async () => { const result = await resolveFleetAttachTarget( 'worker', diff --git a/packages/cli/src/cli/lib/fleet-attach-target.ts b/packages/cli/src/cli/lib/fleet-attach-target.ts index 2ea805f724..0253485a4f 100644 --- a/packages/cli/src/cli/lib/fleet-attach-target.ts +++ b/packages/cli/src/cli/lib/fleet-attach-target.ts @@ -35,7 +35,12 @@ export async function resolveFleetAttachTarget( `Pass --node explicitly or repair the project session. (${describeError(error)})`, }; } - const knownRemoteSession = Boolean(selection?.relaycastRoute && selection.relaycastBaseUrl); + const knownRemoteSession = Boolean( + selection?.relaycastRoute || + selection?.relaycastBaseUrl || + selection?.relaycastApiKey || + selection?.relaycastApiKeyRef + ); try { const relay = createRelay(); const nodes = await relay.nodes.list(); @@ -74,7 +79,7 @@ export async function resolveFleetAttachTarget( } function nodeLabel(node: RelayNode): string | undefined { - return [node.name, node.nodeId, node.id].find( + return [node.nodeId, node.id, node.name].find( (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 ); } diff --git a/packages/cli/src/cli/lib/sdk-client.test.ts b/packages/cli/src/cli/lib/sdk-client.test.ts index 2d1aba4709..4c71c54c72 100644 --- a/packages/cli/src/cli/lib/sdk-client.test.ts +++ b/packages/cli/src/cli/lib/sdk-client.test.ts @@ -83,6 +83,28 @@ describe('sdk client option resolution', () => { expect(resolveWorkspaceKey({ env: { AGENT_RELAY_HOME: dir } })).toBe('rk_project_broker'); }); + it('honors an explicit AGENT_RELAY_PROJECT override when resolving a workspace selection', () => { + const overrideRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-sdk-override-project-')); + try { + writeProjectWorkspaceKey(path.join(overrideRoot, '.agentworkforce/relay'), 'rk_override'); + expect( + resolveWorkspaceSelection({ + projectRoot, + env: { + AGENT_RELAY_HOME: dir, + AGENT_RELAY_PROJECT: overrideRoot, + }, + }) + ).toMatchObject({ + key: 'rk_override', + source: 'project', + origin: path.join(overrideRoot, '.agentworkforce/relay/workspace-key.json'), + }); + } finally { + fs.rmSync(overrideRoot, { recursive: true, force: true }); + } + }); + it('lets an explicit flag and env override the CWD broker workspace key', () => { writeProjectWorkspaceKey(projectDataDir(), 'rk_project_broker'); @@ -225,6 +247,38 @@ describe('sdk client option resolution', () => { expect(resolveBaseUrl(replayOptions)).toBe('https://agent37-cast.agentrelay.com'); }); + it('persists a selected route credential under the selected credential home without mutating process.env', () => { + const selectedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-sdk-selected-home-')); + const processHome = process.env.AGENT_RELAY_HOME; + try { + writeProjectWorkspaceKey(projectDataDir(), 'rk_live_selected'); + const selectedEnv = { AGENT_RELAY_HOME: selectedHome }; + const selection = resolveWorkspaceSelection({ env: selectedEnv }); + expect(selection).toMatchObject({ credentialHome: selectedHome }); + + expect( + persistWorkspaceRelaycastTarget(selection, { + route: 'agent37-isolated', + baseUrl: 'https://agent37-cast.agentrelay.com', + workspaceId: 'rw_selected', + relaycastApiKey: 'rk_live_selected_agent37', + }) + ).toBe(true); + + expect(process.env.AGENT_RELAY_HOME).toBe(processHome); + expect(readProjectWorkspaceSession(projectDataDir(), undefined, selectedEnv)).toMatchObject({ + relaycastApiKey: 'rk_live_selected_agent37', + }); + expect( + readProjectWorkspaceSession(projectDataDir(), undefined, { + AGENT_RELAY_HOME: `${selectedHome}-other`, + })?.relaycastApiKey + ).toBeUndefined(); + } finally { + fs.rmSync(selectedHome, { recursive: true, force: true }); + } + }); + it.each(['flag', 'env'] as const)( 'creates a fresh project target pin for an unpinned %s selection', (source) => { diff --git a/packages/cli/src/cli/lib/sdk-client.ts b/packages/cli/src/cli/lib/sdk-client.ts index a6004b6f56..fe9bf17169 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -1,4 +1,3 @@ -import { lstatSync } from 'node:fs'; import path from 'node:path'; import { AgentRelay, type AgentRelayAgent } from '@agent-relay/sdk'; @@ -43,7 +42,8 @@ export type WorkspaceTransport = { /** Resolve the selected key and any previously persisted Relay workspace identity. */ export function resolveWorkspaceSelection(options: SdkClientOptions = {}): WorkspaceSelection | undefined { - const projectRoot = options.projectRoot ?? inferGitProjectRoot(env(options)); + const explicitProject = trimOrUndefined(env(options).AGENT_RELAY_PROJECT); + const projectRoot = explicitProject ? path.resolve(explicitProject) : options.projectRoot; return resolveCloudWorkspaceSelection({ workspaceKey: options.workspaceKey, env: env(options), @@ -51,32 +51,6 @@ export function resolveWorkspaceSelection(options: SdkClientOptions = {}): Works }); } -/** Resolve the repository root for nested package invocations. */ -function inferGitProjectRoot(environment: NodeJS.ProcessEnv): string | undefined { - // The config package intentionally honours this override for worktrees and - // subprojects. Do not replace an operator-selected namespace with Git's - // answer. - if (environment.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim()) return undefined; - // Follow-up commands only need the project boundary, not Git execution. - // Recognize both ordinary repositories and worktree .git files so a missing - // Git binary or a Git subprocess failure cannot select a different workspace. - let directory = process.cwd(); - while (true) { - try { - const marker = lstatSync(path.join(directory, '.git')); - if (marker.isDirectory() || marker.isFile()) return directory; - throw new Error('Invalid Git project marker.'); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw new Error('Cannot resolve the repository workspace; check access to its Git project marker.'); - } - } - const parent = path.dirname(directory); - if (parent === directory) return undefined; - directory = parent; - } -} - /** * Resolve the workspace key and report which source it came from. Precedence: * explicit flag → `RELAY_WORKSPACE_KEY`/`RELAY_API_KEY` env → the key the local diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 7535553f34..2f3baaf33b 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { promoteWorkspaceKeyEnvAlias } from './workspace-env.js'; import { @@ -203,6 +203,48 @@ describe('workspace session persistence', () => { }); }); + it('uses the injected default credential home when AGENT_RELAY_HOME is omitted', () => { + const root = tempRoot(); + const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); + const defaultHome = path.join(root, 'default-home'); + const ambientHome = path.join(root, 'ambient-home'); + const originalHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = ambientHome; + const homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(defaultHome); + const env = { ...process.env }; + delete env.AGENT_RELAY_HOME; + + try { + persistWorkspaceSession({ + workspaceKey: 'rk_live_default_home', + workspaceId: 'rw_default_home', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_default_home_route', + projectDataDir, + env, + }); + + expect(readProjectWorkspaceSession(projectDataDir, undefined, env)).toMatchObject({ + relaycastApiKey: 'rk_live_default_home_route', + }); + expect( + readProjectWorkspaceSession(projectDataDir, undefined, { AGENT_RELAY_HOME: ambientHome }) + ).toMatchObject({ + workspaceKey: 'rk_live_default_home', + }); + expect( + readProjectWorkspaceSession(projectDataDir, undefined, { AGENT_RELAY_HOME: ambientHome }) + ?.relaycastApiKey + ).toBeUndefined(); + expect(process.env.AGENT_RELAY_HOME).toBe(ambientHome); + } finally { + homeSpy.mockRestore(); + if (originalHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = originalHome; + } + }); + it('replaces a stale route credential when re-persisting an explicit route', () => { const root = tempRoot(); const projectDataDir = path.join(root, 'project', '.agentworkforce', 'relay'); @@ -224,12 +266,13 @@ describe('workspace session persistence', () => { env, }); - expect(readProjectWorkspaceSession(projectDataDir)).toEqual({ + expect(readProjectWorkspaceSession(projectDataDir, undefined, env)).toMatchObject({ workspaceKey: 'rk_live_redeemed', workspaceId: 'rw_redeemed', relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_redeemed', + relaycastApiKeyRef: expect.any(String), }); }); diff --git a/packages/cli/src/cli/lib/workspace-session.ts b/packages/cli/src/cli/lib/workspace-session.ts index 65def6f681..a68f85e165 100644 --- a/packages/cli/src/cli/lib/workspace-session.ts +++ b/packages/cli/src/cli/lib/workspace-session.ts @@ -89,7 +89,7 @@ export function persistWorkspaceSession( const name = options.name === undefined ? undefined : validateWorkspaceSessionName(options.name); const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; - const existing = readProjectWorkspaceSession(projectDataDir); + const existing = readProjectWorkspaceSession(projectDataDir, undefined, options.env); // 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. @@ -114,6 +114,7 @@ export function persistWorkspaceSession( ...(options.relaycastRoute ? { relaycastRoute: options.relaycastRoute } : {}), ...(options.relaycastBaseUrl ? { relaycastBaseUrl: options.relaycastBaseUrl } : {}), ...(options.relaycastApiKey ? { relaycastApiKey: options.relaycastApiKey } : {}), + env: options.env, }); } else { writeProjectWorkspaceKey(projectDataDir, workspaceKey, { @@ -121,6 +122,7 @@ export function persistWorkspaceSession( ...(options.relaycastRoute ? { relaycastRoute: options.relaycastRoute } : {}), ...(options.relaycastBaseUrl ? { relaycastBaseUrl: options.relaycastBaseUrl } : {}), ...(options.relaycastApiKey ? { relaycastApiKey: options.relaycastApiKey } : {}), + env: options.env, }); } @@ -145,5 +147,5 @@ export function pinProjectWorkspaceSession(options: PinProjectWorkspaceSessionOp throw new Error('Workspace key is required.'); } const projectDataDir = options.projectDataDir ?? getProjectPaths(options.projectRoot).dataDir; - writeProjectWorkspaceKey(projectDataDir, workspaceKey); + writeProjectWorkspaceKey(projectDataDir, workspaceKey, { env: options.env }); } diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index a91289a37b..675d5e3fe6 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -969,6 +969,18 @@ describe('Cloud fleet sandbox client', () => { }); }); + it('rejects incomplete revision maps before Cloud authentication', async () => { + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos: ['AgentWorkforce/cloud', 'AgentWorkforce/relay'], + repoRevisions: { 'AgentWorkforce/cloud': '0123456789abcdef0123456789abcdef01234567' }, + }) + ).rejects.toThrow('cover every requested repository'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + }); + it('rejects an explicitly empty Relayfile path list before provisioning', async () => { await expect( ensureCloudFleetSandbox({ diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 6b5420fdab..591e1fd7c0 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -357,8 +357,12 @@ function validateRepoRevisions( ): Record | undefined { if (repoRevisions === undefined) return undefined; const entries = Object.entries(repoRevisions); - if (entries.length === 0) return undefined; const allowedRepos = new Set(repos ?? []); + if (entries.length === 0 || entries.length > 16 || entries.length !== allowedRepos.size) { + throw new Error( + 'Cloud fleet sandbox revisions must cover every requested repository exactly once (maximum 16).' + ); + } for (const [repo, revision] of entries) { if (!REPOSITORY_KEY_PATTERN.test(repo) || repo.includes('..')) { throw new Error(`Cloud fleet sandbox repository key '${repo}' must use owner/name form.`); diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index b1b5ae45a0..b18dc2893f 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -75,7 +75,7 @@ export { type ConnectProviderResult, } from './connect.js'; -export { createWorkspace, issueWorkspaceToken, resolveActiveWorkspace } from './workspaces.js'; +export { createWorkspace, issueWorkspaceToken, resolveActiveWorkspace, resolveWorkspaceByKey } from './workspaces.js'; export { redactCredentialValues } from './redact.js'; export { ensureCloudFleetSandbox, diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index ffb1b339fc..4b2a771e04 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -11,6 +11,7 @@ import { resolveWorkspaceKeyWithSource, resolveWorkspaceSelection, writeProjectWorkspaceTargetIfSelectionCurrent, + writeProjectWorkspaceKeyPreservingSession, writeProjectWorkspaceKey, } from './project-workspace-key.js'; import { setWorkspaceKey } from './workspace-store.js'; @@ -37,6 +38,55 @@ describe('project workspace key resolution', () => { expect(fs.statSync(projectWorkspaceKeyPath(dataDir)).mode & 0o777).toBe(0o600); }); + it('uses an explicit credential home without mutating process.env or the project file', () => { + const credentialHome = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-credential-home-')); + const processHome = process.env.AGENT_RELAY_HOME; + try { + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_temporary', + env: { AGENT_RELAY_HOME: credentialHome }, + }); + + expect(process.env.AGENT_RELAY_HOME).toBe(processHome); + expect(readProjectWorkspaceSession(dataDir, fs, { AGENT_RELAY_HOME: credentialHome })).toMatchObject({ + workspaceKey: 'rk_canonical', + relaycastApiKey: 'rk_live_temporary', + }); + expect( + readProjectWorkspaceSession(dataDir, fs, { AGENT_RELAY_HOME: `${credentialHome}-other` }) + ?.relaycastApiKey + ).toBeUndefined(); + expect(fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8')).not.toContain('rk_live_temporary'); + } finally { + fs.rmSync(credentialHome, { recursive: true, force: true }); + } + }); + + it('preserves a route credential through metadata updates in the selected credential home', () => { + writeProjectWorkspaceKey(dataDir, 'rk_canonical', { + workspaceId: 'rw_abc', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_temporary', + env: { AGENT_RELAY_HOME: home }, + }); + + writeProjectWorkspaceKeyPreservingSession(dataDir, 'rk_canonical', { + enrolledNodeId: 'node_1', + env: { AGENT_RELAY_HOME: home }, + }); + + expect(readProjectWorkspaceSession(dataDir, fs, { AGENT_RELAY_HOME: home })).toMatchObject({ + workspaceKey: 'rk_canonical', + enrolledNodeId: 'node_1', + relaycastApiKey: 'rk_live_temporary', + }); + expect(fs.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf8')).not.toContain('rk_live_temporary'); + }); + it('keeps a temporary Relaycast key out of the project file', () => { const previousHome = process.env.AGENT_RELAY_HOME; process.env.AGENT_RELAY_HOME = home; diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index 3922337e6a..a98de43d08 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -8,6 +8,7 @@ import { readRelaycastCredential, readWorkspaceStore, relaycastCredentialRef, + relaycastCredentialStorePath, writeRelaycastCredential, workspaceStorePath, } from './workspace-store.js'; @@ -88,6 +89,8 @@ export interface WorkspaceSelection { projectDataDir?: string; /** Whether that project session existed when this selection was captured. */ projectSessionPresent?: boolean; + /** Machine credential home used for route credentials; never contains key material. */ + credentialHome?: string; } /** Absolute path to the workspace key recorded by `agent-relay node up`. */ @@ -98,15 +101,17 @@ export function projectWorkspaceKeyPath(dataDir: string): string { /** Read a project broker's workspace key, falling through on absent or malformed state. */ export function readProjectWorkspaceKey( dataDir: string, - fileSystem: WorkspaceKeyFileSystem = fs + fileSystem: WorkspaceKeyFileSystem = fs, + env: NodeJS.ProcessEnv = process.env ): string | undefined { - return readProjectWorkspaceSession(dataDir, fileSystem)?.workspaceKey; + return readProjectWorkspaceSession(dataDir, fileSystem, env)?.workspaceKey; } /** Read the project workspace and its optional enrolled Fleet identity. */ export function readProjectWorkspaceSession( dataDir: string, - fileSystem: WorkspaceKeyFileSystem = fs + fileSystem: WorkspaceKeyFileSystem = fs, + env: NodeJS.ProcessEnv = process.env ): ProjectWorkspaceSession | undefined { try { const raw = fileSystem.readFileSync(projectWorkspaceKeyPath(dataDir), 'utf-8'); @@ -128,7 +133,7 @@ export function readProjectWorkspaceSession( : undefined; const storedCredential = relaycastApiKeyRef && expectedRef === relaycastApiKeyRef - ? readRelaycastCredential(relaycastApiKeyRef) + ? readRelaycastCredential(relaycastApiKeyRef, env) : undefined; const relaycastApiKey = relaycastApiKeyRef ? storedCredential && @@ -159,24 +164,19 @@ export function readProjectWorkspaceSession( export function writeProjectWorkspaceKey( dataDir: string, workspaceKey: string | undefined, - options: { - enrolledNodeId?: string; - workspaceId?: string; - relaycastRoute?: 'canonical' | 'agent37-isolated'; - relaycastBaseUrl?: string; - relaycastApiKey?: string; - relaycastApiKeyRef?: string; - } = {} + options: ProjectWorkspaceSessionMetadata & { env?: NodeJS.ProcessEnv } = {} ): void { const key = trimOrUndefined(workspaceKey); if (!key) return; - withProjectWorkspaceKeyLock(dataDir, () => writeProjectWorkspaceKeyUnlocked(dataDir, key, options)); + const { env, ...metadata } = options; + withProjectWorkspaceKeyLock(dataDir, () => writeProjectWorkspaceKeyUnlocked(dataDir, key, metadata, env)); } function writeProjectWorkspaceKeyUnlocked( dataDir: string, workspaceKey: string, - options: ProjectWorkspaceSessionMetadata = {} + options: ProjectWorkspaceSessionMetadata = {}, + credentialEnv: NodeJS.ProcessEnv = process.env ): void { const key = trimOrUndefined(workspaceKey); if (!key) return; @@ -188,12 +188,16 @@ function writeProjectWorkspaceKeyUnlocked( let relaycastApiKeyRef = trimOrUndefined(options.relaycastApiKeyRef); if (relaycastApiKey && workspaceId && relaycastRoute && relaycastBaseUrl) { relaycastApiKeyRef = relaycastCredentialRef(dataDir, workspaceId, relaycastRoute, relaycastBaseUrl); - writeRelaycastCredential(relaycastApiKeyRef, { - workspaceId, - route: relaycastRoute, - baseUrl: relaycastBaseUrl, - apiKey: relaycastApiKey, - }); + writeRelaycastCredential( + relaycastApiKeyRef, + { + workspaceId, + route: relaycastRoute, + baseUrl: relaycastBaseUrl, + apiKey: relaycastApiKey, + }, + credentialEnv + ); } fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 }); const file = projectWorkspaceKeyPath(dataDir); @@ -384,8 +388,11 @@ export function writeProjectWorkspaceTargetIfSelectionCurrent( Pick > ): boolean { + const credentialEnv = selection.credentialHome + ? { AGENT_RELAY_HOME: selection.credentialHome } + : process.env; return withProjectWorkspaceKeyLock(dataDir, () => { - const current = readProjectWorkspaceSession(dataDir); + const current = readProjectWorkspaceSession(dataDir, fs, credentialEnv); if (selection.projectSessionPresent === false && current) return false; if ( current && @@ -407,19 +414,28 @@ export function writeProjectWorkspaceTargetIfSelectionCurrent( target.relaycastRoute, target.relaycastBaseUrl ); - writeRelaycastCredential(credentialRef, { - workspaceId: target.workspaceId, - route: target.relaycastRoute, - baseUrl: target.relaycastBaseUrl, - apiKey: target.relaycastApiKey, - }); - writeProjectWorkspaceKeyUnlocked(dataDir, selection.key, { - ...(current?.enrolledNodeId ? { enrolledNodeId: current.enrolledNodeId } : {}), - workspaceId: target.workspaceId, - relaycastRoute: target.relaycastRoute, - relaycastBaseUrl: target.relaycastBaseUrl, - relaycastApiKeyRef: credentialRef, - }); + writeRelaycastCredential( + credentialRef, + { + workspaceId: target.workspaceId, + route: target.relaycastRoute, + baseUrl: target.relaycastBaseUrl, + apiKey: target.relaycastApiKey, + }, + credentialEnv + ); + writeProjectWorkspaceKeyUnlocked( + dataDir, + selection.key, + { + ...(current?.enrolledNodeId ? { enrolledNodeId: current.enrolledNodeId } : {}), + workspaceId: target.workspaceId, + relaycastRoute: target.relaycastRoute, + relaycastBaseUrl: target.relaycastBaseUrl, + relaycastApiKeyRef: credentialRef, + }, + credentialEnv + ); return true; }); } @@ -437,13 +453,14 @@ export function writeProjectWorkspaceTargetIfSelectionCurrent( export function writeProjectWorkspaceKeyPreservingSession( dataDir: string, workspaceKey: string | undefined, - options: ProjectWorkspaceSessionMetadata = {} + options: ProjectWorkspaceSessionMetadata & { env?: NodeJS.ProcessEnv } = {} ): void { const key = trimOrUndefined(workspaceKey); if (!key) return; + const { env: credentialEnv, ...metadata } = options; withProjectWorkspaceKeyLock(dataDir, () => { - const existing = readProjectWorkspaceSession(dataDir); + const existing = readProjectWorkspaceSession(dataDir, fs, credentialEnv); const sameWorkspace = existing?.workspaceKey === key; const retained: ProjectWorkspaceSessionMetadata = sameWorkspace ? { @@ -456,23 +473,30 @@ export function writeProjectWorkspaceKeyPreservingSession( } : {}; - writeProjectWorkspaceKeyUnlocked(dataDir, key, { - ...retained, - ...(trimOrUndefined(options.enrolledNodeId) - ? { enrolledNodeId: trimOrUndefined(options.enrolledNodeId) } - : {}), - ...(trimOrUndefined(options.workspaceId) ? { workspaceId: trimOrUndefined(options.workspaceId) } : {}), - ...(options.relaycastRoute ? { relaycastRoute: options.relaycastRoute } : {}), - ...(trimOrUndefined(options.relaycastBaseUrl) - ? { relaycastBaseUrl: trimOrUndefined(options.relaycastBaseUrl) } - : {}), - ...(trimOrUndefined(options.relaycastApiKey) - ? { relaycastApiKey: trimOrUndefined(options.relaycastApiKey) } - : {}), - ...(trimOrUndefined(options.relaycastApiKeyRef) - ? { relaycastApiKeyRef: trimOrUndefined(options.relaycastApiKeyRef) } - : {}), - }); + writeProjectWorkspaceKeyUnlocked( + dataDir, + key, + { + ...retained, + ...(trimOrUndefined(metadata.enrolledNodeId) + ? { enrolledNodeId: trimOrUndefined(metadata.enrolledNodeId) } + : {}), + ...(trimOrUndefined(metadata.workspaceId) + ? { workspaceId: trimOrUndefined(metadata.workspaceId) } + : {}), + ...(metadata.relaycastRoute ? { relaycastRoute: metadata.relaycastRoute } : {}), + ...(trimOrUndefined(metadata.relaycastBaseUrl) + ? { relaycastBaseUrl: trimOrUndefined(metadata.relaycastBaseUrl) } + : {}), + ...(trimOrUndefined(metadata.relaycastApiKey) + ? { relaycastApiKey: trimOrUndefined(metadata.relaycastApiKey) } + : {}), + ...(trimOrUndefined(metadata.relaycastApiKeyRef) + ? { relaycastApiKeyRef: trimOrUndefined(metadata.relaycastApiKeyRef) } + : {}), + }, + credentialEnv + ); }); } @@ -498,14 +522,17 @@ export function resolveWorkspaceSelection( options: ResolveWorkspaceKeyOptions = {} ): WorkspaceSelection | undefined { const env = options.env ?? process.env; + const credentialHome = path.resolve(path.dirname(relaycastCredentialStorePath(env))); + const credentialHomeSelection = { credentialHome }; const dataDir = options.projectDataDir ?? projectDataDir(options.projectRoot); - const project = dataDir ? readProjectWorkspaceSession(dataDir, options.fileSystem ?? fs) : undefined; + const project = dataDir ? readProjectWorkspaceSession(dataDir, options.fileSystem ?? fs, env) : undefined; const flag = trimOrUndefined(options.workspaceKey); if (flag) { return { key: flag, source: 'flag', origin: '--workspace-key', + ...credentialHomeSelection, ...(project?.workspaceKey === flag && project.workspaceId ? { workspaceId: project.workspaceId } : {}), ...(project?.workspaceKey === flag && project.relaycastRoute ? { relaycastRoute: project.relaycastRoute } @@ -532,6 +559,7 @@ export function resolveWorkspaceSelection( key: envKey, source: 'env', origin: `$${name}`, + ...credentialHomeSelection, ...(project?.workspaceKey === envKey && project.workspaceId ? { workspaceId: project.workspaceId } : {}), @@ -559,6 +587,7 @@ export function resolveWorkspaceSelection( key: project.workspaceKey, source: 'project', origin: projectWorkspaceKeyPath(dataDir as string), + ...credentialHomeSelection, ...(project.workspaceId ? { workspaceId: project.workspaceId } : {}), ...(project.relaycastRoute ? { relaycastRoute: project.relaycastRoute } : {}), ...(project.relaycastBaseUrl ? { relaycastBaseUrl: project.relaycastBaseUrl } : {}), @@ -591,6 +620,7 @@ export function resolveActiveWorkspaceSelection( key: storeKey, source: 'store', origin: `${workspaceStorePath(env)} (active: "${activeName}")`, + credentialHome: path.resolve(path.dirname(relaycastCredentialStorePath(env))), ...(projectDataDir ? { projectDataDir, projectSessionPresent: false } : {}), } : undefined; diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 9ca963428c..f8affaf621 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -135,11 +135,13 @@ describe('workspace store', () => { }); }); - await Promise.all([ + const results = await Promise.allSettled([ ...Array.from({ length: 8 }, (_, index) => run('writer', `writer-${index}`)), run('reader', 'reader-a'), run('reader', 'reader-b'), ]); + const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed) throw failed.reason; const stored = JSON.parse(fs.readFileSync(relaycastCredentialStorePath(), 'utf8')) as { credentials: Record; @@ -171,6 +173,26 @@ describe('workspace store', () => { expect(fs.existsSync(lock)).toBe(false); }); + it('refuses to inspect or clean a stale credential lock symlink', () => { + const lock = `${relaycastCredentialStorePath()}.lock`; + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-lock-target-')); + const sentinel = path.join(outside, 'keep.txt'); + fs.writeFileSync(sentinel, 'keep'); + fs.symlinkSync(outside, lock, 'dir'); + const staleAt = new Date(Date.now() - 60_000); + fs.lutimesSync(lock, staleAt, staleAt); + + expect(() => + writeRelaycastCredential('must-fail-closed', { + workspaceId: 'rw_must_fail_closed', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_must_fail_closed', + }) + ).toThrow(/real directory/); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep'); + }); + it('scopes route references to the selected endpoint', () => { expect( relaycastCredentialRef( diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 59037e560b..d0d9022988 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -101,6 +101,14 @@ interface RelaycastCredentialLockOwner { token: string; } +function assertRelaycastCredentialLockDirectory(lock: string): fs.Stats { + const info = fs.lstatSync(lock); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Refusing to operate on a Relaycast credential lock that is not a real directory.'); + } + return info; +} + function withRelaycastCredentialLock(file: string, fn: () => T): T { const directory = path.dirname(file); const lock = `${file}.lock`; @@ -135,13 +143,18 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { if (!(isNodeError(error) && error.code === 'EEXIST')) throw error; } try { - if (Date.now() - fs.statSync(lock).mtimeMs >= RELAYCAST_CREDENTIAL_LOCK_STALE_MS) { + if ( + Date.now() - assertRelaycastCredentialLockDirectory(lock).mtimeMs >= + RELAYCAST_CREDENTIAL_LOCK_STALE_MS + ) { const observedLock = inspectRelaycastCredentialLock(lock); if (!observedLock.ownerIsAlive) { for (const entry of observedLock.entries) { + assertRelaycastCredentialLockDirectory(lock); fs.rmSync(path.join(lock, entry), { force: true }); } try { + assertRelaycastCredentialLockDirectory(lock); fs.rmdirSync(lock); } catch (error) { if (isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY')) { @@ -161,20 +174,29 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { } Atomics.wait(RELAYCAST_CREDENTIAL_LOCK_WAIT, 0, 0, RELAYCAST_CREDENTIAL_LOCK_RETRY_MS); } + let callbackFailed = false; try { return fn(); + } catch (error) { + callbackFailed = true; + throw error; } finally { if (fs.existsSync(ownerPath)) { - fs.rmSync(ownerPath, { force: true }); + let cleanupError: unknown; try { - fs.rmdirSync(lock); - } catch (error) { - if (isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY')) { - // Another writer may have replaced the lock after our marker was - // removed. Leave that replacement untouched. - } else { - throw error; + fs.rmSync(ownerPath, { force: true }); + try { + fs.rmdirSync(lock); + } catch (error) { + if (!(isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ENOTEMPTY'))) { + cleanupError = error; + } } + } catch (error) { + cleanupError = error; + } + if (cleanupError && !callbackFailed) { + throw cleanupError; } } } @@ -184,6 +206,7 @@ function inspectRelaycastCredentialLock(lock: string): { entries: string[]; ownerIsAlive: boolean; } { + assertRelaycastCredentialLockDirectory(lock); const entries = fs.readdirSync(lock); let sawLiveOwner = false; for (const entry of entries) { diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 0384351684..8771128bff 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setWorkspaceKey } from './workspace-store.js'; -import { resolveActiveWorkspace } from './workspaces.js'; +import { readWorkspaceStore, setWorkspaceKey } from './workspace-store.js'; +import { resolveActiveWorkspace, resolveWorkspaceByKey } from './workspaces.js'; let dir: string; const originalEnv = { ...process.env }; @@ -81,3 +81,97 @@ describe('resolveActiveWorkspace', () => { expect(new Headers(init.headers).get('authorization')).toBe('Bearer access-token'); }); }); + +describe('resolveWorkspaceByKey', () => { + const resolvedWorkspace = { + workspace: { + name: 'Selected', + key: 'rk_live_selected', + cloudWorkspaceId: 'cloud_selected', + relaycastWorkspaceId: 'rw_selected', + relayfileWorkspaceId: 'rf_selected', + relayauthWorkspaceId: 'ra_selected', + }, + }; + + it('sends the selected key in a POST body and never in the request URL', async () => { + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify(resolvedWorkspace), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + + await expect(resolveWorkspaceByKey('rk_live_selected')).resolves.toMatchObject({ + key: 'rk_live_selected', + cloudWorkspaceId: 'cloud_selected', + }); + + const [request, init] = fetchSpy.mock.calls[0]!; + expect(String(request)).toBe('https://cloud.example.test/api/v1/workspaces/current/resolve'); + expect(String(request)).not.toContain('rk_live_selected'); + expect((init as RequestInit).method).toBe('POST'); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + workspaceKey: 'rk_live_selected', + }); + }); + + it('fails closed when Cloud returns a descriptor for a different selected key', async () => { + const fetchSpy = vi.fn( + async () => + new Response( + JSON.stringify({ + workspace: { + ...resolvedWorkspace.workspace, + key: 'rk_live_other', + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + vi.stubGlobal('fetch', fetchSpy); + + await expect(resolveWorkspaceByKey('rk_live_selected')).rejects.toThrow( + 'Cloud resolved a different workspace credential than the selected project pin.' + ); + }); + + it('fails closed when Cloud omits the selected key from the resolver response', async () => { + const fetchSpy = vi.fn( + async () => + new Response( + JSON.stringify({ + workspace: { + ...resolvedWorkspace.workspace, + key: undefined, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + vi.stubGlobal('fetch', fetchSpy); + + await expect(resolveWorkspaceByKey('rk_live_selected')).rejects.toThrow( + 'Cloud resolved a different workspace credential than the selected project pin.' + ); + }); + + it('does not change the active workspace while resolving a selected project key', async () => { + setWorkspaceKey('active', 'rk_live_active'); + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify(resolvedWorkspace), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + + await resolveWorkspaceByKey('rk_live_selected'); + + expect(readWorkspaceStore().active).toBe('active'); + expect(readWorkspaceStore().workspaces.active?.key).toBe('rk_live_active'); + }); +}); diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 400869bfca..e29e25cf64 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -342,6 +342,40 @@ export async function issueWorkspaceToken( ); } +/** Resolve a selected project pin without exposing its key in URLs or changing the active workspace. */ +export async function resolveWorkspaceByKey( + workspaceKey: string, + options: ResolveActiveWorkspaceOptions = {} +): Promise { + const key = workspaceKey.trim(); + if (!/^rk_live_[A-Za-z0-9_-]{1,512}$/.test(key)) throw new Error('A valid workspace key is required.'); + const apiUrl = options.apiUrl || defaultApiUrl(); + const auth = await ensureAuthenticated(apiUrl, { + interactive: false, + refreshTimeoutMs: options.refreshTimeoutMs, + }); + const endpoint = '/api/v1/workspaces/current/resolve'; + const { response } = await authorizedApiFetch( + auth, + endpoint, + { + method: 'POST', + body: JSON.stringify({ workspaceKey: key }), + signal: AbortSignal.timeout(options.refreshTimeoutMs ?? 30_000), + }, + { interactive: false } + ); + const payload = await readJson(response); + if (!response.ok) throw buildEndpointError('Project workspace resolve', endpoint, response, payload); + // Unlike the legacy active-workspace resolver, this endpoint is an + // attestation for a specific project pin. A response that omits the echoed + // key must fail closed rather than being filled in from the request. + const resolved = normalizeActiveWorkspaceDescriptor(payload, '', auth.apiUrl); + if (resolved.key !== key) + throw new Error('Cloud resolved a different workspace credential than the selected project pin.'); + return resolved; +} + export async function resolveActiveWorkspace( options: ResolveActiveWorkspaceOptions = {} ): Promise { diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts new file mode 100644 index 0000000000..81b5d8fa15 --- /dev/null +++ b/packages/config/src/project-namespace.test.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { findProjectRoot } from './project-namespace.js'; + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-boundary-')); + vi.stubEnv('AGENT_RELAY_PROJECT', ''); +}); +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('shared repository workspace boundary', () => { + it('uses the checkout root from nested packages before and after a pin is written', () => { + const nested = path.join(root, 'packages', 'web'); + fs.mkdirSync(nested, { recursive: true }); + fs.mkdirSync(path.join(root, '.git')); + fs.writeFileSync(path.join(nested, 'package.json'), '{}'); + expect(findProjectRoot(nested)).toBe(root); + fs.mkdirSync(path.join(root, '.agentworkforce', 'relay'), { recursive: true }); + fs.writeFileSync(path.join(root, '.agentworkforce', 'relay', 'workspace-key.json'), '{}'); + expect(findProjectRoot(nested)).toBe(root); + }); + it('preserves an existing explicitly pinned subproject and environment overrides', () => { + const nested = path.join(root, 'packages', 'web'); + fs.mkdirSync(path.join(nested, '.agentworkforce', 'relay'), { recursive: true }); + fs.mkdirSync(path.join(root, '.git')); + fs.writeFileSync(path.join(nested, '.agentworkforce', 'relay', 'workspace-key.json'), '{}'); + expect(findProjectRoot(nested)).toBe(nested); + vi.stubEnv('AGENT_RELAY_PROJECT', root); + expect(findProjectRoot(nested)).toBe(root); + }); + it('recognizes a worktree Git file and preserves package roots outside Git', () => { + const nested = path.join(root, 'package'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'package.json'), '{}'); + expect(findProjectRoot(nested)).toBe(nested); + fs.writeFileSync(path.join(root, '.git'), 'gitdir: /unused/metadata'); + expect(findProjectRoot(nested)).toBe(root); + }); + it('fails closed for a malformed Git marker rather than selecting a package workspace', () => { + fs.symlinkSync('/nonexistent/git', path.join(root, '.git')); + expect(() => findProjectRoot(root)).toThrow('Cannot resolve the repository workspace'); + }); +}); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 796b0b3c88..ff99700d12 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -28,7 +28,8 @@ function hashPath(projectPath: string): string { * * Priority: * 1. AGENT_RELAY_PROJECT environment variable (for worktrees/subprojects) - * 2. Find project root by looking for markers (.git, package.json, etc.) + * 2. An explicitly pinned nested project, or the enclosing Git checkout + * 3. The nearest package marker when outside Git */ export function findProjectRoot(startDir: string = process.cwd()): string { // Allow explicit override for worktrees and subprojects @@ -39,19 +40,30 @@ export function findProjectRoot(startDir: string = process.cwd()): string { let current = path.resolve(startDir); const root = path.parse(current).root; - const markers = ['.git', 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', '.agentworkforce/relay']; - - while (current !== root) { - for (const marker of markers) { - if (fs.existsSync(path.join(current, marker))) { - return current; + const markers = ['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', '.agentworkforce/relay']; + let nearestPackage: string | undefined; + while (true) { + // Existing subproject pins remain intentional namespaces. A package.json + // alone must not split spawn, rebind, node up and attach across projects. + if (fs.existsSync(path.join(current, PROJECT_DATA_DIR, 'workspace-key.json'))) return current; + try { + const marker = fs.lstatSync(path.join(current, '.git')); + if (marker.isDirectory() || marker.isFile()) return current; + throw new Error('Invalid Git project marker.'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error('Cannot resolve the repository workspace; check access to its Git project marker.'); } } + for (const marker of markers) { + if (!nearestPackage && fs.existsSync(path.join(current, marker))) nearestPackage = current; + } + if (current === root) break; current = path.dirname(current); } // Fallback to start directory - return path.resolve(startDir); + return nearestPackage ?? path.resolve(startDir); } /** diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/case.json b/tests/relayflows/cases/1763-zero-config-sandbox/case.json new file mode 100644 index 0000000000..84ff50b23c --- /dev/null +++ b/tests/relayflows/cases/1763-zero-config-sandbox/case.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "id": "1763-zero-config-sandbox", + "kind": "feature", + "title": "Forward exact repository identity for zero-config sandbox provisioning", + "runner": { + "command": ["node", "tests/relayflows/cases/1763-zero-config-sandbox/run.mjs"] + }, + "requirements": [], + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "absent", + "signature": "sandbox_repository_revision_contract_absent" + }, + "head": { + "outcome": "fixed", + "signature": "sandbox_repository_revision_contract_forwarded" + } + } +} diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs new file mode 100644 index 0000000000..2aa48f91bf --- /dev/null +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -0,0 +1,141 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CASE_ID = '1763-zero-config-sandbox'; +const targetDir = requiredDirectory('RELAY_PR_PROOF_TARGET_DIR'); +const harnessDir = requiredDirectory('RELAY_PR_PROOF_HARNESS_DIR'); +const resultPath = requiredValue('RELAY_PR_PROOF_RESULT_PATH'); +const arm = requiredValue('RELAY_PR_PROOF_ARM'); +if (arm !== 'base' && arm !== 'head') throw new Error(`Invalid proof arm ${JSON.stringify(arm)}.`); + +const expectedSha = + arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; +const targetSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +if (!expectedSha || targetSha !== expectedSha) + throw new Error(`Target ${targetSha} is not expected ${arm} ${expectedSha}.`); +if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) + throw new Error('Runner is not from exact-head harness.'); + +const probePath = path.join(targetDir, 'packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'); +const configPath = path.join(targetDir, '.relayflow-1763-zero-config-sandbox.vitest.config.mjs'); +const observationPath = path.join(targetDir, '.relayflow-1763-zero-config-sandbox-observation.json'); +const revision = '0123456789abcdef0123456789abcdef01234567'; + +const probeSource = String.raw`import { afterEach, expect, test, vi } from 'vitest'; +import { writeFile } from 'node:fs/promises'; + +const mocks = vi.hoisted(() => ({ ensureCloudSession: vi.fn(), authorizedApiFetch: vi.fn() })); +vi.mock('./auth.js', () => ({ ensureCloudSession: mocks.ensureCloudSession, authorizedApiFetch: mocks.authorizedApiFetch })); +import { ensureCloudFleetSandbox } from './fleet-sandbox.js'; + +afterEach(() => vi.restoreAllMocks()); + +test('forwards the exact repository revision contract to Cloud', async () => { + const observationPath = process.env.RELAY_PR1763_OBSERVATION_PATH; + if (!observationPath) throw new Error('Missing observation path.'); + const auth = { accessToken: 'relayflow-proof', refreshToken: 'relayflow-proof-refresh', accessTokenExpiresAt: '2099-01-01T00:00:00Z', apiUrl: 'https://relayflow.invalid' }; + mocks.ensureCloudSession.mockResolvedValue({ auth, client: {} }); + mocks.authorizedApiFetch + .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99' }), auth }) + .mockResolvedValueOnce({ response: Response.json({ outcome: 'provisioned', nodeId: 'node-proof', nodeName: 'sandbox-proof', sandboxId: 'sandbox-proof', relayWorkspaceId: 'rw-proof', relayfileMounted: true, repoRevisions: { 'AgentWorkforce/relay': '${revision}' } }, { status: 201 }), auth }); + const result = await ensureCloudFleetSandbox({ + workspaceId: 'rw-proof', + requiredCapability: 'spawn:codex', + mountRelayfile: true, + repos: ['AgentWorkforce/relay'], + repoRevisions: { 'AgentWorkforce/relay': '${revision}' }, + }); + const request = mocks.authorizedApiFetch.mock.calls[1]?.[2]; + const body = request?.body ? JSON.parse(request.body) : {}; + await writeFile(observationPath, JSON.stringify({ + requestRepos: body.repos ?? null, + requestRepoRevisions: body.repoRevisions ?? null, + resultRepoRevisions: result.repoRevisions ?? null, + }), 'utf8'); +}); +`; +const configSource = `export default { test: { environment: 'node', include: ['packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'], setupFiles: [] } };\n`; + +try { + run( + 'npm', + ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], + targetDir, + 'Cloud dependency installation' + ); + run('npm', ['run', 'build:config'], targetDir, 'configuration package build'); + run('npm', ['run', 'build:cloud'], targetDir, 'Cloud package build'); + await writeGeneratedFile(probePath, probeSource); + await writeGeneratedFile(configPath, configSource); + run( + 'npm', + ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], + targetDir, + 'repository revision contract probe', + { RELAY_PR1763_OBSERVATION_PATH: observationPath } + ); + const observation = JSON.parse(await readFile(observationPath, 'utf8')); + const forwarded = + JSON.stringify(observation.requestRepos) === JSON.stringify(['AgentWorkforce/relay']) && + observation.requestRepoRevisions?.['AgentWorkforce/relay'] === revision && + observation.resultRepoRevisions?.['AgentWorkforce/relay'] === revision; + const absent = observation.requestRepoRevisions === null && observation.resultRepoRevisions === null; + const outcome = arm === 'base' && absent ? 'absent' : arm === 'head' && forwarded ? 'fixed' : null; + if (!outcome) + throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); + await mkdir(path.dirname(resultPath), { recursive: true }); + await writeFile( + resultPath, + `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The Cloud client forwarded and returned the exact repository revision without allocating a production sandbox.' : 'The base Cloud client omitted the repository revision contract, so zero-config exact checkout attestation was absent.' })}\n`, + 'utf8' + ); +} finally { + await rm(probePath, { force: true }); + await rm(configPath, { force: true }); + await rm(observationPath, { force: true }); +} + +function requiredValue(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing ${name}.`); + return value; +} +function requiredDirectory(name) { + return path.resolve(requiredValue(name)); +} +function isWithin(directory, candidate) { + const relative = path.relative(directory, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); +} +function run(command, args, cwd, label, extraEnv = {}) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...extraEnv }, + stdio: ['ignore', 'inherit', 'inherit'], + timeout: 5 * 60 * 1000, + }); + if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); + if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); +} +async function writeGeneratedFile(file, contents) { + try { + const existing = await lstat(file); + if (!existing.isFile()) throw new Error(`Refusing non-file ${file}.`); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; + const handle = await open(tmp, 'wx', 0o600); + try { + await handle.writeFile(contents, 'utf8'); + } finally { + await handle.close(); + } + await rename(tmp, file); +} From ce58a45bb5dd8f2b27028eabf956e175ab0fc7e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 12 Sep 2026 21:49:45 +0000 Subject: [PATCH 04/41] style: auto-format with Prettier Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index b18dc2893f..7620de1525 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -75,7 +75,12 @@ export { type ConnectProviderResult, } from './connect.js'; -export { createWorkspace, issueWorkspaceToken, resolveActiveWorkspace, resolveWorkspaceByKey } from './workspaces.js'; +export { + createWorkspace, + issueWorkspaceToken, + resolveActiveWorkspace, + resolveWorkspaceByKey, +} from './workspaces.js'; export { redactCredentialValues } from './redact.js'; export { ensureCloudFleetSandbox, From bd654d028337801576979a5b79297352cfb2bc0e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sat, 12 Sep 2026 23:58:00 +0200 Subject: [PATCH 05/41] fix(workspace): fail closed when Git namespace discovery fails Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../cloud/src/project-workspace-key.test.ts | 20 +++++++++++++++++++ packages/cloud/src/project-workspace-key.ts | 6 +----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index 4b2a771e04..54f11c2e19 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -285,6 +285,26 @@ describe('project workspace key resolution', () => { }); }); + it('fails closed on a malformed Git project marker instead of falling back globally', () => { + const env = { AGENT_RELAY_HOME: home }; + const malformedProject = path.join(root, 'malformed-git-project'); + fs.mkdirSync(malformedProject, { recursive: true }); + fs.symlinkSync(path.join(root, 'missing-git-metadata'), path.join(malformedProject, '.git')); + setWorkspaceKey('global', 'rk_global', env); + + const previousCwd = process.cwd(); + const previousProject = process.env.AGENT_RELAY_PROJECT; + delete process.env.AGENT_RELAY_PROJECT; + process.chdir(malformedProject); + try { + expect(() => resolveWorkspaceSelection({ env })).toThrow('Cannot resolve the repository workspace'); + } finally { + process.chdir(previousCwd); + if (previousProject === undefined) delete process.env.AGENT_RELAY_PROJECT; + else process.env.AGENT_RELAY_PROJECT = previousProject; + } + }); + it('records the absent project session snapshot for an active-store selection', () => { const env = { AGENT_RELAY_HOME: home }; setWorkspaceKey('global', 'rk_global', env); diff --git a/packages/cloud/src/project-workspace-key.ts b/packages/cloud/src/project-workspace-key.ts index a98de43d08..90cc9992d2 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -640,11 +640,7 @@ export function resolveWorkspaceKey(options: ResolveWorkspaceKeyOptions = {}): s } function projectDataDir(projectRoot: string | undefined): string | undefined { - try { - return getProjectPaths(projectRoot).dataDir; - } catch { - return undefined; - } + return getProjectPaths(projectRoot).dataDir; } function trimOrUndefined(value: string | undefined): string | undefined { From bd88e0a69010a1f9a2a36b4c9a81c4ca7bc21406 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 00:34:37 +0200 Subject: [PATCH 06/41] test: prove zero-config sandbox repository attestation Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../cases/1763-zero-config-sandbox/run.mjs | 183 +++++++----------- 1 file changed, 66 insertions(+), 117 deletions(-) diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index 2aa48f91bf..4c1a58631a 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -1,141 +1,90 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { appendFile, lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const CASE_ID = '1763-zero-config-sandbox'; +const INSTALL_TIMEOUT_MS = 8 * 60 * 1000; +const PROBE_TIMEOUT_MS = 5 * 60 * 1000; const targetDir = requiredDirectory('RELAY_PR_PROOF_TARGET_DIR'); const harnessDir = requiredDirectory('RELAY_PR_PROOF_HARNESS_DIR'); const resultPath = requiredValue('RELAY_PR_PROOF_RESULT_PATH'); const arm = requiredValue('RELAY_PR_PROOF_ARM'); if (arm !== 'base' && arm !== 'head') throw new Error(`Invalid proof arm ${JSON.stringify(arm)}.`); - -const expectedSha = - arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; +const expectedSha = arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; const targetSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); -if (!expectedSha || targetSha !== expectedSha) - throw new Error(`Target ${targetSha} is not expected ${arm} ${expectedSha}.`); -if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) - throw new Error('Runner is not from exact-head harness.'); +if (!expectedSha || targetSha !== expectedSha) throw new Error(`Target ${targetSha} is not expected ${arm} ${expectedSha}.`); +if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) throw new Error('Runner is not from exact-head harness.'); +let excludeState; const probePath = path.join(targetDir, 'packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'); -const configPath = path.join(targetDir, '.relayflow-1763-zero-config-sandbox.vitest.config.mjs'); +const configPath = path.join(targetDir, '.relayflow', '1763-zero-config-sandbox.vitest.config.mjs'); const observationPath = path.join(targetDir, '.relayflow-1763-zero-config-sandbox-observation.json'); -const revision = '0123456789abcdef0123456789abcdef01234567'; - -const probeSource = String.raw`import { afterEach, expect, test, vi } from 'vitest'; +const revision = expectedSha; +const configSource = `import path from 'node:path'; +const names = ['cloud','config','fleet','harness-driver','harnesses','policy','sdk','session','utils']; +export default { resolve: { alias: names.flatMap((name) => { const root = path.resolve(process.cwd(), 'packages', name, 'src'); return [{ find: new RegExp('^@agent-relay/' + name + '/(.+)$'), replacement: root + '/$1' }, { find: '@agent-relay/' + name, replacement: path.join(root, 'index.ts') }]; }) }, test: { environment: 'node', include: ['packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'], setupFiles: [] } }; +`; +const probeSource = String.raw`import { expect, test, vi } from 'vitest'; import { writeFile } from 'node:fs/promises'; - +import { Command } from 'commander'; const mocks = vi.hoisted(() => ({ ensureCloudSession: vi.fn(), authorizedApiFetch: vi.fn() })); vi.mock('./auth.js', () => ({ ensureCloudSession: mocks.ensureCloudSession, authorizedApiFetch: mocks.authorizedApiFetch })); -import { ensureCloudFleetSandbox } from './fleet-sandbox.js'; - -afterEach(() => vi.restoreAllMocks()); - -test('forwards the exact repository revision contract to Cloud', async () => { - const observationPath = process.env.RELAY_PR1763_OBSERVATION_PATH; - if (!observationPath) throw new Error('Missing observation path.'); - const auth = { accessToken: 'relayflow-proof', refreshToken: 'relayflow-proof-refresh', accessTokenExpiresAt: '2099-01-01T00:00:00Z', apiUrl: 'https://relayflow.invalid' }; +vi.mock('../../cli/src/cli/lib/broker-lifecycle.js', () => ({ readBrokerConnection: vi.fn(() => ({ url: 'http://127.0.0.1:1', api_key: 'probe', pid: 1, port: 1 })) })); +vi.mock('@agent-relay/harness-driver', async (importOriginal) => ({ ...(await importOriginal()), HarnessDriverClient: class { async getSession() { return { workspace_key: 'probe', node_token: 'probe', node_id: 'node', node_name: 'node', broker_version: '1', protocol_version: 2, mode: 'persist', uptime_secs: 1 }; } async listAgents() { return []; } async listFleetInventory() { return { nodeName: 'node', agents: [] }; } disconnect() {} } })); +import { registerFleetCommands } from '../../cli/src/cli/commands/fleet.js'; +const revision = '${revision}'; +const auth = { accessToken: 'probe', refreshToken: 'probe', accessTokenExpiresAt: '2099-01-01T00:00:00Z', apiUrl: 'https://relayflow.invalid' }; +test('fleet sandbox CLI forwards exact repo revision and uses returned provider identity for cleanup', async () => { + const output = process.env.RELAY_PR1763_OBSERVATION_PATH; + if (!output) throw new Error('Missing observation path.'); + const requests = []; mocks.ensureCloudSession.mockResolvedValue({ auth, client: {} }); - mocks.authorizedApiFetch - .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99' }), auth }) - .mockResolvedValueOnce({ response: Response.json({ outcome: 'provisioned', nodeId: 'node-proof', nodeName: 'sandbox-proof', sandboxId: 'sandbox-proof', relayWorkspaceId: 'rw-proof', relayfileMounted: true, repoRevisions: { 'AgentWorkforce/relay': '${revision}' } }, { status: 201 }), auth }); - const result = await ensureCloudFleetSandbox({ - workspaceId: 'rw-proof', - requiredCapability: 'spawn:codex', - mountRelayfile: true, - repos: ['AgentWorkforce/relay'], - repoRevisions: { 'AgentWorkforce/relay': '${revision}' }, + mocks.authorizedApiFetch.mockImplementation(async (_auth, _path, request) => { + const body = request?.body ? JSON.parse(request.body) : null; + requests.push({ body }); + if (requests.length === 1) return { response: Response.json({ cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99' }), auth }; + return { response: Response.json({ outcome: 'provisioned', cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', nodeId: 'node-proof', nodeName: body?.name ?? 'sandbox-proof', sandboxId: body?.sandboxId ?? 'sbx_123e4567-e89b-42d3-a456-426614174000', relayWorkspaceId: 'rw-proof', relayfileMounted: true, providerId: 'agent37', relaycastTarget: { route: 'agent37-isolated', baseUrl: 'https://agent37-cast.agentrelay.com', workspaceId: 'rw-proof', relaycastApiKey: 'rk_live_probe' }, repoRevisions: body?.repoRevisions ?? undefined }, { status: 201 }), auth }; + }); + const logs = [], warnings = [], deletes = [], releases = []; + const program = new Command(); program.exitOverride(); + registerFleetCommands(program, { + core: { getProjectPaths: () => ({ projectRoot: process.cwd() }), env: {} }, + resolveWorkspaceSelection: () => ({ workspaceId: 'rw-proof', key: 'probe-key', source: 'project' }), + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn(async () => { throw new Error('synthetic dispatch failure'); }) } } })), + createWorkspaceRelay: vi.fn(() => ({ workspace: { info: vi.fn(async () => ({ id: 'rw-proof' })), register: vi.fn(async () => ({ token: 'launcher' })), release: vi.fn(async (input) => { releases.push(input); return { deleted: true }; }) } })), + createWorkspace: vi.fn(), log: (value) => logs.push(String(value)), error: vi.fn(), exit: vi.fn((code) => { throw new Error('CLI exit ' + code); }), + }, + deleteCloudFleetSandbox: vi.fn(async (input) => { deletes.push(input); }), + persistWorkspaceRelaycastTarget: () => true, + log: () => undefined, warn: (...args) => warnings.push(args.join(' ')), error: () => undefined, }); - const request = mocks.authorizedApiFetch.mock.calls[1]?.[2]; - const body = request?.body ? JSON.parse(request.body) : {}; - await writeFile(observationPath, JSON.stringify({ - requestRepos: body.repos ?? null, - requestRepoRevisions: body.repoRevisions ?? null, - resultRepoRevisions: result.repoRevisions ?? null, - }), 'utf8'); + await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); + const body = requests[1]?.body ?? {}; + await writeFile(output, JSON.stringify({ requestCount: requests.length, requestRepos: body.repos ?? null, requestRepoRevisions: body.repoRevisions ?? null, resultRepoRevisions: body.repoRevisions ?? null, workloadProfile: body.workloadProfile ?? null, cleanupProviderIds: deletes.map((x) => x.providerId ?? null), launcherReleases: releases.length, warnings }, null, 2)); + expect(releases.length).toBe(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); + if (${JSON.stringify(arm)} === 'head') { expect(body.repos).toEqual(['AgentWorkforce/relay']); expect(body.repoRevisions).toEqual({ 'AgentWorkforce/relay': revision }); expect(body.workloadProfile).toBe('long-running-agent'); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } + else { expect(body.repoRevisions ?? null).toBe(null); expect(body.workloadProfile).toBe('long-running-agent'); } }); `; -const configSource = `export default { test: { environment: 'node', include: ['packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'], setupFiles: [] } };\n`; - try { - run( - 'npm', - ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], - targetDir, - 'Cloud dependency installation' - ); - run('npm', ['run', 'build:config'], targetDir, 'configuration package build'); - run('npm', ['run', 'build:cloud'], targetDir, 'Cloud package build'); - await writeGeneratedFile(probePath, probeSource); - await writeGeneratedFile(configPath, configSource); - run( - 'npm', - ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], - targetDir, - 'repository revision contract probe', - { RELAY_PR1763_OBSERVATION_PATH: observationPath } - ); + if (process.env.RELAY_PR1763_SKIP_INSTALL !== '1') run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], targetDir, 'Cloud dependency installation', INSTALL_TIMEOUT_MS); + excludeState = await prepareGitExclude(targetDir); + await writeGeneratedFile(probePath, probeSource); await mkdir(path.dirname(configPath), { recursive: true }); await writeGeneratedFile(configPath, configSource); + run('npm', ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], targetDir, 'CLI repository revision proof', PROBE_TIMEOUT_MS, { RELAY_PR1763_OBSERVATION_PATH: observationPath }); const observation = JSON.parse(await readFile(observationPath, 'utf8')); - const forwarded = - JSON.stringify(observation.requestRepos) === JSON.stringify(['AgentWorkforce/relay']) && - observation.requestRepoRevisions?.['AgentWorkforce/relay'] === revision && - observation.resultRepoRevisions?.['AgentWorkforce/relay'] === revision; - const absent = observation.requestRepoRevisions === null && observation.resultRepoRevisions === null; - const outcome = arm === 'base' && absent ? 'absent' : arm === 'head' && forwarded ? 'fixed' : null; - if (!outcome) - throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); + const forwarded = JSON.stringify(observation.requestRepoRevisions) === JSON.stringify({ 'AgentWorkforce/relay': revision }); + const absent = observation.requestRepoRevisions === null; + const outcome = arm === 'head' && forwarded ? 'fixed' : arm === 'base' && absent ? 'absent' : null; + if (!outcome) throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); await mkdir(path.dirname(resultPath), { recursive: true }); - await writeFile( - resultPath, - `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The Cloud client forwarded and returned the exact repository revision without allocating a production sandbox.' : 'The base Cloud client omitted the repository revision contract, so zero-config exact checkout attestation was absent.' })}\n`, - 'utf8' - ); -} finally { - await rm(probePath, { force: true }); - await rm(configPath, { force: true }); - await rm(observationPath, { force: true }); -} - -function requiredValue(name) { - const value = process.env[name]?.trim(); - if (!value) throw new Error(`Missing ${name}.`); - return value; -} -function requiredDirectory(name) { - return path.resolve(requiredValue(name)); -} -function isWithin(directory, candidate) { - const relative = path.relative(directory, candidate); - return ( - relative === '' || - (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) - ); -} -function run(command, args, cwd, label, extraEnv = {}) { - const result = spawnSync(command, args, { - cwd, - env: { ...process.env, ...extraEnv }, - stdio: ['ignore', 'inherit', 'inherit'], - timeout: 5 * 60 * 1000, - }); - if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); - if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); -} -async function writeGeneratedFile(file, contents) { - try { - const existing = await lstat(file); - if (!existing.isFile()) throw new Error(`Refusing non-file ${file}.`); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } - const tmp = `${file}.tmp-${process.pid}-${randomUUID()}`; - const handle = await open(tmp, 'wx', 0o600); - try { - await handle.writeFile(contents, 'utf8'); - } finally { - await handle.close(); - } - await rename(tmp, file); -} + await writeFile(resultPath, `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the exact repository revision contract.' })}\n`); +} finally { await rm(probePath, { force: true }); await rm(configPath, { force: true }); await rm(observationPath, { force: true }); if (excludeState) await restoreGitExclude(excludeState); } +function requiredValue(name) { const value = process.env[name]?.trim(); if (!value) throw new Error(`Missing ${name}.`); return value; } +function requiredDirectory(name) { return path.resolve(requiredValue(name)); } +function isWithin(directory, candidate) { const relative = path.relative(directory, candidate); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } +function run(command, args, cwd, label, timeoutMs, extraEnv = {}) { const result = spawnSync(command, args, { cwd, env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'inherit', 'inherit'], timeout: timeoutMs }); if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); } +async function prepareGitExclude(root) { const raw = execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim(); const file = path.isAbsolute(raw) ? raw : path.resolve(root, raw); let original = null; try { original = await readFile(file); } catch (error) { if (error?.code !== 'ENOENT') throw error; } const marker = Buffer.from('\n# relayflow-1763 generated probe\n.relayflow-1763-zero-config-sandbox-observation.json\npackages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts\n.relayflow/1763-zero-config-sandbox.vitest.config.mjs\nnode_modules\n'); const existing = original ?? Buffer.alloc(0); if (!existing.includes(marker)) await appendFile(file, marker); return { file, original }; } +async function restoreGitExclude(state) { if (state.original === null) { await rm(state.file, { force: true }); } else { await writeFile(state.file, state.original); } } +async function writeGeneratedFile(file, contents) { try { const existing = await lstat(file); if (!existing.isFile()) throw new Error(`Refusing non-file ${file}.`); } catch (error) { if (error?.code !== 'ENOENT') throw error; } const tmp = `${file}.tmp-${process.pid}`; const handle = await open(tmp, 'wx', 0o600); try { await handle.writeFile(contents, 'utf8'); } finally { await handle.close(); } await rename(tmp, file); } From 50708171641e474d49494dfaaf9c24933549f269 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 00:38:22 +0200 Subject: [PATCH 07/41] fix: close sandbox credential transport and namespace review gaps Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../cli/src/cli/commands/local-agent.test.ts | 4 +- packages/cli/src/cli/commands/local-agent.ts | 4 +- packages/cloud/src/fleet-sandbox.test.ts | 23 ++++++ packages/cloud/src/fleet-sandbox.ts | 24 +++++- packages/cloud/src/workspace-store.test.ts | 78 ++++++++++++++---- packages/cloud/src/workspace-store.ts | 13 +++ packages/cloud/src/workspaces.test.ts | 79 +++++++++++++++++++ packages/cloud/src/workspaces.ts | 28 ++++++- packages/config/src/project-namespace.test.ts | 12 ++- packages/config/src/project-namespace.ts | 19 ++++- 10 files changed, 261 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index b8b873f813..7e5a2fa55b 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -944,7 +944,9 @@ describe('local agent subtree', () => { { from: 'user' } ); expect(error).toHaveBeenCalledWith( - expect.stringContaining('--workspace-key requires an explicit --node') + expect.stringContaining( + 'To target the local broker instead, use --broker-url / --api-key or read connection.json from --state-dir' + ) ); expect(connectLocal).not.toHaveBeenCalled(); } diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index 1d55fd8bee..e32e0f9727 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -623,7 +623,9 @@ async function withDeliveryModeClient( ): Promise { let node = typeof opts.node === 'string' && opts.node.trim() ? opts.node.trim() : undefined; if (!node && opts.workspaceKey !== undefined) { - deps.error('Error: --workspace-key requires an explicit --node.'); + deps.error( + 'Error: --workspace-key requires an explicit --node. To target the local broker instead, use --broker-url / --api-key or read connection.json from --state-dir.' + ); deps.exit(1); return undefined; } diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 675d5e3fe6..588c3e0e16 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -981,6 +981,29 @@ describe('Cloud fleet sandbox client', () => { expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); }); + it('rejects duplicate repositories before Cloud authentication', async () => { + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos: ['AgentWorkforce/relay', 'AgentWorkforce/relay'], + }) + ).rejects.toThrow('must not contain duplicates'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + }); + + it('rejects more than sixteen repositories before Cloud authentication', async () => { + const repos = Array.from({ length: 17 }, (_, index) => `AgentWorkforce/repo-${index}`); + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos, + }) + ).rejects.toThrow('at most 16 repositories'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + }); + it('rejects an explicitly empty Relayfile path list before provisioning', async () => { await expect( ensureCloudFleetSandbox({ diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 591e1fd7c0..5f70663540 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -351,6 +351,20 @@ function requiredString(payload: JsonRecord, key: string, context: string): stri const REPOSITORY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/; const REPOSITORY_REVISION_PATTERN = /^[0-9a-f]{40}$/; +function validateRequestedRepos(repos: readonly string[] | undefined): void { + if (repos === undefined || repos.length === 0) return; + if (repos.length > 16) { + throw new Error('Cloud fleet sandbox requests may include at most 16 repositories.'); + } + const seen = new Set(); + for (const repo of repos) { + if (seen.has(repo)) { + throw new Error('Cloud fleet sandbox repositories must not contain duplicates.'); + } + seen.add(repo); + } +} + function validateRepoRevisions( repos: readonly string[] | undefined, repoRevisions: Readonly> | undefined @@ -358,7 +372,14 @@ function validateRepoRevisions( if (repoRevisions === undefined) return undefined; const entries = Object.entries(repoRevisions); const allowedRepos = new Set(repos ?? []); - if (entries.length === 0 || entries.length > 16 || entries.length !== allowedRepos.size) { + if ( + entries.length === 0 || + entries.length > 16 || + repos === undefined || + repos.length > 16 || + repos.length !== allowedRepos.size || + entries.length !== allowedRepos.size + ) { throw new Error( 'Cloud fleet sandbox revisions must cover every requested repository exactly once (maximum 16).' ); @@ -665,6 +686,7 @@ export async function ensureCloudFleetSandbox( if (input.relayfilePaths !== undefined && input.relayfilePaths.length === 0) { throw new Error('At least one Relayfile subtree path is required when relayfilePaths is provided.'); } + validateRequestedRepos(input.repos); const repoRevisions = validateRepoRevisions(input.repos, input.repoRevisions); const session = await ensureCloudSession({ diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index f8affaf621..31aca1439e 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -69,6 +69,50 @@ describe('workspace store', () => { expect(mode).toBe(0o600); }); + it.skipIf(process.platform === 'win32')( + 'rejects a credential parent that is group or world writable', + () => { + const originalMode = fs.statSync(dir).mode & 0o777; + try { + fs.chmodSync(dir, 0o777); + expect(() => + writeRelaycastCredential('must-reject-insecure-parent', { + workspaceId: 'rw_insecure_parent', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_insecure_parent', + }) + ).toThrow(/without group or other write permissions/); + } finally { + fs.chmodSync(dir, originalMode); + } + } + ); + + it('rejects a symlinked credential parent', () => { + const target = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-ws-parent-target-')); + const linkContainer = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-ws-parent-link-')); + const link = path.join(linkContainer, 'home'); + fs.symlinkSync(target, link, 'dir'); + const previousHome = process.env.AGENT_RELAY_HOME; + process.env.AGENT_RELAY_HOME = link; + try { + expect(() => + writeRelaycastCredential('must-reject-symlink-parent', { + workspaceId: 'rw_symlink_parent', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_symlink_parent', + }) + ).toThrow(/without group or other write permissions/); + } finally { + if (previousHome === undefined) delete process.env.AGENT_RELAY_HOME; + else process.env.AGENT_RELAY_HOME = previousHome; + fs.rmSync(linkContainer, { recursive: true, force: true }); + fs.rmSync(target, { recursive: true, force: true }); + } + }); + it('stores route credentials outside the project with a scoped reference', () => { const ref = relaycastCredentialRef( '/checkout/.agentworkforce/relay', @@ -176,21 +220,25 @@ describe('workspace store', () => { it('refuses to inspect or clean a stale credential lock symlink', () => { const lock = `${relaycastCredentialStorePath()}.lock`; const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-lock-target-')); - const sentinel = path.join(outside, 'keep.txt'); - fs.writeFileSync(sentinel, 'keep'); - fs.symlinkSync(outside, lock, 'dir'); - const staleAt = new Date(Date.now() - 60_000); - fs.lutimesSync(lock, staleAt, staleAt); - - expect(() => - writeRelaycastCredential('must-fail-closed', { - workspaceId: 'rw_must_fail_closed', - route: 'canonical', - baseUrl: 'https://relay.example', - apiKey: 'rk_live_must_fail_closed', - }) - ).toThrow(/real directory/); - expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep'); + try { + const sentinel = path.join(outside, 'keep.txt'); + fs.writeFileSync(sentinel, 'keep'); + fs.symlinkSync(outside, lock, 'dir'); + const staleAt = new Date(Date.now() - 60_000); + fs.lutimesSync(lock, staleAt, staleAt); + + expect(() => + writeRelaycastCredential('must-fail-closed', { + workspaceId: 'rw_must_fail_closed', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_must_fail_closed', + }) + ).toThrow(/real directory/); + expect(fs.readFileSync(sentinel, 'utf8')).toBe('keep'); + } finally { + fs.rmSync(outside, { recursive: true, force: true }); + } }); it('scopes route references to the selected endpoint', () => { diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index d0d9022988..54a1b95189 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -116,6 +116,19 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { const ownerPath = path.join(lock, ownerToken); const startedAt = Date.now(); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + // The parent is the credential boundary: other users must not be able to + // replace lock entries between inspection and cleanup. + const directoryInfo = fs.lstatSync(directory); + if ( + !directoryInfo.isDirectory() || + directoryInfo.isSymbolicLink() || + (process.platform !== 'win32' && + (directoryInfo.uid !== process.getuid?.() || (directoryInfo.mode & 0o022) !== 0)) + ) { + throw new Error( + 'Relaycast credential storage requires a directory owned by the current user without group or other write permissions.' + ); + } while (true) { try { fs.mkdirSync(lock, { mode: 0o700 }); diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 8771128bff..97324e7f40 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -113,11 +115,88 @@ describe('resolveWorkspaceByKey', () => { expect(String(request)).toBe('https://cloud.example.test/api/v1/workspaces/current/resolve'); expect(String(request)).not.toContain('rk_live_selected'); expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).redirect).toBe('error'); expect(JSON.parse(String((init as RequestInit).body))).toEqual({ workspaceKey: 'rk_live_selected', }); }); + it.each(['http://cloud.example.test', 'https://user:password@cloud.example.test', 'file:///tmp/cloud'])( + 'rejects unsafe resolver transport before any credential request: %s', + async (apiUrl) => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + await expect(resolveWorkspaceByKey('rk_live_selected', { apiUrl })).rejects.toThrow('requires HTTPS'); + expect(fetchSpy).not.toHaveBeenCalled(); + } + ); + + it.each([307, 308])('never forwards a workspace key to a redirect target (%s)', async (status) => { + let forwardedRequests = 0; + let sourceRequests = 0; + const target = createServer((_request, response) => { + forwardedRequests++; + response.end('{}'); + }); + target.listen(0, '127.0.0.1'); + await once(target, 'listening'); + const targetAddress = target.address() as { port: number }; + const source = createServer((_request, response) => { + sourceRequests++; + response.writeHead(status, { location: `http://127.0.0.1:${targetAddress.port}/capture` }); + response.end(); + }); + source.listen(0, '127.0.0.1'); + await once(source, 'listening'); + const sourceAddress = source.address() as { port: number }; + process.env.CLOUD_API_URL = `http://127.0.0.1:${sourceAddress.port}`; + try { + await expect(resolveWorkspaceByKey('rk_live_selected')).rejects.toThrow(); + expect(sourceRequests).toBe(1); + expect(forwardedRequests).toBe(0); + } finally { + source.closeAllConnections(); + target.closeAllConnections(); + await Promise.all([ + new Promise((resolve) => source.close(() => resolve())), + new Promise((resolve) => target.close(() => resolve())), + ]); + } + }); + + it('rejects an insecure stored session host before refreshing its credentials', async () => { + process.env.CLOUD_API_URL = 'http://insecure.example.test'; + process.env.CLOUD_API_ACCESS_TOKEN_EXPIRES_AT = '2000-01-01T00:00:00.000Z'; + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + await expect( + resolveWorkspaceByKey('rk_live_selected', { apiUrl: 'https://cloud.example.test' }) + ).rejects.toThrow('requires HTTPS'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('accepts the canonical Cloud relaycastApiKey echo without a legacy key alias', async () => { + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + workspace: { + ...resolvedWorkspace.workspace, + key: undefined, + relaycastApiKey: 'rk_live_selected', + }, + }), + { status: 200 } + ) + ) + ); + await expect(resolveWorkspaceByKey('rk_live_selected')).resolves.toMatchObject({ + key: 'rk_live_selected', + }); + }); + it('fails closed when Cloud returns a descriptor for a different selected key', async () => { const fetchSpy = vi.fn( async () => diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index e29e25cf64..0558424ca8 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -1,4 +1,4 @@ -import { authorizedApiFetch, ensureAuthenticated } from './auth.js'; +import { authorizedApiFetch, ensureAuthenticated, readStoredAuth } from './auth.js'; import { redactCredentialValues } from './redact.js'; import { type ActiveWorkspaceDescriptor, @@ -342,6 +342,25 @@ export async function issueWorkspaceToken( ); } +function assertWorkspaceResolverTransport(apiUrl: string): void { + let url: URL; + try { + url = new URL(apiUrl); + } catch { + throw new Error('Project workspace resolution requires a valid Cloud API URL.'); + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + url.username || + url.password || + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) + ) { + throw new Error( + 'Project workspace resolution requires HTTPS (HTTP is allowed only for a local development server).' + ); + } +} + /** Resolve a selected project pin without exposing its key in URLs or changing the active workspace. */ export async function resolveWorkspaceByKey( workspaceKey: string, @@ -350,16 +369,23 @@ export async function resolveWorkspaceByKey( const key = workspaceKey.trim(); if (!/^rk_live_[A-Za-z0-9_-]{1,512}$/.test(key)) throw new Error('A valid workspace key is required.'); const apiUrl = options.apiUrl || defaultApiUrl(); + assertWorkspaceResolverTransport(apiUrl); + // Stored sessions keep their own API host; validate it before a refresh can + // send credentials, even when the requested/default host is secure. + const stored = await readStoredAuth(); + if (stored) assertWorkspaceResolverTransport(stored.apiUrl); const auth = await ensureAuthenticated(apiUrl, { interactive: false, refreshTimeoutMs: options.refreshTimeoutMs, }); + assertWorkspaceResolverTransport(auth.apiUrl); const endpoint = '/api/v1/workspaces/current/resolve'; const { response } = await authorizedApiFetch( auth, endpoint, { method: 'POST', + redirect: 'error', body: JSON.stringify({ workspaceKey: key }), signal: AbortSignal.timeout(options.refreshTimeoutMs ?? 30_000), }, diff --git a/packages/config/src/project-namespace.test.ts b/packages/config/src/project-namespace.test.ts index 81b5d8fa15..eb4b573c43 100644 --- a/packages/config/src/project-namespace.test.ts +++ b/packages/config/src/project-namespace.test.ts @@ -42,8 +42,18 @@ describe('shared repository workspace boundary', () => { fs.writeFileSync(path.join(root, '.git'), 'gitdir: /unused/metadata'); expect(findProjectRoot(nested)).toBe(root); }); + it('accepts a valid symlinked Git marker', () => { + const gitTarget = path.join(root, 'gitdir-file'); + fs.writeFileSync(gitTarget, 'gitdir: /valid/worktree/metadata'); + fs.symlinkSync(gitTarget, path.join(root, '.git')); + + expect(findProjectRoot(root)).toBe(root); + }); it('fails closed for a malformed Git marker rather than selecting a package workspace', () => { + const nested = path.join(root, 'packages', 'web'); + fs.mkdirSync(nested, { recursive: true }); + fs.writeFileSync(path.join(nested, 'package.json'), '{}'); fs.symlinkSync('/nonexistent/git', path.join(root, '.git')); - expect(() => findProjectRoot(root)).toThrow('Cannot resolve the repository workspace'); + expect(() => findProjectRoot(nested)).toThrow('Cannot resolve the repository workspace'); }); }); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index ff99700d12..3d261d2235 100644 --- a/packages/config/src/project-namespace.ts +++ b/packages/config/src/project-namespace.ts @@ -46,15 +46,28 @@ export function findProjectRoot(startDir: string = process.cwd()): string { // Existing subproject pins remain intentional namespaces. A package.json // alone must not split spawn, rebind, node up and attach across projects. if (fs.existsSync(path.join(current, PROJECT_DATA_DIR, 'workspace-key.json'))) return current; + const gitMarkerPath = path.join(current, '.git'); + let marker: fs.Stats | undefined; try { - const marker = fs.lstatSync(path.join(current, '.git')); - if (marker.isDirectory() || marker.isFile()) return current; - throw new Error('Invalid Git project marker.'); + marker = fs.lstatSync(gitMarkerPath); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw new Error('Cannot resolve the repository workspace; check access to its Git project marker.'); } } + if (marker) { + if (marker.isDirectory() || marker.isFile()) return current; + if (marker.isSymbolicLink()) { + try { + const target = fs.statSync(gitMarkerPath); + if (target.isDirectory() || target.isFile()) return current; + } catch { + // A dangling or inaccessible .git symlink is a malformed Git marker, + // not evidence that this directory is outside a checkout. + } + } + throw new Error('Cannot resolve the repository workspace; check access to its Git project marker.'); + } for (const marker of markers) { if (!nearestPackage && fs.existsSync(path.join(current, marker))) nearestPackage = current; } From 8960b8027facf0475d1973c16829235a181a8e97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 12 Sep 2026 22:40:21 +0000 Subject: [PATCH 08/41] style: auto-format with Prettier Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../cases/1763-zero-config-sandbox/run.mjs | 122 +++++++++++++++--- 1 file changed, 105 insertions(+), 17 deletions(-) diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index 4c1a58631a..ee67bc9b54 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -11,10 +11,13 @@ const harnessDir = requiredDirectory('RELAY_PR_PROOF_HARNESS_DIR'); const resultPath = requiredValue('RELAY_PR_PROOF_RESULT_PATH'); const arm = requiredValue('RELAY_PR_PROOF_ARM'); if (arm !== 'base' && arm !== 'head') throw new Error(`Invalid proof arm ${JSON.stringify(arm)}.`); -const expectedSha = arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; +const expectedSha = + arm === 'base' ? process.env.RELAY_PR_PROOF_BASE_SHA : process.env.RELAY_PR_PROOF_HEAD_SHA; const targetSha = execFileSync('git', ['-C', targetDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); -if (!expectedSha || targetSha !== expectedSha) throw new Error(`Target ${targetSha} is not expected ${arm} ${expectedSha}.`); -if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) throw new Error('Runner is not from exact-head harness.'); +if (!expectedSha || targetSha !== expectedSha) + throw new Error(`Target ${targetSha} is not expected ${arm} ${expectedSha}.`); +if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) + throw new Error('Runner is not from exact-head harness.'); let excludeState; const probePath = path.join(targetDir, 'packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'); @@ -69,22 +72,107 @@ test('fleet sandbox CLI forwards exact repo revision and uses returned provider }); `; try { - if (process.env.RELAY_PR1763_SKIP_INSTALL !== '1') run('npm', ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], targetDir, 'Cloud dependency installation', INSTALL_TIMEOUT_MS); + if (process.env.RELAY_PR1763_SKIP_INSTALL !== '1') + run( + 'npm', + ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], + targetDir, + 'Cloud dependency installation', + INSTALL_TIMEOUT_MS + ); excludeState = await prepareGitExclude(targetDir); - await writeGeneratedFile(probePath, probeSource); await mkdir(path.dirname(configPath), { recursive: true }); await writeGeneratedFile(configPath, configSource); - run('npm', ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], targetDir, 'CLI repository revision proof', PROBE_TIMEOUT_MS, { RELAY_PR1763_OBSERVATION_PATH: observationPath }); + await writeGeneratedFile(probePath, probeSource); + await mkdir(path.dirname(configPath), { recursive: true }); + await writeGeneratedFile(configPath, configSource); + run( + 'npm', + ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], + targetDir, + 'CLI repository revision proof', + PROBE_TIMEOUT_MS, + { RELAY_PR1763_OBSERVATION_PATH: observationPath } + ); const observation = JSON.parse(await readFile(observationPath, 'utf8')); - const forwarded = JSON.stringify(observation.requestRepoRevisions) === JSON.stringify({ 'AgentWorkforce/relay': revision }); + const forwarded = + JSON.stringify(observation.requestRepoRevisions) === JSON.stringify({ 'AgentWorkforce/relay': revision }); const absent = observation.requestRepoRevisions === null; const outcome = arm === 'head' && forwarded ? 'fixed' : arm === 'base' && absent ? 'absent' : null; - if (!outcome) throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); + if (!outcome) + throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); await mkdir(path.dirname(resultPath), { recursive: true }); - await writeFile(resultPath, `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the exact repository revision contract.' })}\n`); -} finally { await rm(probePath, { force: true }); await rm(configPath, { force: true }); await rm(observationPath, { force: true }); if (excludeState) await restoreGitExclude(excludeState); } -function requiredValue(name) { const value = process.env[name]?.trim(); if (!value) throw new Error(`Missing ${name}.`); return value; } -function requiredDirectory(name) { return path.resolve(requiredValue(name)); } -function isWithin(directory, candidate) { const relative = path.relative(directory, candidate); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } -function run(command, args, cwd, label, timeoutMs, extraEnv = {}) { const result = spawnSync(command, args, { cwd, env: { ...process.env, ...extraEnv }, stdio: ['ignore', 'inherit', 'inherit'], timeout: timeoutMs }); if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); } -async function prepareGitExclude(root) { const raw = execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim(); const file = path.isAbsolute(raw) ? raw : path.resolve(root, raw); let original = null; try { original = await readFile(file); } catch (error) { if (error?.code !== 'ENOENT') throw error; } const marker = Buffer.from('\n# relayflow-1763 generated probe\n.relayflow-1763-zero-config-sandbox-observation.json\npackages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts\n.relayflow/1763-zero-config-sandbox.vitest.config.mjs\nnode_modules\n'); const existing = original ?? Buffer.alloc(0); if (!existing.includes(marker)) await appendFile(file, marker); return { file, original }; } -async function restoreGitExclude(state) { if (state.original === null) { await rm(state.file, { force: true }); } else { await writeFile(state.file, state.original); } } -async function writeGeneratedFile(file, contents) { try { const existing = await lstat(file); if (!existing.isFile()) throw new Error(`Refusing non-file ${file}.`); } catch (error) { if (error?.code !== 'ENOENT') throw error; } const tmp = `${file}.tmp-${process.pid}`; const handle = await open(tmp, 'wx', 0o600); try { await handle.writeFile(contents, 'utf8'); } finally { await handle.close(); } await rename(tmp, file); } + await writeFile( + resultPath, + `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the exact repository revision contract.' })}\n` + ); +} finally { + await rm(probePath, { force: true }); + await rm(configPath, { force: true }); + await rm(observationPath, { force: true }); + if (excludeState) await restoreGitExclude(excludeState); +} +function requiredValue(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing ${name}.`); + return value; +} +function requiredDirectory(name) { + return path.resolve(requiredValue(name)); +} +function isWithin(directory, candidate) { + const relative = path.relative(directory, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); +} +function run(command, args, cwd, label, timeoutMs, extraEnv = {}) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, ...extraEnv }, + stdio: ['ignore', 'inherit', 'inherit'], + timeout: timeoutMs, + }); + if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); + if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); +} +async function prepareGitExclude(root) { + const raw = execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { + encoding: 'utf8', + }).trim(); + const file = path.isAbsolute(raw) ? raw : path.resolve(root, raw); + let original = null; + try { + original = await readFile(file); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const marker = Buffer.from( + '\n# relayflow-1763 generated probe\n.relayflow-1763-zero-config-sandbox-observation.json\npackages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts\n.relayflow/1763-zero-config-sandbox.vitest.config.mjs\nnode_modules\n' + ); + const existing = original ?? Buffer.alloc(0); + if (!existing.includes(marker)) await appendFile(file, marker); + return { file, original }; +} +async function restoreGitExclude(state) { + if (state.original === null) { + await rm(state.file, { force: true }); + } else { + await writeFile(state.file, state.original); + } +} +async function writeGeneratedFile(file, contents) { + try { + const existing = await lstat(file); + if (!existing.isFile()) throw new Error(`Refusing non-file ${file}.`); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + const tmp = `${file}.tmp-${process.pid}`; + const handle = await open(tmp, 'wx', 0o600); + try { + await handle.writeFile(contents, 'utf8'); + } finally { + await handle.close(); + } + await rename(tmp, file); +} From 684fb33c6a6cc7dacbc49edebb98ee563abe3d31 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 00:58:15 +0200 Subject: [PATCH 09/41] fix(cloud): harden sandbox repo and resolver inputs Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/fleet-sandbox.test.ts | 22 +++++++++++++++++++ packages/cloud/src/fleet-sandbox.ts | 12 +++++++++-- packages/cloud/src/workspaces.test.ts | 27 ++++++++++++++++++++++++ packages/cloud/src/workspaces.ts | 13 +++++++----- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 588c3e0e16..fa4560e196 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -992,6 +992,28 @@ describe('Cloud fleet sandbox client', () => { expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); }); + it('rejects case-insensitive duplicate repositories before Cloud authentication', async () => { + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos: ['AgentWorkforce/relay', 'agentworkforce/RELAY'], + }) + ).rejects.toThrow('must not contain duplicates'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + }); + + it('rejects checkout basename collisions before Cloud authentication', async () => { + await expect( + ensureCloudFleetSandbox({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + repos: ['owner-a/tools', 'owner-b/tools'], + }) + ).rejects.toThrow('unique checkout names'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + }); + it('rejects more than sixteen repositories before Cloud authentication', async () => { const repos = Array.from({ length: 17 }, (_, index) => `AgentWorkforce/repo-${index}`); await expect( diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 5f70663540..a79f39a71c 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -357,11 +357,19 @@ function validateRequestedRepos(repos: readonly string[] | undefined): void { throw new Error('Cloud fleet sandbox requests may include at most 16 repositories.'); } const seen = new Set(); + const seenCheckoutNames = new Set(); for (const repo of repos) { - if (seen.has(repo)) { + const normalizedRepo = repo.toLowerCase(); + if (seen.has(normalizedRepo)) { throw new Error('Cloud fleet sandbox repositories must not contain duplicates.'); } - seen.add(repo); + seen.add(normalizedRepo); + const slash = repo.lastIndexOf('/'); + const checkoutName = (slash === -1 ? repo : repo.slice(slash + 1)).toLowerCase(); + if (seenCheckoutNames.has(checkoutName)) { + throw new Error('Cloud fleet sandbox repositories must have unique checkout names.'); + } + seenCheckoutNames.add(checkoutName); } } diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 97324e7f40..d5de94b511 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -121,6 +121,33 @@ describe('resolveWorkspaceByKey', () => { }); }); + it('uses credentials from explicit resolver env instead of ambient process env', async () => { + process.env.CLOUD_API_ACCESS_TOKEN = 'ambient-access-token'; + const env = { + ...process.env, + CLOUD_API_URL: 'https://cloud.explicit.example.test', + CLOUD_API_ACCESS_TOKEN: 'explicit-access-token', + CLOUD_API_REFRESH_TOKEN: 'explicit-refresh-token', + CLOUD_API_ACCESS_TOKEN_EXPIRES_AT: '2999-01-01T00:00:00.000Z', + }; + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify(resolvedWorkspace), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchSpy); + + await resolveWorkspaceByKey('rk_live_selected', { env }); + + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get('authorization')).toBe('Bearer explicit-access-token'); + expect(String(fetchSpy.mock.calls[0]?.[0])).toBe( + 'https://cloud.explicit.example.test/api/v1/workspaces/current/resolve' + ); + }); + it.each(['http://cloud.example.test', 'https://user:password@cloud.example.test', 'file:///tmp/cloud'])( 'rejects unsafe resolver transport before any credential request: %s', async (apiUrl) => { diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 0558424ca8..17ee80a00a 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -1,4 +1,4 @@ -import { authorizedApiFetch, ensureAuthenticated, readStoredAuth } from './auth.js'; +import { authorizedApiFetch, ensureAuthenticated, ensureCloudSession, readStoredAuth } from './auth.js'; import { redactCredentialValues } from './redact.js'; import { type ActiveWorkspaceDescriptor, @@ -368,15 +368,18 @@ export async function resolveWorkspaceByKey( ): Promise { const key = workspaceKey.trim(); if (!/^rk_live_[A-Za-z0-9_-]{1,512}$/.test(key)) throw new Error('A valid workspace key is required.'); - const apiUrl = options.apiUrl || defaultApiUrl(); + const env = options.env ?? process.env; + const apiUrl = options.apiUrl || env.CLOUD_API_URL?.trim() || defaultApiUrl(); assertWorkspaceResolverTransport(apiUrl); // Stored sessions keep their own API host; validate it before a refresh can // send credentials, even when the requested/default host is secure. - const stored = await readStoredAuth(); + const stored = await readStoredAuth(env); if (stored) assertWorkspaceResolverTransport(stored.apiUrl); - const auth = await ensureAuthenticated(apiUrl, { + const { auth } = await ensureCloudSession({ + apiUrl, interactive: false, refreshTimeoutMs: options.refreshTimeoutMs, + env, }); assertWorkspaceResolverTransport(auth.apiUrl); const endpoint = '/api/v1/workspaces/current/resolve'; @@ -389,7 +392,7 @@ export async function resolveWorkspaceByKey( body: JSON.stringify({ workspaceKey: key }), signal: AbortSignal.timeout(options.refreshTimeoutMs ?? 30_000), }, - { interactive: false } + { interactive: false, env } ); const payload = await readJson(response); if (!response.ok) throw buildEndpointError('Project workspace resolve', endpoint, response, payload); From d0a91f27c83af10c7044123c4bfee1cb138f61e4 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:01:42 +0200 Subject: [PATCH 10/41] fix(cloud): validate Windows credential directory ACLs Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- ...redential-directory-windows.native.test.ts | 22 +++ .../src/credential-directory-windows.test.ts | 85 +++++++++++ .../cloud/src/credential-directory-windows.ts | 132 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 packages/cloud/src/credential-directory-windows.native.test.ts create mode 100644 packages/cloud/src/credential-directory-windows.test.ts create mode 100644 packages/cloud/src/credential-directory-windows.ts diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts new file mode 100644 index 0000000000..587caf189b --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { assertWindowsCredentialDirectory } from './credential-directory-windows.js'; + +const describeWindows = process.platform === 'win32' ? describe : describe.skip; +let directory: string | undefined; + +afterEach(() => { + if (directory) fs.rmSync(directory, { recursive: true, force: true }); + directory = undefined; +}); + +describeWindows('native Windows credential directory ACL validation', () => { + it('accepts a private temporary directory without changing its ACL', () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-')); + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); + }); +}); diff --git a/packages/cloud/src/credential-directory-windows.test.ts b/packages/cloud/src/credential-directory-windows.test.ts new file mode 100644 index 0000000000..2bf98614d8 --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { execFileSync } from 'node:child_process'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFileSync: vi.fn(), +})); + +import { assertWindowsCredentialDirectory } from './credential-directory-windows.js'; + +const execFileSyncMock = vi.mocked(execFileSync); +const originalPlatform = process.platform; + +afterEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +function withWindowsPlatform(): void { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }); +} + +describe('assertWindowsCredentialDirectory', () => { + it('does not invoke PowerShell on non-Windows platforms', () => { + assertWindowsCredentialDirectory('/tmp/relay-credentials'); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it('passes the directory as JSON stdin to a static PowerShell probe', () => { + withWindowsPlatform(); + execFileSyncMock.mockReturnValue('{"ok":true}'); + const directory = path.join(os.tmpdir(), 'relay-acl-private'); + + expect(() => assertWindowsCredentialDirectory(directory)).not.toThrow(); + + const [command, args, options] = execFileSyncMock.mock.calls[0]!; + expect(command).toBe('powershell.exe'); + expect(args).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', expect.any(String)]); + expect(options).toMatchObject({ + input: JSON.stringify({ directory: path.resolve(directory) }), + encoding: 'utf8', + timeout: expect.any(Number), + windowsHide: true, + }); + const script = String(args[args.length - 1]); + expect(script).toContain('Get-Acl'); + expect(script).toContain('ReparsePoint'); + expect(script).not.toContain(directory); + expect(args).not.toContain(path.resolve(directory)); + expect(args).not.toContain('-ExecutionPolicy'); + }); + + it('fails closed without exposing native ACL output or the path', () => { + withWindowsPlatform(); + execFileSyncMock.mockReturnValue( + '{"ok":false,"reason":"credential-parent-untrusted-allow","detail":"secret-token"}' + ); + const directory = path.join(os.tmpdir(), 'relay-acl-secret-path'); + + expect(() => assertWindowsCredentialDirectory(directory)).toThrow( + 'Windows Relaycast credential storage requires a private directory' + ); + try { + assertWindowsCredentialDirectory(directory); + } catch (error) { + expect(String(error)).not.toContain('secret-token'); + expect(String(error)).not.toContain(directory); + } + }); + + it('fails closed when PowerShell is unavailable or times out', () => { + withWindowsPlatform(); + execFileSyncMock.mockImplementation(() => { + throw new Error('powershell output contained a credential'); + }); + + expect(() => assertWindowsCredentialDirectory('/tmp/relay-acl-private')).toThrow( + 'choose a private directory under the current user profile' + ); + }); +}); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts new file mode 100644 index 0000000000..7ab1fedd2d --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.ts @@ -0,0 +1,132 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const WINDOWS_ACL_TIMEOUT_MS = 5_000; + +/** + * This script is deliberately static. The directory is supplied as JSON on + * stdin so a path can never become PowerShell source or an argument that is + * reinterpreted by a shell. + */ +const WINDOWS_ACL_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' + +function Emit-Failure([string]$Reason) { + [Console]::Out.WriteLine((@{ ok = $false; reason = $Reason } | ConvertTo-Json -Compress)) +} + +try { + $request = [Console]::In.ReadToEnd() | ConvertFrom-Json + $leafPath = [IO.Path]::GetFullPath([string]$request.directory) + $leaf = Get-Item -LiteralPath $leafPath -Force + if (-not $leaf.PSIsContainer) { + Emit-Failure 'credential-parent-not-directory' + exit 0 + } + + $trusted = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [void]$trusted.Add([Security.Principal.WindowsIdentity]::GetCurrent().Name) + [void]$trusted.Add('NT AUTHORITY\SYSTEM') + [void]$trusted.Add('BUILTIN\Administrators') + [void]$trusted.Add('NT SERVICE\TrustedInstaller') + + function Resolve-Principal([object]$Reference) { + try { + $sid = [Security.Principal.SecurityIdentifier]::new([string]$Reference.Value) + return $sid.Translate([Security.Principal.NTAccount]).Value + } catch { + return [string]$Reference.Value + } + } + + function Is-Trusted([string]$Principal) { + return $trusted.Contains($Principal) + } + + function Has-LeafRisk([string]$Rights) { + return $Rights -match 'Read|Write|WriteDac|WriteOwner|Delete|DeleteChild|ChangePermissions|TakeOwnership|FullControl|Modify' + } + + function Has-AncestorReplacementRisk([string]$Rights) { + return $Rights -match 'Write|WriteDac|WriteOwner|Delete|DeleteChild|ChangePermissions|TakeOwnership|FullControl|Modify' + } + + $cursor = $leaf + while ($null -ne $cursor) { + if (($cursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Emit-Failure 'credential-parent-reparse-point' + exit 0 + } + + $acl = Get-Acl -LiteralPath $cursor.FullName + if (-not (Is-Trusted (Resolve-Principal $acl.Owner))) { + Emit-Failure 'credential-parent-untrusted-owner' + exit 0 + } + + $isLeaf = $cursor.FullName -eq $leaf.FullName + foreach ($entry in $acl.Access) { + if ($entry.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + $principal = Resolve-Principal $entry.IdentityReference + if (Is-Trusted $principal) { + continue + } + $rights = [string]$entry.FileSystemRights + if (($isLeaf -and (Has-LeafRisk $rights)) -or ((-not $isLeaf) -and (Has-AncestorReplacementRisk $rights))) { + Emit-Failure 'credential-parent-untrusted-allow' + exit 0 + } + } + + $cursor = $cursor.Parent + } + + [Console]::Out.WriteLine('{"ok":true}') +} catch { + Emit-Failure 'credential-parent-acl-unavailable' +} +`; + +function privateDirectoryError(): Error { + return new Error( + 'Windows Relaycast credential storage requires a private directory with trusted ACLs; choose a private directory under the current user profile.' + ); +} + +/** + * Verify the Windows ACL boundary before a plaintext credential store is + * written. Non-Windows platforms retain their native permission checks. + */ +export function assertWindowsCredentialDirectory(directory: string): void { + if (process.platform !== 'win32') return; + + const absoluteDirectory = path.resolve(directory); + let output: string; + try { + output = execFileSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_SCRIPT], + { + input: JSON.stringify({ directory: absoluteDirectory }), + encoding: 'utf8', + timeout: WINDOWS_ACL_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 64 * 1024, + } + ); + } catch { + throw privateDirectoryError(); + } + + try { + const result = JSON.parse(output) as { ok?: unknown }; + if (result.ok !== true) throw privateDirectoryError(); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Windows Relaycast credential storage')) { + throw error; + } + throw privateDirectoryError(); + } +} From 53a580a35a554bb27de44b1d2e258f7435b4a4f3 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:07:22 +0200 Subject: [PATCH 11/41] fix(cloud): harden Windows credential ACL validation Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .github/workflows/test.yml | 25 ++++++ ...redential-directory-windows.native.test.ts | 12 +++ .../src/credential-directory-windows.test.ts | 5 ++ .../cloud/src/credential-directory-windows.ts | 81 +++++++++++++------ 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2e7dd9806..a3ce48a556 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,31 @@ jobs: - name: Run tests run: npm test + windows-credential-acl: + name: Windows credential ACL validation + needs: changes + if: needs.changes.outputs.node_changed == 'true' + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + cache: 'npm' + + - name: Install dependencies without lifecycle scripts + run: npm ci --ignore-scripts + + - name: Run Windows credential ACL tests + run: >- + npx vitest run + packages/cloud/src/credential-directory-windows.test.ts + packages/cloud/src/credential-directory-windows.native.test.ts + --pool=forks --maxWorkers=1 + coverage: name: Coverage (upload) needs: changes diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts index 587caf189b..d2ce476b7d 100644 --- a/packages/cloud/src/credential-directory-windows.native.test.ts +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; @@ -19,4 +20,15 @@ describeWindows('native Windows credential directory ACL validation', () => { directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-')); expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); }); + + it('rejects an untrusted read grant on the credential directory itself', () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-unsafe-')); + execFileSync('icacls.exe', [directory, '/grant', '*S-1-1-0:(R)'], { + stdio: 'ignore', + windowsHide: true, + }); + expect(() => assertWindowsCredentialDirectory(directory!)).toThrow( + 'Windows Relaycast credential storage requires a private directory' + ); + }); }); diff --git a/packages/cloud/src/credential-directory-windows.test.ts b/packages/cloud/src/credential-directory-windows.test.ts index 2bf98614d8..25aae38d6b 100644 --- a/packages/cloud/src/credential-directory-windows.test.ts +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -49,6 +49,11 @@ describe('assertWindowsCredentialDirectory', () => { const script = String(args[args.length - 1]); expect(script).toContain('Get-Acl'); expect(script).toContain('ReparsePoint'); + expect(script).toContain('S-1-5-18'); + expect(script).toContain('ReadData'); + expect(script).toContain('DeleteSubdirectoriesAndFiles'); + expect(script).toContain('InheritOnly'); + expect(script).not.toContain('$acl.Owner.Value'); expect(script).not.toContain(directory); expect(args).not.toContain(path.resolve(directory)); expect(args).not.toContain('-ExecutionPolicy'); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 7ab1fedd2d..8c739c181c 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -24,32 +24,58 @@ try { exit 0 } - $trusted = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - [void]$trusted.Add([Security.Principal.WindowsIdentity]::GetCurrent().Name) - [void]$trusted.Add('NT AUTHORITY\SYSTEM') - [void]$trusted.Add('BUILTIN\Administrators') - [void]$trusted.Add('NT SERVICE\TrustedInstaller') + $trustedSids = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + [void]$trustedSids.Add([Security.Principal.WindowsIdentity]::GetCurrent().User.Value) + [void]$trustedSids.Add('S-1-5-18') + [void]$trustedSids.Add('S-1-5-32-544') + try { + $trustedInstallerSid = ([Security.Principal.NTAccount]::new('NT SERVICE', 'TrustedInstaller')).Translate([Security.Principal.SecurityIdentifier]).Value + [void]$trustedSids.Add($trustedInstallerSid) + } catch { + # TrustedInstaller is optional; SYSTEM and Administrators remain trusted. + } - function Resolve-Principal([object]$Reference) { + function Resolve-IdentitySid([object]$Reference, [string]$OwnerSid) { + $value = if ($null -eq $Reference) { + '' + } elseif ($Reference -is [string]) { + [string]$Reference + } elseif ($null -ne $Reference.PSObject.Properties['Value']) { + [string]$Reference.Value + } else { + [string]$Reference + } + if (-not $value) { return $null } try { - $sid = [Security.Principal.SecurityIdentifier]::new([string]$Reference.Value) - return $sid.Translate([Security.Principal.NTAccount]).Value + if ($value -eq 'S-1-3-0') { return $OwnerSid } + if ($value -match '^S-\d-(?:\d+-){1,}\d+$') { + return ([Security.Principal.SecurityIdentifier]::new($value)).Value + } + return ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value } catch { - return [string]$Reference.Value + return $null } } - function Is-Trusted([string]$Principal) { - return $trusted.Contains($Principal) + function Is-Trusted([string]$Sid) { + return $null -ne $Sid -and $trustedSids.Contains($Sid) } - function Has-LeafRisk([string]$Rights) { - return $Rights -match 'Read|Write|WriteDac|WriteOwner|Delete|DeleteChild|ChangePermissions|TakeOwnership|FullControl|Modify' - } - - function Has-AncestorReplacementRisk([string]$Rights) { - return $Rights -match 'Write|WriteDac|WriteOwner|Delete|DeleteChild|ChangePermissions|TakeOwnership|FullControl|Modify' - } + $leafRiskMask = [int64][Security.AccessControl.FileSystemRights]::ReadData + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::WriteData + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::AppendData + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::Delete + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::ChangePermissions + $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::TakeOwnership + + # Directory WriteData is intentionally excluded: the credential directory's + # existing owner must be protected from replacement, while ordinary parent + # directories such as C:\Users may legitimately allow child creation. + $ancestorRiskMask = [int64][Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles + $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::Delete + $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::ChangePermissions + $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::TakeOwnership $cursor = $leaf while ($null -ne $cursor) { @@ -59,22 +85,29 @@ try { } $acl = Get-Acl -LiteralPath $cursor.FullName - if (-not (Is-Trusted (Resolve-Principal $acl.Owner))) { + $ownerSid = Resolve-IdentitySid $acl.Owner '' + if (-not (Is-Trusted $ownerSid)) { Emit-Failure 'credential-parent-untrusted-owner' exit 0 } - $isLeaf = $cursor.FullName -eq $leaf.FullName + $isLeaf = [StringComparer]::OrdinalIgnoreCase.Equals($cursor.FullName, $leaf.FullName) foreach ($entry in $acl.Access) { if ($entry.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { continue } - $principal = Resolve-Principal $entry.IdentityReference - if (Is-Trusted $principal) { + # InheritOnly entries do not apply to this ancestor itself. On the leaf, + # however, they govern future credential files and must still be checked. + if (-not $isLeaf -and (($entry.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0)) { + continue + } + $principalSid = Resolve-IdentitySid $entry.IdentityReference $ownerSid + if (Is-Trusted $principalSid) { continue } - $rights = [string]$entry.FileSystemRights - if (($isLeaf -and (Has-LeafRisk $rights)) -or ((-not $isLeaf) -and (Has-AncestorReplacementRisk $rights))) { + $rightsValue = [int64]$entry.FileSystemRights + $riskMask = if ($isLeaf) { $leafRiskMask } else { $ancestorRiskMask } + if (($rightsValue -band $riskMask) -ne 0) { Emit-Failure 'credential-parent-untrusted-allow' exit 0 } From 90252fc042c27d2a63d58ce4891c510d0eb6eae5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:09:55 +0200 Subject: [PATCH 12/41] fix(cloud): normalize Windows creator owner ACLs Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../cloud/src/credential-directory-windows.test.ts | 1 + packages/cloud/src/credential-directory-windows.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/cloud/src/credential-directory-windows.test.ts b/packages/cloud/src/credential-directory-windows.test.ts index 25aae38d6b..de5eca5c8e 100644 --- a/packages/cloud/src/credential-directory-windows.test.ts +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -26,6 +26,7 @@ function withWindowsPlatform(): void { describe('assertWindowsCredentialDirectory', () => { it('does not invoke PowerShell on non-Windows platforms', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); assertWindowsCredentialDirectory('/tmp/relay-credentials'); expect(execFileSyncMock).not.toHaveBeenCalled(); }); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 8c739c181c..3dd5bf733b 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -47,11 +47,17 @@ try { } if (-not $value) { return $null } try { - if ($value -eq 'S-1-3-0') { return $OwnerSid } + $sid = $null if ($value -match '^S-\d-(?:\d+-){1,}\d+$') { - return ([Security.Principal.SecurityIdentifier]::new($value)).Value + $sid = ([Security.Principal.SecurityIdentifier]::new($value)).Value + } else { + $sid = ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value } - return ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value + # Get-Acl commonly returns a localized NTAccount (for example, + # "CREATOR OWNER") rather than the well-known SID directly. Resolve + # first, then bind this inherited principal to the validated owner. + if ($sid -eq 'S-1-3-0') { return $OwnerSid } + return $sid } catch { return $null } From 5bb6491494a57ba6b4f3d3cc6a0a23d27267721b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:10:44 +0200 Subject: [PATCH 13/41] fix(cloud): validate credential directory ancestor boundaries Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.test.ts | 35 ++++++++++++++++++++++ packages/cloud/src/workspace-store.ts | 29 ++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 31aca1439e..82b82e4713 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -113,6 +113,33 @@ describe('workspace store', () => { } }); + it.skipIf(process.platform === 'win32')( + 'rejects a writable ancestor above an otherwise private credential directory', + () => { + const shared = path.join(dir, 'shared'); + const privateDirectory = path.join(shared, 'private'); + fs.mkdirSync(privateDirectory, { recursive: true, mode: 0o700 }); + fs.chmodSync(shared, 0o777); + try { + expect(() => + writeRelaycastCredential( + 'unsafe-ancestor', + { + workspaceId: 'rw_private', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_private', + }, + { AGENT_RELAY_HOME: privateDirectory } + ) + ).toThrow('unsafe ancestor'); + expect(fs.existsSync(path.join(privateDirectory, 'relaycast-credentials.json'))).toBe(false); + } finally { + fs.chmodSync(shared, 0o700); + } + } + ); + it('stores route credentials outside the project with a scoped reference', () => { const ref = relaycastCredentialRef( '/checkout/.agentworkforce/relay', @@ -138,6 +165,14 @@ describe('workspace store', () => { apiKey: 'rk_live_sentinel', }); const source = fs.readFileSync(new URL('./workspace-store.ts', import.meta.url), 'utf8'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' })); + fs.writeFileSync( + path.join(dir, 'credential-directory-windows.js'), + ts.transpileModule( + fs.readFileSync(new URL('./credential-directory-windows.ts', import.meta.url), 'utf8'), + { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } } + ).outputText + ); const worker = path.join(dir, 'workspace-store-worker.mjs'); fs.writeFileSync( worker, diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 54a1b95189..3cbe295eb7 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { createHash, randomUUID } from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; +import { assertWindowsCredentialDirectory } from './credential-directory-windows.js'; /** * Local store of named workspace keys plus which one is active. This is the @@ -109,6 +110,33 @@ function assertRelaycastCredentialLockDirectory(lock: string): fs.Stats { return info; } +function assertCredentialAncestors(directory: string): void { + if (process.platform === 'win32') { + assertWindowsCredentialDirectory(directory); + return; + } + const uid = process.getuid?.(); + // Check both the supplied path and its canonical target. System-owned + // aliases such as macOS /var -> /private/var remain usable, while neither + // a foreign-owned ancestor nor a writable non-sticky parent can be swapped. + for (const start of new Set([path.resolve(directory), fs.realpathSync(directory)])) { + let current = start; + while (true) { + const info = fs.lstatSync(current); + const trustedOwner = info.uid === uid || info.uid === 0; + const sticky = (info.mode & 0o1000) !== 0; + if (!trustedOwner || (!info.isSymbolicLink() && (info.mode & 0o022) !== 0 && !sticky)) { + throw new Error( + 'Relaycast credential storage has an unsafe ancestor; choose a directory under your private home.' + ); + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + } +} + function withRelaycastCredentialLock(file: string, fn: () => T): T { const directory = path.dirname(file); const lock = `${file}.lock`; @@ -129,6 +157,7 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { 'Relaycast credential storage requires a directory owned by the current user without group or other write permissions.' ); } + assertCredentialAncestors(directory); while (true) { try { fs.mkdirSync(lock, { mode: 0o700 }); From e99454490f09ab73aa9c66a7cb4ec224460265cb Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:17:01 +0200 Subject: [PATCH 14/41] test(cloud): diagnose Windows ACL fixture boundaries Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- ...redential-directory-windows.native.test.ts | 131 +++++++++++++++++- .../cloud/src/credential-directory-windows.ts | 9 +- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts index d2ce476b7d..3946649f79 100644 --- a/packages/cloud/src/credential-directory-windows.native.test.ts +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -10,15 +10,129 @@ import { assertWindowsCredentialDirectory } from './credential-directory-windows const describeWindows = process.platform === 'win32' ? describe : describe.skip; let directory: string | undefined; +// This diagnostic is test-only and deliberately emits resolved SIDs and +// numeric ACL facts, never paths, account names, or native error text. +const ACL_DIAGNOSTIC_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +function Resolve-Sid([object]$Reference) { + $value = if ($null -eq $Reference) { + '' + } elseif ($Reference -is [string]) { + [string]$Reference + } elseif ($null -ne $Reference.PSObject.Properties['Value']) { + [string]$Reference.Value + } else { + [string]$Reference + } + if (-not $value) { return 'unresolved' } + try { + if ($value -match '^S-\d-(?:\d+-){1,}\d+$') { + return ([Security.Principal.SecurityIdentifier]::new($value)).Value + } + return ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value + } catch { + return 'unresolved' + } +} +$request = [Console]::In.ReadToEnd() | ConvertFrom-Json +$cursor = Get-Item -LiteralPath ([IO.Path]::GetFullPath([string]$request.directory)) -Force +$depth = 0 +$rows = @() +while ($null -ne $cursor) { + $acl = Get-Acl -LiteralPath $cursor.FullName + $entries = @($acl.Access | ForEach-Object { + [pscustomobject]@{ + principalSid = Resolve-Sid $_.IdentityReference + type = [string]$_.AccessControlType + rights = [int64]$_.FileSystemRights + inheritance = [string]$_.InheritanceFlags + propagation = [string]$_.PropagationFlags + } + }) + $rows += [pscustomobject]@{ + depth = $depth + ownerSid = Resolve-Sid $acl.Owner + attributes = [string]$cursor.Attributes + entries = $entries + } + $cursor = $cursor.Parent + $depth++ +} +ConvertTo-Json -InputObject @($rows) -Depth 8 -Compress +`; + +type AclDiagnosticEntry = { + principalSid?: unknown; + type?: unknown; + rights?: unknown; + inheritance?: unknown; + propagation?: unknown; +}; + +type AclDiagnosticRow = { + depth?: unknown; + ownerSid?: unknown; + attributes?: unknown; + entries?: AclDiagnosticEntry[]; +}; + +function reportAclDiagnostic(directoryPath: string): void { + try { + const output = execFileSync( + 'powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', ACL_DIAGNOSTIC_SCRIPT], + { + input: JSON.stringify({ directory: path.resolve(directoryPath) }), + encoding: 'utf8', + timeout: 5_000, + windowsHide: true, + maxBuffer: 128 * 1024, + } + ); + const parsed = JSON.parse(output) as AclDiagnosticRow | AclDiagnosticRow[]; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const safeRows = rows.map((row) => ({ + depth: row.depth, + ownerSid: row.ownerSid, + attributes: row.attributes, + entries: (row.entries ?? []).map((entry) => ({ + principalSid: entry.principalSid, + type: entry.type, + rights: entry.rights, + inheritance: entry.inheritance, + propagation: entry.propagation, + })), + })); + console.error(`[Windows ACL diagnostic] ${JSON.stringify(safeRows)}`); + } catch { + console.error('[Windows ACL diagnostic] unavailable'); + } +} + afterEach(() => { if (directory) fs.rmSync(directory, { recursive: true, force: true }); directory = undefined; }); describeWindows('native Windows credential directory ACL validation', () => { - it('accepts a private temporary directory without changing its ACL', () => { - directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-')); - expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); + it('accepts a private directory under the user profile without changing its ACL', () => { + directory = fs.mkdtempSync(path.join(os.homedir(), '.relay-acl-native-')); + try { + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); + } catch (error) { + reportAclDiagnostic(directory); + throw error; + } + }); + + it('diagnoses a hosted temporary directory if inherited ACLs are too broad', () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-temp-')); + try { + assertWindowsCredentialDirectory(directory); + } catch (error) { + reportAclDiagnostic(directory); + expect(String(error)).toContain('Windows Relaycast credential storage requires a private directory'); + } }); it('rejects an untrusted read grant on the credential directory itself', () => { @@ -31,4 +145,15 @@ describeWindows('native Windows credential directory ACL validation', () => { 'Windows Relaycast credential storage requires a private directory' ); }); + + it('rejects an untrusted generic-all grant on the credential directory itself', () => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-generic-unsafe-')); + execFileSync('icacls.exe', [directory, '/grant', '*S-1-1-0:(GA)'], { + stdio: 'ignore', + windowsHide: true, + }); + expect(() => assertWindowsCredentialDirectory(directory!)).toThrow( + 'Windows Relaycast credential storage requires a private directory' + ); + }); }); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 3dd5bf733b..48a9fe14da 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -74,6 +74,12 @@ try { $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::Delete $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::ChangePermissions $leafRiskMask = $leafRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::TakeOwnership + # Access rules can retain generic masks rather than their expanded file + # rights. Normalize those high bits explicitly so they cannot bypass the + # specific-rights checks above. + $leafRiskMask = $leafRiskMask -bor [int64]0x10000000 # GENERIC_ALL + $leafRiskMask = $leafRiskMask -bor [int64]0x80000000 # GENERIC_READ + $leafRiskMask = $leafRiskMask -bor [int64]0x40000000 # GENERIC_WRITE # Directory WriteData is intentionally excluded: the credential directory's # existing owner must be protected from replacement, while ordinary parent @@ -82,6 +88,7 @@ try { $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::Delete $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::ChangePermissions $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::TakeOwnership + $ancestorRiskMask = $ancestorRiskMask -bor [int64]0x10000000 # GENERIC_ALL $cursor = $leaf while ($null -ne $cursor) { @@ -111,7 +118,7 @@ try { if (Is-Trusted $principalSid) { continue } - $rightsValue = [int64]$entry.FileSystemRights + $rightsValue = [int64]([uint32]$entry.FileSystemRights) $riskMask = if ($isLeaf) { $leafRiskMask } else { $ancestorRiskMask } if (($rightsValue -band $riskMask) -ne 0) { Emit-Failure 'credential-parent-untrusted-allow' From efbb7ca22e39fb72394f92a03ad1e59af2a82ca6 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:17:50 +0200 Subject: [PATCH 15/41] fix(cloud): isolate default API URL from ambient environment Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/auth.ts | 2 +- packages/cloud/src/types.test.ts | 20 +++++++++++++++++++ packages/cloud/src/types.ts | 4 ++-- packages/cloud/src/workspaces.test.ts | 28 +++++++++++++++++++++++++++ packages/cloud/src/workspaces.ts | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 packages/cloud/src/types.test.ts diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 2338b80218..335dc8abb1 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -701,7 +701,7 @@ export async function ensureAuthenticated( export async function ensureCloudSession(options: CloudSessionOptions = {}): Promise { const env = options.env ?? process.env; - const apiUrl = options.apiUrl || env.CLOUD_API_URL?.trim() || defaultApiUrl(); + const apiUrl = options.apiUrl || defaultApiUrl(env); const force = options.force === true; const interactive = options.interactive !== false; const refreshTimeoutMs = options.refreshTimeoutMs; diff --git a/packages/cloud/src/types.test.ts b/packages/cloud/src/types.test.ts new file mode 100644 index 0000000000..1e93454d61 --- /dev/null +++ b/packages/cloud/src/types.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defaultApiUrl } from './types.js'; + +afterEach(() => vi.unstubAllEnvs()); + +describe('defaultApiUrl environment isolation', () => { + it('does not inherit an ambient host when an explicit environment omits it', () => { + vi.stubEnv('CLOUD_API_URL', 'https://ambient.example.test'); + expect(defaultApiUrl({})).toBe('https://agentrelay.com/cloud'); + expect(defaultApiUrl({ CLOUD_API_URL: ' ' })).toBe('https://agentrelay.com/cloud'); + expect(defaultApiUrl({ CLOUD_API_URL: ' https://isolated.example.test ' })).toBe( + 'https://isolated.example.test' + ); + }); + + it('preserves the ambient override for callers without an explicit environment', () => { + vi.stubEnv('CLOUD_API_URL', 'https://ambient.example.test'); + expect(defaultApiUrl()).toBe('https://ambient.example.test'); + }); +}); diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index b8843245a1..18e4b5daa0 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -281,8 +281,8 @@ export const REFRESH_TOKEN_WINDOW_MS = 24 * 60 * 60 * 1000; export const DEFAULT_REFRESH_TIMEOUT_MS = 10_000; export const AUTH_FILE_PATH = path.join(os.homedir(), '.agentworkforce/relay', 'cloud-auth.json'); -export function defaultApiUrl(): string { - return process.env.CLOUD_API_URL?.trim() || 'https://agentrelay.com/cloud'; +export function defaultApiUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.CLOUD_API_URL?.trim() || 'https://agentrelay.com/cloud'; } export function isSupportedProvider(provider: string): boolean { diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index d5de94b511..771eac5aad 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createServer } from 'node:http'; @@ -8,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { readWorkspaceStore, setWorkspaceKey } from './workspace-store.js'; import { resolveActiveWorkspace, resolveWorkspaceByKey } from './workspaces.js'; +import { AUTH_FILE_PATH } from './types.js'; let dir: string; const originalEnv = { ...process.env }; @@ -96,6 +98,32 @@ describe('resolveWorkspaceByKey', () => { }, }; + it('ignores an ambient API host when an isolated environment omits it', async () => { + process.env.CLOUD_API_URL = 'http://ambient.example.test'; + const originalReadFile = fsPromises.readFile.bind(fsPromises); + const readFileSpy = vi.spyOn(fsPromises, 'readFile').mockImplementation(async (...args) => { + if (String(args[0]) === AUTH_FILE_PATH) { + return JSON.stringify({ + apiUrl: 'https://stored.example.test', + accessToken: 'stored-access-token', + refreshToken: 'stored-refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }); + } + return originalReadFile(...args); + }); + const fetchSpy = vi.fn(async () => new Response(JSON.stringify(resolvedWorkspace), { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + try { + await resolveWorkspaceByKey('rk_live_selected', { env: {} }); + expect(String(fetchSpy.mock.calls[0]?.[0])).toBe( + 'https://stored.example.test/api/v1/workspaces/current/resolve' + ); + } finally { + readFileSpy.mockRestore(); + } + }); + it('sends the selected key in a POST body and never in the request URL', async () => { const fetchSpy = vi.fn( async () => diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 17ee80a00a..ceb750f76c 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -369,7 +369,7 @@ export async function resolveWorkspaceByKey( const key = workspaceKey.trim(); if (!/^rk_live_[A-Za-z0-9_-]{1,512}$/.test(key)) throw new Error('A valid workspace key is required.'); const env = options.env ?? process.env; - const apiUrl = options.apiUrl || env.CLOUD_API_URL?.trim() || defaultApiUrl(); + const apiUrl = options.apiUrl || defaultApiUrl(env); assertWorkspaceResolverTransport(apiUrl); // Stored sessions keep their own API host; validate it before a refresh can // send credentials, even when the requested/default host is secure. From 96927693b8986d2d16f2f53ac8fcc552fa2226d7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:18:15 +0200 Subject: [PATCH 16/41] test(cloud): validate Windows ACL fixtures before mutation Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../src/credential-directory-windows.native.test.ts | 6 ++++-- packages/cloud/src/credential-directory-windows.ts | 10 +++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts index 3946649f79..7cb7a06729 100644 --- a/packages/cloud/src/credential-directory-windows.native.test.ts +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -136,7 +136,8 @@ describeWindows('native Windows credential directory ACL validation', () => { }); it('rejects an untrusted read grant on the credential directory itself', () => { - directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-unsafe-')); + directory = fs.mkdtempSync(path.join(os.homedir(), '.relay-acl-native-unsafe-')); + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); execFileSync('icacls.exe', [directory, '/grant', '*S-1-1-0:(R)'], { stdio: 'ignore', windowsHide: true, @@ -147,7 +148,8 @@ describeWindows('native Windows credential directory ACL validation', () => { }); it('rejects an untrusted generic-all grant on the credential directory itself', () => { - directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-generic-unsafe-')); + directory = fs.mkdtempSync(path.join(os.homedir(), '.relay-acl-native-generic-unsafe-')); + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); execFileSync('icacls.exe', [directory, '/grant', '*S-1-1-0:(GA)'], { stdio: 'ignore', windowsHide: true, diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 48a9fe14da..d363988a7d 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -77,9 +77,9 @@ try { # Access rules can retain generic masks rather than their expanded file # rights. Normalize those high bits explicitly so they cannot bypass the # specific-rights checks above. - $leafRiskMask = $leafRiskMask -bor [int64]0x10000000 # GENERIC_ALL - $leafRiskMask = $leafRiskMask -bor [int64]0x80000000 # GENERIC_READ - $leafRiskMask = $leafRiskMask -bor [int64]0x40000000 # GENERIC_WRITE + $leafRiskMask = $leafRiskMask -bor [int64]268435456 # GENERIC_ALL + $leafRiskMask = $leafRiskMask -bor [int64]2147483648 # GENERIC_READ + $leafRiskMask = $leafRiskMask -bor [int64]1073741824 # GENERIC_WRITE # Directory WriteData is intentionally excluded: the credential directory's # existing owner must be protected from replacement, while ordinary parent @@ -88,7 +88,7 @@ try { $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::Delete $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::ChangePermissions $ancestorRiskMask = $ancestorRiskMask -bor [int64][Security.AccessControl.FileSystemRights]::TakeOwnership - $ancestorRiskMask = $ancestorRiskMask -bor [int64]0x10000000 # GENERIC_ALL + $ancestorRiskMask = $ancestorRiskMask -bor [int64]268435456 # GENERIC_ALL $cursor = $leaf while ($null -ne $cursor) { @@ -118,7 +118,7 @@ try { if (Is-Trusted $principalSid) { continue } - $rightsValue = [int64]([uint32]$entry.FileSystemRights) + $rightsValue = ([int64]$entry.FileSystemRights -band 4294967295) $riskMask = if ($isLeaf) { $leafRiskMask } else { $ancestorRiskMask } if (($rightsValue -band $riskMask) -ne 0) { Emit-Failure 'credential-parent-untrusted-allow' From fbfff3841bdd412f8952c960f19e944870007aa7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:24:28 +0200 Subject: [PATCH 17/41] fix(cloud): avoid PowerShell ACL module autoload Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .../src/credential-directory-windows.native.test.ts | 6 ++++-- packages/cloud/src/credential-directory-windows.test.ts | 5 ++++- packages/cloud/src/credential-directory-windows.ts | 9 +++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts index 7cb7a06729..6ba8618919 100644 --- a/packages/cloud/src/credential-directory-windows.native.test.ts +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -35,11 +35,12 @@ function Resolve-Sid([object]$Reference) { } } $request = [Console]::In.ReadToEnd() | ConvertFrom-Json -$cursor = Get-Item -LiteralPath ([IO.Path]::GetFullPath([string]$request.directory)) -Force +$cursor = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath([string]$request.directory)) +$null = $cursor.Exists $depth = 0 $rows = @() while ($null -ne $cursor) { - $acl = Get-Acl -LiteralPath $cursor.FullName + $acl = $cursor.GetAccessControl() $entries = @($acl.Access | ForEach-Object { [pscustomobject]@{ principalSid = Resolve-Sid $_.IdentityReference @@ -87,6 +88,7 @@ function reportAclDiagnostic(directoryPath: string): void { timeout: 5_000, windowsHide: true, maxBuffer: 128 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], } ); const parsed = JSON.parse(output) as AclDiagnosticRow | AclDiagnosticRow[]; diff --git a/packages/cloud/src/credential-directory-windows.test.ts b/packages/cloud/src/credential-directory-windows.test.ts index de5eca5c8e..31ba971de5 100644 --- a/packages/cloud/src/credential-directory-windows.test.ts +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -46,9 +46,12 @@ describe('assertWindowsCredentialDirectory', () => { encoding: 'utf8', timeout: expect.any(Number), windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], }); const script = String(args[args.length - 1]); - expect(script).toContain('Get-Acl'); + expect(script).toContain('DirectoryInfo]::new'); + expect(script).toContain('GetAccessControl'); + expect(script).not.toContain('Get-Acl'); expect(script).toContain('ReparsePoint'); expect(script).toContain('S-1-5-18'); expect(script).toContain('ReadData'); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index d363988a7d..10f72ee58f 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -18,8 +18,8 @@ function Emit-Failure([string]$Reason) { try { $request = [Console]::In.ReadToEnd() | ConvertFrom-Json $leafPath = [IO.Path]::GetFullPath([string]$request.directory) - $leaf = Get-Item -LiteralPath $leafPath -Force - if (-not $leaf.PSIsContainer) { + $leaf = [IO.DirectoryInfo]::new($leafPath) + if (-not $leaf.Exists) { Emit-Failure 'credential-parent-not-directory' exit 0 } @@ -53,7 +53,7 @@ try { } else { $sid = ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value } - # Get-Acl commonly returns a localized NTAccount (for example, + # ACL enumeration commonly returns a localized NTAccount (for example, # "CREATOR OWNER") rather than the well-known SID directly. Resolve # first, then bind this inherited principal to the validated owner. if ($sid -eq 'S-1-3-0') { return $OwnerSid } @@ -97,7 +97,7 @@ try { exit 0 } - $acl = Get-Acl -LiteralPath $cursor.FullName + $acl = $cursor.GetAccessControl() $ownerSid = Resolve-IdentitySid $acl.Owner '' if (-not (Is-Trusted $ownerSid)) { Emit-Failure 'credential-parent-untrusted-owner' @@ -160,6 +160,7 @@ export function assertWindowsCredentialDirectory(directory: string): void { timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true, maxBuffer: 64 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], } ); } catch { From f57cb4b558952595da905272ca53510f3b9c0455 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:25:57 +0200 Subject: [PATCH 18/41] test(cli): isolate persisted route credential fixtures Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/commands/observer.test.ts | 1 + packages/cli/src/cli/lib/attach-fleet-node.test.ts | 8 ++++++-- packages/cli/src/cli/lib/workspace-session.test.ts | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/cli/commands/observer.test.ts b/packages/cli/src/cli/commands/observer.test.ts index 9723d1a82f..350c73171c 100644 --- a/packages/cli/src/cli/commands/observer.test.ts +++ b/packages/cli/src/cli/commands/observer.test.ts @@ -101,6 +101,7 @@ describe('agent-relay observer', () => { it('mints through the persisted credential and origin as one transport pair', async () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-observer-project-')); vi.stubEnv('AGENT_RELAY_PROJECT', projectRoot); + vi.stubEnv('AGENT_RELAY_HOME', path.join(projectRoot, 'credentials')); writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), WORKSPACE_KEY, { workspaceId: 'rw_abc', relaycastRoute: 'agent37-isolated', diff --git a/packages/cli/src/cli/lib/attach-fleet-node.test.ts b/packages/cli/src/cli/lib/attach-fleet-node.test.ts index 050b38751a..39b0676a45 100644 --- a/packages/cli/src/cli/lib/attach-fleet-node.test.ts +++ b/packages/cli/src/cli/lib/attach-fleet-node.test.ts @@ -1195,6 +1195,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { cleanup.push(remote.close); const ticket = capturingTicketFetch(remote.url); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-attach-project-')); + const env = { AGENT_RELAY_HOME: path.join(projectRoot, 'credentials') }; const priorProject = process.env.AGENT_RELAY_PROJECT; process.env.AGENT_RELAY_PROJECT = projectRoot; writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_live_canonical', { @@ -1202,6 +1203,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_isolated', + env, }); try { @@ -1211,7 +1213,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { mode: 'view', baseUrl: 'https://agent37-cast.agentrelay.com/', workspaceKey: 'rk_live_canonical', - env: {}, + env, fetch: ticket.fetch, }); cleanup.push(proxy.close); @@ -1232,6 +1234,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { cleanup.push(remote.close); const ticket = capturingTicketFetch(remote.url); const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-attach-project-')); + const env = { AGENT_RELAY_HOME: path.join(projectRoot, 'credentials') }; const priorProject = process.env.AGENT_RELAY_PROJECT; process.env.AGENT_RELAY_PROJECT = projectRoot; writeProjectWorkspaceKey(path.join(projectRoot, '.agentworkforce/relay'), 'rk_live_canonical', { @@ -1239,6 +1242,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { relaycastRoute: 'agent37-isolated', relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', relaycastApiKey: 'rk_live_isolated', + env, }); try { @@ -1247,7 +1251,7 @@ describe('startFleetNodeAttachProxy workspace-key precedence', () => { node: 'other-node', mode: 'view', workspaceKey: 'rk_live_other', - env: {}, + env, fetch: ticket.fetch, }); cleanup.push(proxy.close); diff --git a/packages/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 2f3baaf33b..c94a658649 100644 --- a/packages/cli/src/cli/lib/workspace-session.test.ts +++ b/packages/cli/src/cli/lib/workspace-session.test.ts @@ -254,6 +254,7 @@ describe('workspace session persistence', () => { relaycastRoute: 'canonical', relaycastBaseUrl: 'https://cast.agentrelay.com', relaycastApiKey: 'rk_live_stale_canonical', + env, }); persistWorkspaceSession({ From 2e32546247462e833854c80eaf3714688d9702dd Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:36:13 +0200 Subject: [PATCH 19/41] test(cloud): remove resolved Windows ACL diagnostic Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- ...redential-directory-windows.native.test.ts | 118 +----------------- 1 file changed, 1 insertion(+), 117 deletions(-) diff --git a/packages/cloud/src/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts index 6ba8618919..209bcfe86d 100644 --- a/packages/cloud/src/credential-directory-windows.native.test.ts +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -10,107 +10,6 @@ import { assertWindowsCredentialDirectory } from './credential-directory-windows const describeWindows = process.platform === 'win32' ? describe : describe.skip; let directory: string | undefined; -// This diagnostic is test-only and deliberately emits resolved SIDs and -// numeric ACL facts, never paths, account names, or native error text. -const ACL_DIAGNOSTIC_SCRIPT = String.raw` -$ErrorActionPreference = 'Stop' -function Resolve-Sid([object]$Reference) { - $value = if ($null -eq $Reference) { - '' - } elseif ($Reference -is [string]) { - [string]$Reference - } elseif ($null -ne $Reference.PSObject.Properties['Value']) { - [string]$Reference.Value - } else { - [string]$Reference - } - if (-not $value) { return 'unresolved' } - try { - if ($value -match '^S-\d-(?:\d+-){1,}\d+$') { - return ([Security.Principal.SecurityIdentifier]::new($value)).Value - } - return ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value - } catch { - return 'unresolved' - } -} -$request = [Console]::In.ReadToEnd() | ConvertFrom-Json -$cursor = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath([string]$request.directory)) -$null = $cursor.Exists -$depth = 0 -$rows = @() -while ($null -ne $cursor) { - $acl = $cursor.GetAccessControl() - $entries = @($acl.Access | ForEach-Object { - [pscustomobject]@{ - principalSid = Resolve-Sid $_.IdentityReference - type = [string]$_.AccessControlType - rights = [int64]$_.FileSystemRights - inheritance = [string]$_.InheritanceFlags - propagation = [string]$_.PropagationFlags - } - }) - $rows += [pscustomobject]@{ - depth = $depth - ownerSid = Resolve-Sid $acl.Owner - attributes = [string]$cursor.Attributes - entries = $entries - } - $cursor = $cursor.Parent - $depth++ -} -ConvertTo-Json -InputObject @($rows) -Depth 8 -Compress -`; - -type AclDiagnosticEntry = { - principalSid?: unknown; - type?: unknown; - rights?: unknown; - inheritance?: unknown; - propagation?: unknown; -}; - -type AclDiagnosticRow = { - depth?: unknown; - ownerSid?: unknown; - attributes?: unknown; - entries?: AclDiagnosticEntry[]; -}; - -function reportAclDiagnostic(directoryPath: string): void { - try { - const output = execFileSync( - 'powershell.exe', - ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', ACL_DIAGNOSTIC_SCRIPT], - { - input: JSON.stringify({ directory: path.resolve(directoryPath) }), - encoding: 'utf8', - timeout: 5_000, - windowsHide: true, - maxBuffer: 128 * 1024, - stdio: ['pipe', 'pipe', 'pipe'], - } - ); - const parsed = JSON.parse(output) as AclDiagnosticRow | AclDiagnosticRow[]; - const rows = Array.isArray(parsed) ? parsed : [parsed]; - const safeRows = rows.map((row) => ({ - depth: row.depth, - ownerSid: row.ownerSid, - attributes: row.attributes, - entries: (row.entries ?? []).map((entry) => ({ - principalSid: entry.principalSid, - type: entry.type, - rights: entry.rights, - inheritance: entry.inheritance, - propagation: entry.propagation, - })), - })); - console.error(`[Windows ACL diagnostic] ${JSON.stringify(safeRows)}`); - } catch { - console.error('[Windows ACL diagnostic] unavailable'); - } -} - afterEach(() => { if (directory) fs.rmSync(directory, { recursive: true, force: true }); directory = undefined; @@ -119,22 +18,7 @@ afterEach(() => { describeWindows('native Windows credential directory ACL validation', () => { it('accepts a private directory under the user profile without changing its ACL', () => { directory = fs.mkdtempSync(path.join(os.homedir(), '.relay-acl-native-')); - try { - expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); - } catch (error) { - reportAclDiagnostic(directory); - throw error; - } - }); - - it('diagnoses a hosted temporary directory if inherited ACLs are too broad', () => { - directory = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-acl-native-temp-')); - try { - assertWindowsCredentialDirectory(directory); - } catch (error) { - reportAclDiagnostic(directory); - expect(String(error)).toContain('Windows Relaycast credential storage requires a private directory'); - } + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); }); it('rejects an untrusted read grant on the credential directory itself', () => { From b33d1661f594987cea551c77c2804f3b01120973 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:40:58 +0200 Subject: [PATCH 20/41] fix(cloud): use trusted system PowerShell for credential checks Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .github/workflows/test.yml | 10 +- .../src/credential-directory-windows.test.ts | 16 +- .../cloud/src/credential-directory-windows.ts | 5 +- packages/cloud/src/workspace-store.test.ts | 144 +++++++++--------- 4 files changed, 103 insertions(+), 72 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a3ce48a556..7e5a287e97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,10 +65,10 @@ jobs: runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '22.14.0' cache: 'npm' @@ -83,6 +83,12 @@ jobs: packages/cloud/src/credential-directory-windows.native.test.ts --pool=forks --maxWorkers=1 + - name: Verify concurrent Windows credential writes + run: >- + npx vitest run packages/cloud/src/workspace-store.test.ts + --testNamePattern="preserves concurrent credential writes" + --pool=forks --maxWorkers=1 + coverage: name: Coverage (upload) needs: changes diff --git a/packages/cloud/src/credential-directory-windows.test.ts b/packages/cloud/src/credential-directory-windows.test.ts index 31ba971de5..e3b42c7258 100644 --- a/packages/cloud/src/credential-directory-windows.test.ts +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -17,6 +17,7 @@ const originalPlatform = process.platform; afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); Object.defineProperty(process, 'platform', { value: originalPlatform }); }); @@ -33,13 +34,14 @@ describe('assertWindowsCredentialDirectory', () => { it('passes the directory as JSON stdin to a static PowerShell probe', () => { withWindowsPlatform(); + vi.stubEnv('SystemRoot', 'C:\\Windows'); execFileSyncMock.mockReturnValue('{"ok":true}'); const directory = path.join(os.tmpdir(), 'relay-acl-private'); expect(() => assertWindowsCredentialDirectory(directory)).not.toThrow(); const [command, args, options] = execFileSyncMock.mock.calls[0]!; - expect(command).toBe('powershell.exe'); + expect(command).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); expect(args).toEqual(['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', expect.any(String)]); expect(options).toMatchObject({ input: JSON.stringify({ directory: path.resolve(directory) }), @@ -91,4 +93,16 @@ describe('assertWindowsCredentialDirectory', () => { 'choose a private directory under the current user profile' ); }); + + it.each(['.', 'C:Windows', '\\Windows'])( + 'rejects a drive-relative SystemRoot %s instead of resolving an executable from the repository', + (systemRoot) => { + withWindowsPlatform(); + vi.stubEnv('SystemRoot', systemRoot); + expect(() => assertWindowsCredentialDirectory('/tmp/relay-acl-private')).toThrow( + 'Windows Relaycast credential storage requires a private directory' + ); + expect(execFileSyncMock).not.toHaveBeenCalled(); + } + ); }); diff --git a/packages/cloud/src/credential-directory-windows.ts b/packages/cloud/src/credential-directory-windows.ts index 10f72ee58f..9032af24b7 100644 --- a/packages/cloud/src/credential-directory-windows.ts +++ b/packages/cloud/src/credential-directory-windows.ts @@ -149,10 +149,13 @@ export function assertWindowsCredentialDirectory(directory: string): void { if (process.platform !== 'win32') return; const absoluteDirectory = path.resolve(directory); + const systemRoot = process.env.SystemRoot?.trim() || 'C:\\Windows'; + if (!/^[A-Za-z]:[\\/]/.test(systemRoot)) throw privateDirectoryError(); + const powershell = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); let output: string; try { output = execFileSync( - 'powershell.exe', + powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_SCRIPT], { input: JSON.stringify({ directory: absoluteDirectory }), diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 82b82e4713..5fb2688c54 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -157,78 +157,86 @@ describe('workspace store', () => { expect(fs.statSync(relaycastCredentialStorePath()).mode & 0o777).toBe(0o600); }); - it('preserves concurrent credential writes and never exposes a partial JSON read', async () => { - writeRelaycastCredential('sentinel', { - workspaceId: 'rw_sentinel', - route: 'canonical', - baseUrl: 'https://relay.example', - apiKey: 'rk_live_sentinel', - }); - const source = fs.readFileSync(new URL('./workspace-store.ts', import.meta.url), 'utf8'); - fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' })); - fs.writeFileSync( - path.join(dir, 'credential-directory-windows.js'), - ts.transpileModule( - fs.readFileSync(new URL('./credential-directory-windows.ts', import.meta.url), 'utf8'), - { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } } - ).outputText - ); - const worker = path.join(dir, 'workspace-store-worker.mjs'); - fs.writeFileSync( - worker, - `${ - ts.transpileModule(source, { - compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, - }).outputText - }\n${[ - 'const mode = process.argv[2];', - 'const id = process.argv[3] ?? "reader";', - 'if (mode === "reader") {', - ' for (let index = 0; index < 500; index += 1) {', - ' if (!readRelaycastCredential("sentinel")) process.exit(3);', - ' }', - ' process.exit(0);', - '}', - 'for (let index = 0; index < 12; index += 1) {', - ' writeRelaycastCredential(`${id}-${index}`, { workspaceId: `${id}-${index}`, route: "canonical", baseUrl: "https://relay.example", apiKey: `rk_live_${id}_${index}` });', - '}', - 'process.exit(0);', - ].join('\n')}`, - { mode: 0o600 } - ); + it( + 'preserves concurrent credential writes and never exposes a partial JSON read', + async () => { + writeRelaycastCredential('sentinel', { + workspaceId: 'rw_sentinel', + route: 'canonical', + baseUrl: 'https://relay.example', + apiKey: 'rk_live_sentinel', + }); + const source = fs.readFileSync(new URL('./workspace-store.ts', import.meta.url), 'utf8'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' })); + fs.writeFileSync( + path.join(dir, 'credential-directory-windows.js'), + ts.transpileModule( + fs.readFileSync(new URL('./credential-directory-windows.ts', import.meta.url), 'utf8'), + { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } } + ).outputText + ); + const worker = path.join(dir, 'workspace-store-worker.mjs'); + fs.writeFileSync( + worker, + `${ + ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 }, + }).outputText + }\n${[ + 'const mode = process.argv[2];', + 'const id = process.argv[3] ?? "reader";', + 'if (mode === "reader") {', + ' for (let index = 0; index < 500; index += 1) {', + ' if (!readRelaycastCredential("sentinel")) process.exit(3);', + ' }', + ' process.exit(0);', + '}', + 'for (let index = 0; index < 12; index += 1) {', + ' writeRelaycastCredential(`${id}-${index}`, { workspaceId: `${id}-${index}`, route: "canonical", baseUrl: "https://relay.example", apiKey: `rk_live_${id}_${index}` });', + '}', + 'process.exit(0);', + ].join('\n')}`, + { mode: 0o600 } + ); - const run = (mode: 'reader' | 'writer', id: string): Promise => - new Promise((resolve, reject) => { - const child = spawn(process.execPath, [worker, mode, id], { - env: { AGENT_RELAY_HOME: dir, NODE_ENV: 'test' }, - stdio: ['ignore', 'ignore', 'pipe'], - }); - let stderr = ''; - child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.once('error', reject); - child.once('exit', (code) => { - if (code === 0) resolve(); - else reject(new Error(`credential ${mode} worker ${id} exited ${code}: ${stderr}`)); + const run = (mode: 'reader' | 'writer', id: string): Promise => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [worker, mode, id], { + env: { + AGENT_RELAY_HOME: dir, + NODE_ENV: 'test', + ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`credential ${mode} worker ${id} exited ${code}: ${stderr}`)); + }); }); - }); - const results = await Promise.allSettled([ - ...Array.from({ length: 8 }, (_, index) => run('writer', `writer-${index}`)), - run('reader', 'reader-a'), - run('reader', 'reader-b'), - ]); - const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); - if (failed) throw failed.reason; + const results = await Promise.allSettled([ + ...Array.from({ length: 8 }, (_, index) => run('writer', `writer-${index}`)), + run('reader', 'reader-a'), + run('reader', 'reader-b'), + ]); + const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed) throw failed.reason; - const stored = JSON.parse(fs.readFileSync(relaycastCredentialStorePath(), 'utf8')) as { - credentials: Record; - }; - expect(Object.keys(stored.credentials)).toHaveLength(1 + 8 * 12); - expect(stored.credentials.sentinel.apiKey).toBe('rk_live_sentinel'); - expect(readRelaycastCredential('writer-7-11')?.apiKey).toBe('rk_live_writer-7_11'); - }, 30_000); + const stored = JSON.parse(fs.readFileSync(relaycastCredentialStorePath(), 'utf8')) as { + credentials: Record; + }; + expect(Object.keys(stored.credentials)).toHaveLength(1 + 8 * 12); + expect(stored.credentials.sentinel.apiKey).toBe('rk_live_sentinel'); + expect(readRelaycastCredential('writer-7-11')?.apiKey).toBe('rk_live_writer-7_11'); + }, + process.platform === 'win32' ? 120_000 : 30_000 + ); it('reclaims a stale credential lock left by an exited writer', () => { const lock = `${relaycastCredentialStorePath()}.lock`; From c44945d1b04c23e64926929c314791cbc21b13d8 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:52:02 +0200 Subject: [PATCH 21/41] Harden workspace resolution proof gates Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.test.ts | 8 ++- packages/cloud/src/workspaces.test.ts | 55 ++++--------------- packages/cloud/src/workspaces.ts | 11 +--- .../cases/1763-zero-config-sandbox/run.mjs | 52 ++++++++---------- 4 files changed, 42 insertions(+), 84 deletions(-) diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 5fb2688c54..a9d91f2a78 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -23,7 +23,11 @@ let dir: string; const original = process.env.AGENT_RELAY_HOME; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-ws-')); + // The Windows ACL validator deliberately rejects the runner's shared temp + // directory. Real credentials live below the user's profile, so exercise + // the concurrent-writer path at that same boundary on Windows. + const parent = process.platform === 'win32' ? os.homedir() : os.tmpdir(); + dir = fs.mkdtempSync(path.join(parent, 'relay-ws-')); process.env.AGENT_RELAY_HOME = dir; }); @@ -89,7 +93,7 @@ describe('workspace store', () => { } ); - it('rejects a symlinked credential parent', () => { + it.skipIf(process.platform === 'win32')('rejects a symlinked credential parent', () => { const target = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-ws-parent-target-')); const linkContainer = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-ws-parent-link-')); const link = path.join(linkContainer, 'home'); diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 771eac5aad..6254cb9f4c 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -2,8 +2,6 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { createServer } from 'node:http'; -import { once } from 'node:events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -176,47 +174,18 @@ describe('resolveWorkspaceByKey', () => { ); }); - it.each(['http://cloud.example.test', 'https://user:password@cloud.example.test', 'file:///tmp/cloud'])( - 'rejects unsafe resolver transport before any credential request: %s', - async (apiUrl) => { - const fetchSpy = vi.fn(); - vi.stubGlobal('fetch', fetchSpy); - await expect(resolveWorkspaceByKey('rk_live_selected', { apiUrl })).rejects.toThrow('requires HTTPS'); - expect(fetchSpy).not.toHaveBeenCalled(); - } - ); - - it.each([307, 308])('never forwards a workspace key to a redirect target (%s)', async (status) => { - let forwardedRequests = 0; - let sourceRequests = 0; - const target = createServer((_request, response) => { - forwardedRequests++; - response.end('{}'); - }); - target.listen(0, '127.0.0.1'); - await once(target, 'listening'); - const targetAddress = target.address() as { port: number }; - const source = createServer((_request, response) => { - sourceRequests++; - response.writeHead(status, { location: `http://127.0.0.1:${targetAddress.port}/capture` }); - response.end(); - }); - source.listen(0, '127.0.0.1'); - await once(source, 'listening'); - const sourceAddress = source.address() as { port: number }; - process.env.CLOUD_API_URL = `http://127.0.0.1:${sourceAddress.port}`; - try { - await expect(resolveWorkspaceByKey('rk_live_selected')).rejects.toThrow(); - expect(sourceRequests).toBe(1); - expect(forwardedRequests).toBe(0); - } finally { - source.closeAllConnections(); - target.closeAllConnections(); - await Promise.all([ - new Promise((resolve) => source.close(() => resolve())), - new Promise((resolve) => target.close(() => resolve())), - ]); - } + it.each([ + 'http://cloud.example.test', + 'http://localhost:8787', + 'http://127.0.0.1:8787', + 'http://[::1]:8787', + 'https://user:password@cloud.example.test', + 'file:///tmp/cloud', + ])('rejects unsafe resolver transport before any credential request: %s', async (apiUrl) => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + await expect(resolveWorkspaceByKey('rk_live_selected', { apiUrl })).rejects.toThrow('requires HTTPS'); + expect(fetchSpy).not.toHaveBeenCalled(); }); it('rejects an insecure stored session host before refreshing its credentials', async () => { diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index ceb750f76c..bd61c26aaa 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -349,15 +349,8 @@ function assertWorkspaceResolverTransport(apiUrl: string): void { } catch { throw new Error('Project workspace resolution requires a valid Cloud API URL.'); } - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); - if ( - url.username || - url.password || - (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) - ) { - throw new Error( - 'Project workspace resolution requires HTTPS (HTTP is allowed only for a local development server).' - ); + if (url.username || url.password || url.protocol !== 'https:') { + throw new Error('Project workspace resolution requires HTTPS.'); } } diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index ee67bc9b54..260c105834 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from 'node:child_process'; -import { appendFile, lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,10 +19,10 @@ if (!expectedSha || targetSha !== expectedSha) if (!isWithin(harnessDir, fileURLToPath(import.meta.url))) throw new Error('Runner is not from exact-head harness.'); -let excludeState; const probePath = path.join(targetDir, 'packages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts'); const configPath = path.join(targetDir, '.relayflow', '1763-zero-config-sandbox.vitest.config.mjs'); const observationPath = path.join(targetDir, '.relayflow-1763-zero-config-sandbox-observation.json'); +const excludePath = path.join(path.dirname(resultPath), `${CASE_ID}-${arm}.exclude`); const revision = expectedSha; const configSource = `import path from 'node:path'; const names = ['cloud','config','fleet','harness-driver','harnesses','policy','sdk','session','utils']; @@ -66,8 +66,7 @@ test('fleet sandbox CLI forwards exact repo revision and uses returned provider await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); const body = requests[1]?.body ?? {}; await writeFile(output, JSON.stringify({ requestCount: requests.length, requestRepos: body.repos ?? null, requestRepoRevisions: body.repoRevisions ?? null, resultRepoRevisions: body.repoRevisions ?? null, workloadProfile: body.workloadProfile ?? null, cleanupProviderIds: deletes.map((x) => x.providerId ?? null), launcherReleases: releases.length, warnings }, null, 2)); - expect(releases.length).toBe(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); - if (${JSON.stringify(arm)} === 'head') { expect(body.repos).toEqual(['AgentWorkforce/relay']); expect(body.repoRevisions).toEqual({ 'AgentWorkforce/relay': revision }); expect(body.workloadProfile).toBe('long-running-agent'); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } + if (${JSON.stringify(arm)} === 'head') { expect(body.repos).toEqual(['AgentWorkforce/relay']); expect(body.repoRevisions).toEqual({ 'AgentWorkforce/relay': revision }); expect(body.workloadProfile).toBe('long-running-agent'); expect(releases).toHaveLength(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } else { expect(body.repoRevisions ?? null).toBe(null); expect(body.workloadProfile).toBe('long-running-agent'); } }); `; @@ -80,7 +79,10 @@ try { 'Cloud dependency installation', INSTALL_TIMEOUT_MS ); - excludeState = await prepareGitExclude(targetDir); + await writeGeneratedFile( + excludePath, + '.relayflow-1763-zero-config-sandbox-observation.json\npackages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts\n.relayflow/1763-zero-config-sandbox.vitest.config.mjs\nnode_modules\n' + ); await writeGeneratedFile(probePath, probeSource); await mkdir(path.dirname(configPath), { recursive: true }); await writeGeneratedFile(configPath, configSource); @@ -90,7 +92,10 @@ try { targetDir, 'CLI repository revision proof', PROBE_TIMEOUT_MS, - { RELAY_PR1763_OBSERVATION_PATH: observationPath } + { + RELAY_PR1763_OBSERVATION_PATH: observationPath, + ...withGitExcludeEnv(excludePath), + } ); const observation = JSON.parse(await readFile(observationPath, 'utf8')); const forwarded = @@ -108,7 +113,7 @@ try { await rm(probePath, { force: true }); await rm(configPath, { force: true }); await rm(observationPath, { force: true }); - if (excludeState) await restoreGitExclude(excludeState); + await rm(excludePath, { force: true }); } function requiredValue(name) { const value = process.env[name]?.trim(); @@ -135,30 +140,17 @@ function run(command, args, cwd, label, timeoutMs, extraEnv = {}) { if (result.error) throw new Error(`${label} could not start: ${result.error.message}`); if (result.status !== 0) throw new Error(`${label} failed with ${result.status}`); } -async function prepareGitExclude(root) { - const raw = execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { - encoding: 'utf8', - }).trim(); - const file = path.isAbsolute(raw) ? raw : path.resolve(root, raw); - let original = null; - try { - original = await readFile(file); - } catch (error) { - if (error?.code !== 'ENOENT') throw error; - } - const marker = Buffer.from( - '\n# relayflow-1763 generated probe\n.relayflow-1763-zero-config-sandbox-observation.json\npackages/cloud/src/.relayflow-1763-zero-config-sandbox.test.ts\n.relayflow/1763-zero-config-sandbox.vitest.config.mjs\nnode_modules\n' - ); - const existing = original ?? Buffer.alloc(0); - if (!existing.includes(marker)) await appendFile(file, marker); - return { file, original }; -} -async function restoreGitExclude(state) { - if (state.original === null) { - await rm(state.file, { force: true }); - } else { - await writeFile(state.file, state.original); +function withGitExcludeEnv(file) { + const rawCount = process.env.GIT_CONFIG_COUNT; + const count = rawCount === undefined ? 0 : Number.parseInt(rawCount, 10); + if (!Number.isSafeInteger(count) || count < 0 || count > 100) { + throw new Error('Invalid inherited GIT_CONFIG_COUNT.'); } + return { + GIT_CONFIG_COUNT: String(count + 1), + [`GIT_CONFIG_KEY_${count}`]: 'core.excludesFile', + [`GIT_CONFIG_VALUE_${count}`]: file, + }; } async function writeGeneratedFile(file, contents) { try { From 6cb12c7ae44f085cf2bbc171aaf74620b1802b7e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 01:57:17 +0200 Subject: [PATCH 22/41] Isolate Windows credential lock stress test Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index a9d91f2a78..ccb4261a49 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -172,12 +172,13 @@ describe('workspace store', () => { }); const source = fs.readFileSync(new URL('./workspace-store.ts', import.meta.url), 'utf8'); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' })); + // The parent write above validates the real directory ACL once. This + // fixture isolates the file-lock protocol: otherwise each of the ten + // child processes starts PowerShell for every write and the test measures + // process-start contention instead of credential-store atomicity. fs.writeFileSync( path.join(dir, 'credential-directory-windows.js'), - ts.transpileModule( - fs.readFileSync(new URL('./credential-directory-windows.ts', import.meta.url), 'utf8'), - { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ES2022 } } - ).outputText + 'export function assertWindowsCredentialDirectory() {}\n' ); const worker = path.join(dir, 'workspace-store-worker.mjs'); fs.writeFileSync( From f9a3473b6603231574db5366a5983a7f2644da7d Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 02:00:41 +0200 Subject: [PATCH 23/41] Retry atomic credential replacement on Windows Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 3cbe295eb7..3fbdb9dee9 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -93,6 +93,7 @@ export function writeRelaycastCredential( const RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS = 10_000; const RELAYCAST_CREDENTIAL_LOCK_STALE_MS = 30_000; const RELAYCAST_CREDENTIAL_LOCK_RETRY_MS = 10; +const RELAYCAST_CREDENTIAL_REPLACE_TIMEOUT_MS = 2_000; const RELAYCAST_CREDENTIAL_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4)); const RELAYCAST_CREDENTIAL_LOCK_OWNER_VERSION = 1; @@ -294,7 +295,7 @@ function writeRelaycastCredentialAtomically( fs.closeSync(descriptor); descriptor = undefined; fs.chmodSync(temporary, 0o600); - fs.renameSync(temporary, file); + replaceRelaycastCredentialFile(temporary, file); fs.chmodSync(file, 0o600); } catch (error) { if (descriptor !== undefined) fs.closeSync(descriptor); @@ -307,6 +308,28 @@ function writeRelaycastCredentialAtomically( } } +function replaceRelaycastCredentialFile(temporary: string, file: string): void { + const startedAt = Date.now(); + while (true) { + try { + fs.renameSync(temporary, file); + return; + } catch (error) { + const retryableWindowsSharingViolation = + process.platform === 'win32' && + isNodeError(error) && + (error.code === 'EACCES' || error.code === 'EBUSY' || error.code === 'EPERM'); + if ( + !retryableWindowsSharingViolation || + Date.now() - startedAt >= RELAYCAST_CREDENTIAL_REPLACE_TIMEOUT_MS + ) { + throw error; + } + Atomics.wait(RELAYCAST_CREDENTIAL_LOCK_WAIT, 0, 0, RELAYCAST_CREDENTIAL_LOCK_RETRY_MS); + } + } +} + function isNodeError(err: unknown): err is NodeJS.ErrnoException { return err instanceof Error && 'code' in err; } From 669d17c0d4361fb32ae545cdcd22b24a1f8bf117 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 02:04:39 +0200 Subject: [PATCH 24/41] Treat Windows lock sharing violations as contention Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 3fbdb9dee9..b89bfa3008 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -183,7 +183,7 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { } break; } catch (error) { - if (!(isNodeError(error) && error.code === 'EEXIST')) throw error; + if (!isRelaycastCredentialLockContention(error)) throw error; } try { if ( @@ -245,6 +245,19 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { } } +function isRelaycastCredentialLockContention(error: unknown): boolean { + if (!isNodeError(error)) return false; + if (error.code === 'EEXIST') return true; + // On Windows a competing process can surface directory create/remove races + // as sharing violations instead of EEXIST. The credential directory was + // already ownership/ACL validated above, so retry these codes within the + // same bounded lock timeout and still fail closed if they persist. + return ( + process.platform === 'win32' && + (error.code === 'EACCES' || error.code === 'EBUSY' || error.code === 'EPERM') + ); +} + function inspectRelaycastCredentialLock(lock: string): { entries: string[]; ownerIsAlive: boolean; From 603fe3e4dedfb1d7e355cd76318170e68db1d38b Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 02:12:12 +0200 Subject: [PATCH 25/41] Bound Windows credential lock contention Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index b89bfa3008..0f4d19e3cd 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -160,6 +160,9 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { } assertCredentialAncestors(directory); while (true) { + if (Date.now() - startedAt >= RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS) { + throw new Error('Timed out waiting for the Relaycast credential store lock.'); + } try { fs.mkdirSync(lock, { mode: 0o700 }); try { @@ -212,9 +215,6 @@ function withRelaycastCredentialLock(file: string, fn: () => T): T { if (isNodeError(error) && error.code === 'ENOENT') continue; throw error; } - if (Date.now() - startedAt >= RELAYCAST_CREDENTIAL_LOCK_TIMEOUT_MS) { - throw new Error('Timed out waiting for the Relaycast credential store lock.'); - } Atomics.wait(RELAYCAST_CREDENTIAL_LOCK_WAIT, 0, 0, RELAYCAST_CREDENTIAL_LOCK_RETRY_MS); } let callbackFailed = false; From 12724359ebb3a51f680deb5e814bf767175b582e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 05:52:20 +0200 Subject: [PATCH 26/41] refactor(cli): remove unreachable reused mount branch Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/commands/fleet.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 5b555aea9e..0c56a0069c 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -725,11 +725,8 @@ export function registerFleetCommands( name, cli, task: - sandbox && - sandboxRepository && - mountSandboxRelayfile && - (sandbox.outcome === 'provisioned' || sandbox.outcome === 'reused') - ? `${task}\n\nAgent Relay sandbox context: Relayfile records are available at ${sandbox.outcome === 'provisioned' ? (sandbox.relayfileMountPath ?? '/workspace') : '/workspace'}. The source checkout is separate; use ${workerCwd ?? 'the worker checkout'} for repository files and the mount for Relayfile records.` + sandbox && sandboxRepository && mountSandboxRelayfile && sandbox.outcome === 'provisioned' + ? `${task}\n\nAgent Relay sandbox context: Relayfile records are available at ${sandbox.relayfileMountPath ?? '/workspace'}. The source checkout is separate; use ${workerCwd ?? 'the worker checkout'} for repository files and the mount for Relayfile records.` : task, ...(channel ? { channels: [channel] } : {}), ...(model ? { model } : {}), From eb589528d93a86c01bfd259c710860d4acd8b5be Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 07:20:18 +0200 Subject: [PATCH 27/41] fix(cloud): preserve secure project routing aliases Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/auth.test.ts | 47 +++++++++++++++++++ packages/cloud/src/auth.ts | 20 +++++++- .../cloud/src/project-workspace-key.test.ts | 26 ++++++++++ packages/cloud/src/workspace-store.test.ts | 31 ++++++++++++ packages/cloud/src/workspace-store.ts | 28 ++++++++++- packages/cloud/src/workspaces.test.ts | 26 ++++++++++ packages/cloud/src/workspaces.ts | 6 ++- 7 files changed, 180 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index d7e5c6d5f2..4e9aeda8c6 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -855,6 +855,53 @@ describe('authorizedApiFetch telemetry headers', () => { }); describe('authorizedApiFetch re-login', () => { + it('rejects a refresh-selected unsafe API URL before persisting or retrying credentials', async () => { + const storedAuth: StoredAuth = { + apiUrl: 'https://api.example.test', + accessToken: 'stale-access', + refreshToken: 'stale-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + }; + fsMocks.readFile.mockResolvedValue(JSON.stringify(storedAuth)); + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.includes('/api/v1/auth/token/refresh')) { + return new Response( + JSON.stringify({ + accessToken: 'rotated-access', + refreshToken: 'rotated-refresh', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + apiUrl: 'http://unsafe.example.test', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + return new Response('{}', { status: 401 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + authorizedApiFetch( + storedAuth, + '/api/v1/workspaces/current/resolve', + { method: 'POST', body: JSON.stringify({ workspaceKey: 'rk_live_selected' }) }, + { + interactive: false, + validateApiUrl: (apiUrl) => { + if (new URL(apiUrl).protocol !== 'https:') throw new Error('requires HTTPS'); + }, + } + ) + ).rejects.toThrow('requires HTTPS'); + + const requested = fetchSpy.mock.calls.map((call) => String(call[0])); + expect(requested).toEqual([ + 'https://api.example.test/api/v1/workspaces/current/resolve', + 'https://api.example.test/api/v1/auth/token/refresh', + ]); + expect(fsMocks.writeFile).not.toHaveBeenCalled(); + }); + it('re-authenticates a headless host through the device flow, not the browser', async () => { // The steady state this feature exists for: barry logged in once over ssh // with `--device`, and now a request 401s with a refresh token the server diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 335dc8abb1..6e40b5be4a 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -563,10 +563,17 @@ async function beginBrowserLogin(apiUrl: string): Promise { export async function refreshStoredAuth( auth: StoredAuth, - options: { force?: boolean; refreshTimeoutMs?: number; signal?: AbortSignal } = {} + options: { + force?: boolean; + refreshTimeoutMs?: number; + signal?: AbortSignal; + validateApiUrl?: (apiUrl: string) => void; + } = {} ): Promise { if (isEnvBackedAuth(auth)) { - return markEnvBackedAuth(await requestStoredAuthRefresh(auth, options)); + const nextAuth = await requestStoredAuthRefresh(auth, options); + options.validateApiUrl?.(nextAuth.apiUrl); + return markEnvBackedAuth(nextAuth); } return withStoredAuthLock(async () => { @@ -578,6 +585,10 @@ export async function refreshStoredAuth( } const nextAuth = await requestStoredAuthRefresh(refreshSource, options); + // Some credentialed callers impose a stricter transport contract than the + // general Cloud client. Validate a refresh-selected host before persisting + // the rotated credentials or allowing a retry to send them there. + options.validateApiUrl?.(nextAuth.apiUrl); await writeStoredAuth(nextAuth); return nextAuth; }, options); @@ -816,9 +827,12 @@ export async function authorizedApiFetch( */ device?: boolean; env?: NodeJS.ProcessEnv; + /** Validate every host before a bearer-authenticated request uses it. */ + validateApiUrl?: (apiUrl: string) => void; } = {} ): Promise<{ response: Response; auth: StoredAuth }> { let activeAuth = auth; + options.validateApiUrl?.(activeAuth.apiUrl); let response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); if (response.status !== 401) { @@ -831,6 +845,7 @@ export async function authorizedApiFetch( force: true, refreshTimeoutMs: options.refreshTimeoutMs, signal: init.signal ?? undefined, + validateApiUrl: options.validateApiUrl, }); activeAuth = refreshableAuth; } catch (error) { @@ -860,6 +875,7 @@ export async function authorizedApiFetch( }); } + options.validateApiUrl?.(activeAuth.apiUrl); response = await apiFetch(activeAuth.apiUrl, activeAuth.accessToken, requestPath, init); return { response, auth: activeAuth }; } diff --git a/packages/cloud/src/project-workspace-key.test.ts b/packages/cloud/src/project-workspace-key.test.ts index 54f11c2e19..3977240d1a 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -65,6 +65,32 @@ describe('project workspace key resolution', () => { } }); + it.skipIf(process.platform === 'win32')( + 'recovers a route credential after switching from a symlink alias to the canonical project path', + () => { + const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-workspace-alias-')); + const alias = path.join(aliasRoot, 'checkout'); + fs.symlinkSync(root, alias, 'dir'); + try { + writeProjectWorkspaceKey(path.join(alias, '.agentworkforce/relay'), 'rk_canonical', { + workspaceId: 'rw_alias', + relaycastRoute: 'agent37-isolated', + relaycastBaseUrl: 'https://agent37-cast.agentrelay.com', + relaycastApiKey: 'rk_live_alias', + env: { AGENT_RELAY_HOME: home }, + }); + + expect(readProjectWorkspaceSession(dataDir, fs, { AGENT_RELAY_HOME: home })).toMatchObject({ + workspaceKey: 'rk_canonical', + workspaceId: 'rw_alias', + relaycastApiKey: 'rk_live_alias', + }); + } finally { + fs.rmSync(aliasRoot, { recursive: true, force: true }); + } + } + ); + it('preserves a route credential through metadata updates in the selected credential home', () => { writeProjectWorkspaceKey(dataDir, 'rk_canonical', { workspaceId: 'rw_abc', diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index ccb4261a49..58bebd8d4f 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -307,6 +307,37 @@ describe('workspace store', () => { ); }); + it.skipIf(process.platform === 'win32')( + 'uses the same credential reference through a symlinked project alias', + () => { + const checkout = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-target-')); + const aliasRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-project-alias-')); + const alias = path.join(aliasRoot, 'checkout'); + fs.symlinkSync(checkout, alias, 'dir'); + try { + const canonicalDataDir = path.join(checkout, '.agentworkforce', 'relay'); + const aliasedDataDir = path.join(alias, '.agentworkforce', 'relay'); + expect( + relaycastCredentialRef(canonicalDataDir, 'rw_alias', 'canonical', 'https://relay.example') + ).toBe(relaycastCredentialRef(aliasedDataDir, 'rw_alias', 'canonical', 'https://relay.example')); + } finally { + fs.rmSync(aliasRoot, { recursive: true, force: true }); + fs.rmSync(checkout, { recursive: true, force: true }); + } + } + ); + + it.runIf(process.platform === 'win32')( + 'uses the same credential reference across Windows path casing aliases', + () => { + const projectDataDir = path.join(dir, 'ProjectCase', '.agentworkforce', 'relay'); + fs.mkdirSync(projectDataDir, { recursive: true }); + expect(relaycastCredentialRef(projectDataDir, 'rw_case', 'canonical', 'https://relay.example')).toBe( + relaycastCredentialRef(projectDataDir.toUpperCase(), 'rw_case', 'canonical', 'https://relay.example') + ); + } + ); + it('rejects reserved object-property workspace names', () => { expect(() => setWorkspaceKey('__proto__', 'rk_bad')).toThrow(/Invalid workspace name/); expect(({} as Record).polluted).toBeUndefined(); diff --git a/packages/cloud/src/workspace-store.ts b/packages/cloud/src/workspace-store.ts index 0f4d19e3cd..f455b77131 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -50,10 +50,36 @@ export function relaycastCredentialRef( // closed at read/transport time. } return createHash('sha256') - .update(`${path.resolve(projectDataDir)}\0${workspaceId}\0${route}\0${endpoint}`) + .update(`${canonicalProjectDataDir(projectDataDir)}\0${workspaceId}\0${route}\0${endpoint}`) .digest('hex'); } +function canonicalProjectDataDir(projectDataDir: string): string { + const resolved = path.resolve(projectDataDir); + let current = resolved; + const missingSegments: string[] = []; + + while (true) { + try { + const canonical = path.join(fs.realpathSync.native(current), ...missingSegments); + // Default Windows filesystems are case-insensitive. A stable case fold + // lets the same checkout resolve credentials across casing aliases while + // realpath above preserves the filesystem identity for existing paths. + return process.platform === 'win32' ? canonical.toLowerCase() : canonical; + } catch (error) { + if (!isNodeError(error) || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) { + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } + const parent = path.dirname(current); + if (parent === current) { + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } + missingSegments.unshift(path.basename(current)); + current = parent; + } + } +} + export function readRelaycastCredential( ref: string, env: NodeJS.ProcessEnv = process.env diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 6254cb9f4c..9c8e7e4e0f 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -199,6 +199,32 @@ describe('resolveWorkspaceByKey', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('rejects an insecure refresh-selected host before retrying the selected key', async () => { + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/api/v1/auth/token/refresh')) { + return new Response( + JSON.stringify({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + apiUrl: 'http://unsafe.example.test', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + return new Response('{}', { status: 401 }); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect(resolveWorkspaceByKey('rk_live_selected')).rejects.toThrow('requires HTTPS'); + + expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://cloud.example.test/api/v1/workspaces/current/resolve', + 'https://cloud.example.test/api/v1/auth/token/refresh', + ]); + }); + it('accepts the canonical Cloud relaycastApiKey echo without a legacy key alias', async () => { vi.stubGlobal( 'fetch', diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index bd61c26aaa..83e7e1b0ca 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -385,7 +385,11 @@ export async function resolveWorkspaceByKey( body: JSON.stringify({ workspaceKey: key }), signal: AbortSignal.timeout(options.refreshTimeoutMs ?? 30_000), }, - { interactive: false, env } + { + interactive: false, + env, + validateApiUrl: assertWorkspaceResolverTransport, + } ); const payload = await readJson(response); if (!response.ok) throw buildEndpointError('Project workspace resolve', endpoint, response, payload); From cf7167c3722fcc2625bd699ca8c08fbc80c689f5 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 07:34:35 +0200 Subject: [PATCH 28/41] fix(cloud): validate bootstrap routing context Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/commands/fleet.test.ts | 9 ++++- packages/cli/src/cli/commands/fleet.ts | 27 ++++++++++---- packages/cloud/src/auth.test.ts | 41 +++++++++++++++++++++ packages/cloud/src/auth.ts | 29 ++++++++++++--- packages/cloud/src/types.ts | 2 + packages/cloud/src/workspaces.ts | 1 + 6 files changed, 94 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 3e2557cc73..c6f4de1a2c 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1060,6 +1060,7 @@ describe('fleet command support', () => { workerCwd: '/srv/agent-workforce/cloud/packages/web', }; const resolveSandboxRepository = vi.fn(() => repositorySelection); + const findProjectRoot = vi.fn(() => '/local/cloud/packages/web'); const selectionOptions: Record[] = []; const persistWorkspaceRelaycastTarget = vi.fn(() => true); const placement = { @@ -1093,6 +1094,7 @@ describe('fleet command support', () => { program.exitOverride(); registerFleetCommands(program, { resolveSandboxRepository, + findProjectRoot, sdk: { createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1129,6 +1131,8 @@ describe('fleet command support', () => { 'agent37', '--workspace-id', 'rw_abc', + '--cwd', + 'packages/web', '--name', 'cloud-worker', '--task', @@ -1139,8 +1143,9 @@ describe('fleet command support', () => { { from: 'user' } ); - expect(resolveSandboxRepository).toHaveBeenCalled(); - expect(selectionOptions[0]).toMatchObject({ projectRoot: '/local/cloud' }); + expect(resolveSandboxRepository).toHaveBeenCalledWith(process.cwd(), 'packages/web'); + expect(findProjectRoot).toHaveBeenCalledWith(path.resolve(process.cwd(), 'packages/web')); + expect(selectionOptions[0]).toMatchObject({ projectRoot: '/local/cloud/packages/web' }); const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0] as Record; expect(ensureInput).toMatchObject({ repos: ['AgentWorkforce/cloud'], diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 0c56a0069c..ca1cef75d6 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { InvalidArgumentError, type Command } from 'commander'; +import { findProjectRoot } from '@agent-relay/config'; import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, @@ -146,6 +147,7 @@ export interface FleetCommandDependencies { createFleetWorkspaceClient: (options: SdkClientOptions) => RelayWorkspaceThinClient; resolveWorkspaceSelection: typeof resolveWorkspaceSelection; resolveSandboxRepository: typeof resolveSandboxRepository; + findProjectRoot: typeof findProjectRoot; resolveWorkspaceByKey: typeof resolveWorkspaceByKey; persistWorkspaceRelaycastTarget: typeof persistWorkspaceRelaycastTarget; ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; @@ -168,6 +170,7 @@ function withFleetDefaults(overrides: Partial = {}): F }, resolveWorkspaceSelection, resolveSandboxRepository, + findProjectRoot, resolveWorkspaceByKey, persistWorkspaceRelaycastTarget, ensureCloudFleetSandbox, @@ -349,7 +352,8 @@ export function registerFleetCommands( } const channel = optionalText(options.channel, 'Channel'); const model = optionalText(options.model, 'Model'); - let workerCwd = optionalText(options.cwd, 'Worker cwd'); + const requestedCwd = optionalText(options.cwd, 'Worker cwd'); + let workerCwd = requestedCwd; const organization = optionalText(options.organization, 'Organization'); const project = optionalText(options.project, 'Project'); const workstream = optionalText(options.workstream, 'Workstream'); @@ -380,16 +384,23 @@ export function registerFleetCommands( // inference must remain anchored to the actual checkout from which the // command was invoked. This also lets --cwd point at a sibling checkout. const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot; - sandboxRepository = deps.resolveSandboxRepository( - repositoryRootHint, - optionalText(options.cwd, 'Worker cwd') - ); + sandboxRepository = deps.resolveSandboxRepository(repositoryRootHint, requestedCwd); if (sandboxRepository) workerCwd = sandboxRepository.workerCwd; + const localRequestedCwd = + requestedCwd && !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) + ? path.resolve(process.cwd(), requestedCwd) + : undefined; + // `--cwd` selects both the local checkout subdirectory and its Relay + // project namespace. Resolve an intentional nested pin before mapping + // that local path to the remote checkout; only placement-safe Git + // identity crosses the Cloud boundary. const workspaceProjectRoot = hasExplicitProjectOverride ? coreProjectRoot - : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot) - ? coreProjectRoot - : (sandboxRepository?.projectRoot ?? coreProjectRoot); + : localRequestedCwd + ? deps.findProjectRoot(localRequestedCwd) + : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot) + ? coreProjectRoot + : (sandboxRepository?.projectRoot ?? coreProjectRoot); const sandboxClientOptions = { ...clientOptions, projectRoot: workspaceProjectRoot, diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index 4e9aeda8c6..7b294546aa 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -855,6 +855,47 @@ describe('authorizedApiFetch telemetry headers', () => { }); describe('authorizedApiFetch re-login', () => { + it('rejects an unsafe host selected while establishing an expired stored session', async () => { + const storedAuth: StoredAuth = { + apiUrl: 'https://stored.example.test', + accessToken: 'expired-access-token', + refreshToken: 'stored-refresh-token', + accessTokenExpiresAt: '2000-01-01T00:00:00.000Z', + }; + fsMocks.readFile.mockResolvedValue(JSON.stringify(storedAuth)); + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/api/v1/auth/token/refresh')) { + return new Response( + JSON.stringify({ + accessToken: 'rotated-access-token', + refreshToken: 'rotated-refresh-token', + accessTokenExpiresAt: '2999-01-01T00:00:00.000Z', + apiUrl: 'http://unsafe.example.test', + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal('fetch', fetchSpy); + + await expect( + ensureCloudSession({ + apiUrl: 'https://stored.example.test', + interactive: false, + validateApiUrl: (apiUrl) => { + if (new URL(apiUrl).protocol !== 'https:') throw new Error('requires HTTPS'); + }, + }) + ).rejects.toThrow('requires HTTPS'); + + expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://stored.example.test/api/v1/auth/token/refresh', + ]); + expect(fsMocks.writeFile).not.toHaveBeenCalled(); + }); + it('rejects a refresh-selected unsafe API URL before persisting or retrying credentials', async () => { const storedAuth: StoredAuth = { apiUrl: 'https://api.example.test', diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 6e40b5be4a..609dabe89f 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -738,12 +738,21 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro } if (!shouldRefreshStoredAuth(stored)) { - return createCloudSession(stored, { refreshTimeoutMs }); + return createCloudSession(stored, { + refreshTimeoutMs, + validateApiUrl: options.validateApiUrl, + }); } try { - const auth = await refreshStoredAuth(stored, { refreshTimeoutMs }); - return createCloudSession(auth, { refreshTimeoutMs }); + const auth = await refreshStoredAuth(stored, { + refreshTimeoutMs, + validateApiUrl: options.validateApiUrl, + }); + return createCloudSession(auth, { + refreshTimeoutMs, + validateApiUrl: options.validateApiUrl, + }); } catch (error) { if (isEnvBackedAuth(stored)) { throw toEnvAuthRefreshError(error); @@ -754,11 +763,20 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro } const auth = await loginInteractive(stored.apiUrl, { device: options.device, env }); - return createCloudSession(auth, { refreshTimeoutMs }); + return createCloudSession(auth, { + refreshTimeoutMs, + validateApiUrl: options.validateApiUrl, + }); } } -function createCloudSession(auth: StoredAuth, options: { refreshTimeoutMs?: number } = {}): CloudSession { +function createCloudSession( + auth: StoredAuth, + options: { + refreshTimeoutMs?: number; + validateApiUrl?: (apiUrl: string) => void; + } = {} +): CloudSession { const clientOptions: CloudApiClientOptions = { ...auth, refreshTimeoutMs: options.refreshTimeoutMs, @@ -771,6 +789,7 @@ function createCloudSession(auth: StoredAuth, options: { refreshTimeoutMs?: numb force: refreshOptions.force, refreshTimeoutMs: options.refreshTimeoutMs, signal: refreshOptions.signal, + validateApiUrl: options.validateApiUrl, }) ); } diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index 18e4b5daa0..d092699a46 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -43,6 +43,8 @@ export type CloudSessionOptions = { device?: boolean; refreshTimeoutMs?: number; env?: NodeJS.ProcessEnv; + /** Optional caller policy applied before refreshed credentials use a selected API host. */ + validateApiUrl?: (apiUrl: string) => void; }; export type WhoAmIResponse = { diff --git a/packages/cloud/src/workspaces.ts b/packages/cloud/src/workspaces.ts index 83e7e1b0ca..696f68f167 100644 --- a/packages/cloud/src/workspaces.ts +++ b/packages/cloud/src/workspaces.ts @@ -373,6 +373,7 @@ export async function resolveWorkspaceByKey( interactive: false, refreshTimeoutMs: options.refreshTimeoutMs, env, + validateApiUrl: assertWorkspaceResolverTransport, }); assertWorkspaceResolverTransport(auth.apiUrl); const endpoint = '/api/v1/workspaces/current/resolve'; From b33cfe92fc78a7cf0f53361dd3c41c05429b0088 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 07:35:42 +0200 Subject: [PATCH 29/41] test(cloud): exercise missing Windows path casing Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/workspace-store.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cloud/src/workspace-store.test.ts b/packages/cloud/src/workspace-store.test.ts index 58bebd8d4f..66d41b700c 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -331,9 +331,10 @@ describe('workspace store', () => { 'uses the same credential reference across Windows path casing aliases', () => { const projectDataDir = path.join(dir, 'ProjectCase', '.agentworkforce', 'relay'); - fs.mkdirSync(projectDataDir, { recursive: true }); + const caseAliasDataDir = path.join(dir, 'PROJECTCASE', '.agentworkforce', 'relay'); + expect(fs.existsSync(projectDataDir)).toBe(false); expect(relaycastCredentialRef(projectDataDir, 'rw_case', 'canonical', 'https://relay.example')).toBe( - relaycastCredentialRef(projectDataDir.toUpperCase(), 'rw_case', 'canonical', 'https://relay.example') + relaycastCredentialRef(caseAliasDataDir, 'rw_case', 'canonical', 'https://relay.example') ); } ); From 916555b4c546b02923297be3e8f4bd29185873c3 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 07:47:56 +0200 Subject: [PATCH 30/41] fix(cli): honor environment broker routing Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/README.md | 5 +- .../cli/src/cli/commands/local-agent.test.ts | 52 +++++++++++++++++++ packages/cli/src/cli/commands/local-agent.ts | 17 +++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 2149c66775..aceb2a1200 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -272,9 +272,10 @@ Relaycast credential stays in the machine store under `node agent attach ` automatically routes to the unique live fleet node advertising that worker. If more than one live node advertises the name, the command refuses to guess; pass `--node ` explicitly. Supplying -`--broker-url`, `--api-key`, or `--state-dir` keeps attach local and bypasses +`--broker-url`, `--api-key`, or `--state-dir`, or setting a nonblank +`RELAY_BROKER_URL` or `RELAY_BROKER_API_KEY`, keeps attach local and bypasses automatic Fleet routing. `node agent message flush|hold|auto ` uses the -same unique-node lookup when no local broker flags are supplied. Fleet list and +same unique-node lookup when no local broker selection is supplied. Fleet list and release commands reuse the persisted project route; if that remote session is unavailable, the command reports the routing failure instead of selecting a same-named local worker. diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index 7e5a2fa55b..30816a511d 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -82,6 +82,29 @@ describe('local agent subtree', () => { expect(attach).toHaveBeenCalledWith('lead', 'view', expect.anything()); }); + it.each([ + ['RELAY_BROKER_URL', 'http://127.0.0.1:7777'], + ['RELAY_BROKER_API_KEY', 'local-broker-key'], + ])('attach honors the local broker selected by %s before persisted Fleet routing', async (name, value) => { + const resolveFleetAttachTarget = vi.fn(async () => ({ + target: { + node: 'persisted-remote-node', + baseUrl: 'https://isolated.example.test', + agent: 'lead', + }, + })); + const { program, attach, attachNode } = harness({ + env: { [name]: value }, + resolveFleetAttachTarget, + }); + + await program.parseAsync(['local', 'agent', 'attach', 'lead'], { from: 'user' }); + + expect(resolveFleetAttachTarget).not.toHaveBeenCalled(); + expect(attachNode).not.toHaveBeenCalled(); + expect(attach).toHaveBeenCalledWith('lead', 'view', expect.anything()); + }); + it('forwards native harness output flags to the attach runner', async () => { const { program, attach } = harness(); await program.parseAsync(['local', 'agent', 'attach', 'lead', '--json', '--reasoning', '--diagnostics'], { @@ -934,6 +957,35 @@ describe('local agent subtree', () => { expect(log).toHaveBeenCalledWith(JSON.stringify({ name: 'claude', flushed: 2 }, null, 2)); }); + it.each([ + ['RELAY_BROKER_URL', 'http://127.0.0.1:7777'], + ['RELAY_BROKER_API_KEY', 'local-broker-key'], + ])( + 'message flush honors the local broker selected by %s before persisted Fleet routing', + async (name, value) => { + const client = { flushPending: vi.fn(async () => ({ flushed: 1 })) }; + const connectLocal = vi.fn(async () => client as never); + const resolveFleetAttachTarget = vi.fn(async () => ({ + target: { + node: 'persisted-remote-node', + baseUrl: 'https://isolated.example.test', + agent: 'claude', + }, + })); + const { program } = harness({ + env: { [name]: value }, + connectLocal, + resolveFleetAttachTarget, + }); + + await program.parseAsync(['local', 'agent', 'message', 'flush', 'claude'], { from: 'user' }); + + expect(resolveFleetAttachTarget).not.toHaveBeenCalled(); + expect(connectLocal).toHaveBeenCalled(); + expect(client.flushPending).toHaveBeenCalledWith('claude'); + } + ); + it.each(['flush', 'hold', 'auto'])( 'message %s rejects an explicit workspace key without a node', async (mode) => { diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index e32e0f9727..d46fad8f99 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -531,6 +531,19 @@ function brokerOptionsFromOpts(opts: Record): LocalAgentMessage }; } +function hasLocalBrokerSelection( + deps: Pick, + opts: Record +): boolean { + return Boolean( + opts.brokerUrl !== undefined || + opts.apiKey !== undefined || + opts.stateDir !== undefined || + deps.env.RELAY_BROKER_URL?.trim() || + deps.env.RELAY_BROKER_API_KEY?.trim() + ); +} + function parseRuntimeOption(deps: LocalAgentDependencies, value: unknown): HarnessRuntime | undefined { const runtime = (value ?? 'auto') as string; if (runtime === 'auto' || runtime === 'native' || runtime === 'pty') return runtime; @@ -630,7 +643,7 @@ async function withDeliveryModeClient( return undefined; } let targetBaseUrl: string | undefined; - if (!node && opts.brokerUrl === undefined && opts.apiKey === undefined && opts.stateDir === undefined) { + if (!node && !hasLocalBrokerSelection(deps, opts)) { const fleetTarget = await deps.resolveFleetAttachTarget(name); if (fleetTarget.error) { deps.error(`Error: ${fleetTarget.error}`); @@ -995,7 +1008,7 @@ export function registerLocalAgentCommands( // A sandbox worker has no local broker. Resolve a unique live fleet // placement before falling back to the local connection contract so a // flag-free attach follows the worker automatically. - if (options.brokerUrl === undefined && options.apiKey === undefined && options.stateDir === undefined) { + if (!hasLocalBrokerSelection(deps, options)) { const fleetTarget = await deps.resolveFleetAttachTarget(name); if (fleetTarget.error) { deps.error(`Error: ${fleetTarget.error}`); From 4ea236341cb54a968a598407d1eb181d11d4e1df Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 08:30:36 +0200 Subject: [PATCH 31/41] fix(cli): preserve nested sandbox project context Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/commands/fleet.test.ts | 6 +-- packages/cli/src/cli/commands/fleet.ts | 11 ++++- packages/cli/src/cli/lib/sandbox-repo.test.ts | 41 +++++++++++++++++++ packages/cli/src/cli/lib/sandbox-repo.ts | 6 ++- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index c6f4de1a2c..e6bd7654a3 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1060,7 +1060,7 @@ describe('fleet command support', () => { workerCwd: '/srv/agent-workforce/cloud/packages/web', }; const resolveSandboxRepository = vi.fn(() => repositorySelection); - const findProjectRoot = vi.fn(() => '/local/cloud/packages/web'); + const findProjectRoot = vi.fn(() => "/local/cloud/packages/web team's"); const selectionOptions: Record[] = []; const persistWorkspaceRelaycastTarget = vi.fn(() => true); const placement = { @@ -1145,7 +1145,7 @@ describe('fleet command support', () => { expect(resolveSandboxRepository).toHaveBeenCalledWith(process.cwd(), 'packages/web'); expect(findProjectRoot).toHaveBeenCalledWith(path.resolve(process.cwd(), 'packages/web')); - expect(selectionOptions[0]).toMatchObject({ projectRoot: '/local/cloud/packages/web' }); + expect(selectionOptions[0]).toMatchObject({ projectRoot: "/local/cloud/packages/web team's" }); const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0] as Record; expect(ensureInput).toMatchObject({ repos: ['AgentWorkforce/cloud'], @@ -1163,7 +1163,7 @@ describe('fleet command support', () => { ); expect(persistWorkspaceRelaycastTarget).toHaveBeenCalled(); expect(JSON.parse(logs[0]!).attachCommand).toBe( - "agent-relay node agent attach 'cloud-worker' --mode drive" + "cd '/local/cloud/packages/web team'\\''s' && agent-relay node agent attach 'cloud-worker' --mode drive" ); }); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index ca1cef75d6..9fb4fbc854 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -372,6 +372,7 @@ export function registerFleetCommands( let sandbox: EnsureCloudFleetSandboxResult | undefined; let sandboxRepository: SandboxRepositorySelection | undefined; + let attachProjectRoot: string | undefined; let workspaceRelay: ReturnType | undefined; let relaycastClientOptions = clientOptions; let legacyWorkspaceClientOptions = clientOptions; @@ -401,6 +402,9 @@ export function registerFleetCommands( : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot) ? coreProjectRoot : (sandboxRepository?.projectRoot ?? coreProjectRoot); + if (path.resolve(workspaceProjectRoot) !== path.resolve(coreProjectRoot)) { + attachProjectRoot = workspaceProjectRoot; + } const sandboxClientOptions = { ...clientOptions, projectRoot: workspaceProjectRoot, @@ -761,7 +765,7 @@ export function registerFleetCommands( ...(sandbox ? { sandbox: printableSandbox, - attachCommand: `agent-relay node agent attach ${shellQuote(name)} --mode drive`, + attachCommand: sandboxAttachCommand(name, attachProjectRoot), } : {}), invocation: spawnInvocationWithMergedPlacement(invocation as unknown as Record), @@ -943,6 +947,11 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +function sandboxAttachCommand(name: string, projectRoot: string | undefined): string { + const attach = `agent-relay node agent attach ${shellQuote(name)} --mode drive`; + return projectRoot ? `cd ${shellQuote(projectRoot)} && ${attach}` : attach; +} + /** * Warn (on stderr, so it never pollutes the JSON on stdout) when the workspace * key was inferred from the project's persisted session rather than named diff --git a/packages/cli/src/cli/lib/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts index 11a134da4d..814ed2e025 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.test.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -169,6 +169,47 @@ describe('resolveSandboxRepository', () => { ).toMatch(/^[a-f0-9]{40}$/); }); + it('permits generated Relay metadata for a nested project pin only', () => { + const run = gitMock(); + for (const file of [ + 'workspace-key.json', + 'connection.json', + 'runtime.json', + 'broker-cloud.lock', + ]) { + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('status')) return `?? packages/web/.agentworkforce/relay/${file}\0`; + return gitMock()(command, args); + }); + expect( + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ?.revision + ).toMatch(/^[a-f0-9]{40}$/); + } + + for (const file of [ + 'packages/web/.agentworkforce/relay/config.json', + 'packages/web/prefix.agentworkforce/relay/workspace-key.json', + 'packages/web/.agentworkforce/relay/nested/workspace-key.json', + ]) { + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('status')) return `?? ${file}\0`; + return gitMock()(command, args); + }); + expect(() => + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ).toThrow(/clean checkout/); + } + + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('status')) return ' M packages/web/.agentworkforce/relay/runtime.json\0'; + return gitMock()(command, args); + }); + expect(() => + resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + ).toThrow(/clean checkout/); + }); + it('maps a symlinked nested invocation to the actual Git root and relative directory', () => { const realpath = (value: string) => (value === '/linked/packages/cli' ? '/checkout/packages/cli' : value); expect( diff --git a/packages/cli/src/cli/lib/sandbox-repo.ts b/packages/cli/src/cli/lib/sandbox-repo.ts index 2425eb2a53..9999ef75d5 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.ts @@ -188,8 +188,10 @@ export function resolveSandboxRepository( function isGeneratedRelayMetadata(entry: string): boolean { const normalized = entry.replaceAll('\\', '/'); - if (!normalized.startsWith('.agentworkforce/relay/')) return false; - const relative = normalized.slice('.agentworkforce/relay/'.length); + const marker = '.agentworkforce/relay/'; + const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex !== 0 && (markerIndex < 1 || normalized[markerIndex - 1] !== '/')) return false; + const relative = normalized.slice(markerIndex + marker.length); return ( relative === 'workspace-key.json' || relative === 'connection.json' || From 8916ca9a9cd10f400796aea288717cfd99497696 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 13 Sep 2026 06:31:45 +0000 Subject: [PATCH 32/41] style: auto-format with Prettier Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cli/src/cli/lib/sandbox-repo.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/cli/lib/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts index 814ed2e025..5abe48d9dc 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.test.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -171,19 +171,16 @@ describe('resolveSandboxRepository', () => { it('permits generated Relay metadata for a nested project pin only', () => { const run = gitMock(); - for (const file of [ - 'workspace-key.json', - 'connection.json', - 'runtime.json', - 'broker-cloud.lock', - ]) { + for (const file of ['workspace-key.json', 'connection.json', 'runtime.json', 'broker-cloud.lock']) { run.mockImplementation((command: string, args: readonly string[]) => { if (args.includes('status')) return `?? packages/web/.agentworkforce/relay/${file}\0`; return gitMock()(command, args); }); expect( - resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) - ?.revision + resolveSandboxRepository('/checkout', undefined, { + cwd: () => '/checkout', + execFileSync: run as never, + })?.revision ).toMatch(/^[a-f0-9]{40}$/); } @@ -197,7 +194,10 @@ describe('resolveSandboxRepository', () => { return gitMock()(command, args); }); expect(() => - resolveSandboxRepository('/checkout', undefined, { cwd: () => '/checkout', execFileSync: run as never }) + resolveSandboxRepository('/checkout', undefined, { + cwd: () => '/checkout', + execFileSync: run as never, + }) ).toThrow(/clean checkout/); } From 70f08e11279afe0dfa90100cb2dd148f35c70b71 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 09:55:21 +0200 Subject: [PATCH 33/41] fix(cli): keep sandbox Relayfile live by default Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 6 +- packages/cli/README.md | 38 ++++++++----- packages/cli/src/cli/commands/fleet.test.ts | 56 +++++++++++++++++-- packages/cli/src/cli/commands/fleet.ts | 37 ++++++++---- .../cases/1763-zero-config-sandbox/case.json | 2 +- .../cases/1763-zero-config-sandbox/run.mjs | 5 +- 6 files changed, 110 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 592cf0c3a5..8bfac850df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `fleet spawn --sandbox` attests a clean, pushed GitHub checkout at its exact - commit before dispatch and maps nested local directories to the sandbox clone. +- `fleet spawn --sandbox --checkout` attests a clean, pushed GitHub checkout at + its exact commit before dispatch and maps nested local directories to the + static sandbox clone. Plain `--sandbox` keeps the live Relayfile mirror as the + worker's default filesystem. - `node agent attach ` automatically routes to a unique live Fleet node; ambiguous placements require `--node`. - `fleet spawn --sandbox` keeps temporary routing credentials out of project files. diff --git a/packages/cli/README.md b/packages/cli/README.md index aceb2a1200..b1eddba55f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -241,9 +241,10 @@ absent, it creates and removes a short-lived launcher identity automatically. Automatic placement and release need only the workspace key. The sandbox path provisions a fresh hosted instance and makes the Relayfile -mount mandatory by default. Outside Git, the worker starts in `/workspace`; -inside Git it starts in the corresponding repository checkout described below. -Both paths see the same synced Relayfile workspace. Use `--sandbox-provider daytona` or +mount mandatory by default. The worker starts in the live `/workspace` mirror, +including when the command runs inside a Git checkout. Use `--checkout` when a +task needs a static Git checkout at the caller's exact pushed commit; that mode +keeps the live Relayfile mirror available separately. Use `--sandbox-provider daytona` or `--sandbox-provider e2b` to require an operator-enabled provider; omit the flag to let Cloud's sandbox router choose. Pass `--no-sandbox-relayfile` only when a deliberately bare sandbox is desired. If provisioning times out or the spawn @@ -257,8 +258,8 @@ provisioning ends with an unknown outcome, rerun the command with the warning's `--sandbox-id` to replay the same Cloud identity instead of adopting another fleet node. -When the invocation runs inside a GitHub checkout, sandbox provisioning also -attests the checkout's exact `HEAD` and clones it under +With `--checkout`, sandbox provisioning attests the current GitHub checkout's +exact `HEAD` and clones it under `/srv/agent-workforce/`. The checkout must have no tracked changes or untracked source files, and the exact commit must be reachable from an origin remote. Relay's generated `.agentworkforce/relay/workspace-key.json`, @@ -280,20 +281,30 @@ release commands reuse the persisted project route; if that remote session is unavailable, the command reports the routing failure instead of selecting a same-named local worker. -From a clean repository already pinned to a Relay workspace, the ordinary path is: +From a project already pinned to a Relay workspace, the ordinary live path is: ```bash agent-relay fleet spawn codex \ --name cloud-zero-config \ - --task "Inspect this repository and report its current commit" \ + --task "Inspect the current Relayfile workspace and report its available skills" \ --sandbox agent-relay node agent attach cloud-zero-config --mode drive agent-relay fleet agent list agent-relay fleet release cloud-zero-config ``` -Invoking spawn from `packages/web` places the worker in that same relative -directory in the remote checkout. Private repositories use the pinned +For a static source task, opt in explicitly: + +```bash +agent-relay fleet spawn codex \ + --name cloud-checkout \ + --task "Inspect this repository and report its current commit" \ + --sandbox \ + --checkout +``` + +In checkout mode, invoking spawn from `packages/web` places the worker in that +same relative directory in the remote clone. Private repositories use the pinned workspace's connected GitHub access; a repository-access error means that connection must be granted access to the repository. No GitHub token or workspace key needs to be copied into the task or checkout. @@ -307,7 +318,8 @@ Detaching leaves the worker running. Only one drive session can own a worker at a time; detach the current driver before driving it in another shell, or use `--mode view` to observe. Releasing a worker does not delete its sandbox. To resume its retained sandbox, repeat spawn with the reported -`--sandbox-id `; the retained checkout must still have the same clean HEAD. +`--sandbox-id `; when using `--checkout`, the retained clone must still have +the same clean HEAD. A failed resume preserves retained work. Delete an unused sandbox in Cloud Fleet to stop future provider usage; monthly accounting reservations remain until their normal reset. @@ -315,9 +327,9 @@ until their normal reset. If the workspace is not pinned yet, use `agent-relay workspace rebind ` with an existing stored workspace. A missing or mismatched stored route credential requires rerunning sandbox provisioning for that workspace. -`--base-url`, `--workspace-id`, `--node`, provider selection, and `--cwd` remain -advanced overrides. Outside Git, the existing mount-based sandbox behavior is -preserved. +`--base-url`, `--workspace-id`, `--node`, provider selection, `--cwd`, and the +static `--checkout` mode remain advanced overrides. Plain `--sandbox` uses the +mount-based live workspace inside and outside Git. Pins created before workspace IDs were recorded are resolved automatically through Cloud at spawn time. The key travels in an authenticated POST body, diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index e6bd7654a3..8c45618653 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -936,11 +936,14 @@ describe('fleet command support', () => { relayfileMountPath: '/workspace', })); const deleteCloudFleetSandbox = vi.fn(async () => undefined); + const resolveSandboxRepository = vi.fn(() => { + throw new Error('default live-mount mode must not inspect Git'); + }); const logs: string[] = []; const program = new Command(); program.exitOverride(); registerFleetCommands(program, { - resolveSandboxRepository: () => undefined, + resolveSandboxRepository, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1001,6 +1004,7 @@ describe('fleet command support', () => { waitTimeoutMs: 90_000, name: REPLAY_SANDBOX_NAME, }); + expect(resolveSandboxRepository).not.toHaveBeenCalled(); expect(ensureInput?.name).toBe(`fleet-sandbox-${ensureInput?.sandboxId?.slice('sbx_'.length)}`); expect(register).toHaveBeenCalledWith( expect.objectContaining({ @@ -1020,6 +1024,7 @@ describe('fleet command support', () => { confirm: true, input: expect.objectContaining({ name: 'sandbox-worker', + task: 'Wait for VERIFY', worker_cwd: '/workspace', }), }) @@ -1050,7 +1055,7 @@ describe('fleet command support', () => { }); }); - it('infers the Git root for sandbox repos and forwards only the public revision attestation', async () => { + it('--checkout infers the Git root and forwards only the public revision attestation', async () => { const revision = '0123456789abcdef0123456789abcdef01234567'; const repositorySelection = { repository: 'AgentWorkforce/cloud', @@ -1127,6 +1132,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--checkout', '--sandbox-provider', 'agent37', '--workspace-id', @@ -1167,7 +1173,7 @@ describe('fleet command support', () => { ); }); - it('keeps cross-repo --cwd inference on the actual checkout while using the explicit project workspace', async () => { + it('--checkout keeps cross-repo --cwd inference on the actual checkout while using the explicit project workspace', async () => { vi.stubEnv('AGENT_RELAY_PROJECT', '/workspace-project'); const revision = '0123456789abcdef0123456789abcdef01234567'; const resolveSandboxRepository = vi.fn(() => ({ @@ -1231,6 +1237,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--checkout', '--sandbox-provider', 'e2b', '--no-sandbox-relayfile', @@ -1379,7 +1386,7 @@ describe('fleet command support', () => { ['missing', undefined], ['mismatched', { 'AgentWorkforce/cloud': 'fedcba9876543210fedcba9876543210fedcba98' }], ] as const)( - 'rejects a %s Cloud repository attestation and cleans up a fresh sandbox', + '--checkout rejects a %s Cloud repository attestation and cleans up a fresh sandbox', async (_label, actual) => { const revision = '0123456789abcdef0123456789abcdef01234567'; const resolveSandboxRepository = vi.fn(() => ({ @@ -1440,6 +1447,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--checkout', '--sandbox-provider', 'agent37', '--workspace-id', @@ -1463,7 +1471,7 @@ describe('fleet command support', () => { } ); - it('fails before Cloud when local repository inference is unavailable', async () => { + it('--checkout fails before Cloud when local repository inference is unavailable', async () => { const ensureCloudFleetSandbox = vi.fn(); const program = new Command(); program.exitOverride(); @@ -1496,6 +1504,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--checkout', '--name', 'cloud-worker', '--task', @@ -1509,6 +1518,43 @@ describe('fleet command support', () => { expect(ensureCloudFleetSandbox).not.toHaveBeenCalled(); }); + it('requires --sandbox when static checkout mode is requested', async () => { + const ensureCloudFleetSandbox = vi.fn(); + const resolveSandboxRepository = vi.fn(); + const errors: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository, + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: (message: unknown) => errors.push(String(message)), + exit: (() => { + throw new Error('__exit__'); + }) as never, + }, + ensureCloudFleetSandbox: ensureCloudFleetSandbox as never, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await expect( + program.parseAsync( + ['fleet', 'spawn', 'codex', '--checkout', '--name', 'cloud-worker', '--task', 'Work'], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + expect(errors.join('\n')).toContain('--checkout requires --sandbox'); + expect(resolveSandboxRepository).not.toHaveBeenCalled(); + expect(ensureCloudFleetSandbox).not.toHaveBeenCalled(); + }); + it('fleet spawn --sandbox reuses an Agent37 target without a Relayfile mount', async () => { const placement = { spawn: vi.fn(async () => ({ invocationId: 'inv_reused', node: { name: 'agent37-codex' } })), diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 9fb4fbc854..cdf3d3b583 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -263,6 +263,10 @@ export function registerFleetCommands( '--sandbox', 'Provision a fresh Cloud sandbox node, mount this Relayfile workspace, and spawn there' ) + .option( + '--checkout', + 'Materialize the current Git checkout at its exact pushed HEAD (static; requires --sandbox)' + ) .option( '--sandbox-name ', 'Explicit sandbox node name (custom unless --sandbox-id requires matching fleet-sandbox-)' @@ -302,6 +306,7 @@ export function registerFleetCommands( const task = requiredText(options.task, 'Task'); let targetNode = optionalText(options.targetNode, 'Target node') ?? optionalText(options.node, 'Node'); const useSandbox = options.sandbox === true; + const checkoutRepository = options.checkout === true; const sandboxName = optionalText(options.sandboxName, 'Sandbox name'); const sandboxIdOption = optionalText(options.sandboxId, 'Sandbox ID'); // An explicit sandbox identity is a retained/replayable resource. Never @@ -332,6 +337,9 @@ export function registerFleetCommands( if (!useSandbox && sandboxName) { throw new Error('--sandbox-name requires --sandbox.'); } + if (!useSandbox && checkoutRepository) { + throw new Error('--checkout requires --sandbox.'); + } if (!useSandbox && sandboxIdOption) { throw new Error('--sandbox-id requires --sandbox.'); } @@ -381,20 +389,27 @@ export function registerFleetCommands( const hasExplicitProjectOverride = Boolean( deps.core.env?.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim() ); - // AGENT_RELAY_PROJECT selects the workspace namespace, while repository - // inference must remain anchored to the actual checkout from which the - // command was invoked. This also lets --cwd point at a sibling checkout. - const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot; - sandboxRepository = deps.resolveSandboxRepository(repositoryRootHint, requestedCwd); - if (sandboxRepository) workerCwd = sandboxRepository.workerCwd; + if (checkoutRepository) { + // AGENT_RELAY_PROJECT selects the workspace namespace, while static + // checkout inference remains anchored to the actual Git tree. This + // also lets --cwd point at a sibling checkout when explicitly asked. + const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot; + sandboxRepository = deps.resolveSandboxRepository(repositoryRootHint, requestedCwd); + if (!sandboxRepository) { + throw new Error('--checkout requires a GitHub checkout with a clean, pushed commit.'); + } + workerCwd = sandboxRepository.workerCwd; + } const localRequestedCwd = - requestedCwd && !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) + checkoutRepository && + requestedCwd && + !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) ? path.resolve(process.cwd(), requestedCwd) : undefined; - // `--cwd` selects both the local checkout subdirectory and its Relay - // project namespace. Resolve an intentional nested pin before mapping - // that local path to the remote checkout; only placement-safe Git - // identity crosses the Cloud boundary. + // With --checkout, `--cwd` selects both the local checkout subdirectory + // and its Relay project namespace. Resolve an intentional nested pin + // before mapping that path to the static remote checkout; only + // placement-safe Git identity crosses the Cloud boundary. const workspaceProjectRoot = hasExplicitProjectOverride ? coreProjectRoot : localRequestedCwd diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/case.json b/tests/relayflows/cases/1763-zero-config-sandbox/case.json index 84ff50b23c..9e2d8774dd 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/case.json +++ b/tests/relayflows/cases/1763-zero-config-sandbox/case.json @@ -2,7 +2,7 @@ "version": 1, "id": "1763-zero-config-sandbox", "kind": "feature", - "title": "Forward exact repository identity for zero-config sandbox provisioning", + "title": "Opt into exact static repository checkout for sandbox provisioning", "runner": { "command": ["node", "tests/relayflows/cases/1763-zero-config-sandbox/run.mjs"] }, diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index 260c105834..c6b556d494 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -63,7 +63,8 @@ test('fleet sandbox CLI forwards exact repo revision and uses returned provider persistWorkspaceRelaycastTarget: () => true, log: () => undefined, warn: (...args) => warnings.push(args.join(' ')), error: () => undefined, }); - await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); + const checkoutArgs = ${JSON.stringify(arm)} === 'head' ? ['--checkout'] : []; + await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', ...checkoutArgs, '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); const body = requests[1]?.body ?? {}; await writeFile(output, JSON.stringify({ requestCount: requests.length, requestRepos: body.repos ?? null, requestRepoRevisions: body.repoRevisions ?? null, resultRepoRevisions: body.repoRevisions ?? null, workloadProfile: body.workloadProfile ?? null, cleanupProviderIds: deletes.map((x) => x.providerId ?? null), launcherReleases: releases.length, warnings }, null, 2)); if (${JSON.stringify(arm)} === 'head') { expect(body.repos).toEqual(['AgentWorkforce/relay']); expect(body.repoRevisions).toEqual({ 'AgentWorkforce/relay': revision }); expect(body.workloadProfile).toBe('long-running-agent'); expect(releases).toHaveLength(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } @@ -107,7 +108,7 @@ try { await mkdir(path.dirname(resultPath), { recursive: true }); await writeFile( resultPath, - `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the exact repository revision contract.' })}\n` + `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn --checkout command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the opt-in checkout and exact repository revision contract.' })}\n` ); } finally { await rm(probePath, { force: true }); From f5331c36337666053f7238bee05727b31449c4fc Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 10:21:28 +0200 Subject: [PATCH 34/41] feat(cli): mount repository through live Relayfile by default Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 9 +- packages/cli/README.md | 72 ++++--- packages/cli/src/cli/commands/fleet.test.ts | 145 ++++++++++++- packages/cli/src/cli/commands/fleet.ts | 128 ++++++++++-- packages/cli/src/cli/lib/sandbox-repo.test.ts | 20 +- packages/cli/src/cli/lib/sandbox-repo.ts | 25 +++ packages/cloud/src/fleet-sandbox.test.ts | 112 ++++++++++ packages/cloud/src/fleet-sandbox.ts | 191 ++++++++++++++++++ packages/cloud/src/index.ts | 4 + .../cases/1763-zero-config-sandbox/case.json | 6 +- .../cases/1763-zero-config-sandbox/run.mjs | 33 ++- 11 files changed, 683 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bfac850df..2c086fc95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `fleet spawn --sandbox --checkout` attests a clean, pushed GitHub checkout at - its exact commit before dispatch and maps nested local directories to the - static sandbox clone. Plain `--sandbox` keeps the live Relayfile mirror as the - worker's default filesystem. +- From a pinned GitHub repository, plain `fleet spawn --sandbox` securely seeds + its exact clean, pushed `HEAD` through the workspace's connected GitHub + credential, mounts the decoded source tree and `.skills` through Relayfile, + maps the caller's relative directory, and keeps the working tree synchronized + by the Relayfile daemon. `--checkout` opts into a separate static Git clone. - `node agent attach ` automatically routes to a unique live Fleet node; ambiguous placements require `--node`. - `fleet spawn --sandbox` keeps temporary routing credentials out of project files. diff --git a/packages/cli/README.md b/packages/cli/README.md index b1eddba55f..1e8c5e8439 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -241,10 +241,15 @@ absent, it creates and removes a short-lived launcher identity automatically. Automatic placement and release need only the workspace key. The sandbox path provisions a fresh hosted instance and makes the Relayfile -mount mandatory by default. The worker starts in the live `/workspace` mirror, -including when the command runs inside a Git checkout. Use `--checkout` when a -task needs a static Git checkout at the caller's exact pushed commit; that mode -keeps the live Relayfile mirror available separately. Use `--sandbox-provider daytona` or +mount mandatory by default. Inside a GitHub checkout, Relay infers the Git root, +repository identity, exact `HEAD`, and caller-relative directory. Cloud uses the +pinned workspace's connected GitHub credential to seed that revision into +Relayfile, and the worker starts in the decoded source tree under +`/workspace/github/repos///contents`. The long-running Relayfile +daemon keeps the mounted source tree synchronized with the workspace while +GitHub push events update the workspace's repository source. Use `--checkout` +when a task needs a separate static Git clone; that mode keeps the live +Relayfile mirror available separately. Use `--sandbox-provider daytona` or `--sandbox-provider e2b` to require an operator-enabled provider; omit the flag to let Cloud's sandbox router choose. Pass `--no-sandbox-relayfile` only when a deliberately bare sandbox is desired. If provisioning times out or the spawn @@ -258,15 +263,20 @@ provisioning ends with an unknown outcome, rerun the command with the warning's `--sandbox-id` to replay the same Cloud identity instead of adopting another fleet node. -With `--checkout`, sandbox provisioning attests the current GitHub checkout's -exact `HEAD` and clones it under +Both live and checkout modes require a clean working tree whose exact `HEAD` is +reachable from a configured GitHub remote. This prevents a remote worker from +silently starting at a different revision. Commit and push local work before +retrying when Relay reports dirty files or an unreachable commit. + +With `--checkout`, sandbox provisioning also clones the attested `HEAD` under `/srv/agent-workforce/`. The checkout must have no tracked changes or untracked source files, and the exact commit must be reachable from an origin remote. Relay's generated `.agentworkforce/relay/workspace-key.json`, `connection.json`, and `runtime.json` metadata are permitted. Dirty checkouts and -commits known to be ahead of their origin upstream fail locally. For detached -or otherwise unverified commits, Cloud must fetch and verify the exact SHA -before dispatch; an unreachable commit produces a push-and-retry error. The temporary isolated +commits known to be ahead of their origin upstream fail locally. Detached commits +must appear in an origin remote-tracking branch, and Cloud independently fetches +and verifies the exact SHA before dispatch. An unreachable commit produces a +push-and-retry error. The temporary isolated Relaycast credential stays in the machine store under `~/.agentworkforce/relay`; the project file stores only a non-secret reference. @@ -281,19 +291,26 @@ release commands reuse the persisted project route; if that remote session is unavailable, the command reports the routing failure instead of selecting a same-named local worker. -From a project already pinned to a Relay workspace, the ordinary live path is: +From a clean repository already pinned to a Relay workspace, the ordinary live +path is: ```bash agent-relay fleet spawn codex \ --name cloud-zero-config \ - --task "Inspect the current Relayfile workspace and report its available skills" \ + --task "Inspect this repository and report its current commit" \ --sandbox agent-relay node agent attach cloud-zero-config --mode drive agent-relay fleet agent list agent-relay fleet release cloud-zero-config ``` -For a static source task, opt in explicitly: +Invoking the live command from `packages/web` starts the worker at +`/workspace/github/repos///contents/packages/web`. The repository +source metadata is available beside `contents` under `.relayfile`, `.skills` +is mounted from the same workspace, and no `.git` directory is written into the +Relayfile mirror. + +For a static Git checkout, opt in explicitly: ```bash agent-relay fleet spawn codex \ @@ -304,15 +321,16 @@ agent-relay fleet spawn codex \ ``` In checkout mode, invoking spawn from `packages/web` places the worker in that -same relative directory in the remote clone. Private repositories use the pinned -workspace's connected GitHub access; a repository-access error means that -connection must be granted access to the repository. No GitHub token or -workspace key needs to be copied into the task or checkout. +same relative directory in the remote clone. In both modes, private repositories +use the pinned workspace's connected GitHub access; a repository-access error +means that connection must be granted access to the repository. No GitHub token +or workspace key needs to be copied into the task, mount, or checkout. -The Git checkout and Relayfile mirror are separate trees. Workspace `.skills` -are exposed through the agent CLIs' usual skill directories, and the worker's -task context identifies the mirror for other workspace records. Never move -`.git` into that mirror or clone a second repository there. +With `--checkout`, the Git checkout and Relayfile mirror are separate trees. In +the ordinary live mode, source files are decoded from Relayfile records without +placing `.git` in the mirror. Workspace `.skills` are exposed through the agent +CLIs' usual skill directories, and the worker's task context identifies the +mirror and exact source revision. Detaching leaves the worker running. Only one drive session can own a worker at a time; detach the current driver before driving it in another shell, or @@ -328,18 +346,20 @@ If the workspace is not pinned yet, use `agent-relay workspace rebind ` with an existing stored workspace. A missing or mismatched stored route credential requires rerunning sandbox provisioning for that workspace. `--base-url`, `--workspace-id`, `--node`, provider selection, `--cwd`, and the -static `--checkout` mode remain advanced overrides. Plain `--sandbox` uses the -mount-based live workspace inside and outside Git. +static `--checkout` mode remain advanced overrides. Outside Git, plain +`--sandbox` preserves the existing full-workspace Relayfile mount at +`/workspace`. Pins created before workspace IDs were recorded are resolved automatically through Cloud at spawn time. The key travels in an authenticated POST body, never a URL. Nested packages share the repository pin; an existing subproject pin or `AGENT_RELAY_PROJECT` remains an explicit workspace override. -Large workspaces should select only the live subtree an agent needs. Pass one -or more explicit directory roots after `--sandbox-relayfile-path`; Cloud -validates the `/path/**` form and materializes those roots before the agent -starts: +Large workspaces can add only the other live subtrees an agent needs. Pass one +or more explicit directory roots after `--sandbox-relayfile-path`; the inferred +repository, its source metadata, and `.skills` remain mounted automatically. +Cloud validates the `/path/**` form and materializes those roots before the +agent starts: ```bash agent-relay fleet spawn claude \ diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 8c45618653..4c99675314 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -936,9 +936,7 @@ describe('fleet command support', () => { relayfileMountPath: '/workspace', })); const deleteCloudFleetSandbox = vi.fn(async () => undefined); - const resolveSandboxRepository = vi.fn(() => { - throw new Error('default live-mount mode must not inspect Git'); - }); + const resolveSandboxRepository = vi.fn(() => undefined); const logs: string[] = []; const program = new Command(); program.exitOverride(); @@ -1004,7 +1002,7 @@ describe('fleet command support', () => { waitTimeoutMs: 90_000, name: REPLAY_SANDBOX_NAME, }); - expect(resolveSandboxRepository).not.toHaveBeenCalled(); + expect(resolveSandboxRepository).toHaveBeenCalled(); expect(ensureInput?.name).toBe(`fleet-sandbox-${ensureInput?.sandboxId?.slice('sbx_'.length)}`); expect(register).toHaveBeenCalledWith( expect.objectContaining({ @@ -1055,6 +1053,142 @@ describe('fleet command support', () => { }); }); + it('plain --sandbox materializes the inferred repository through Relayfile and starts in its live relative cwd', async () => { + vi.stubEnv('RELAY_AGENT_TOKEN', undefined); + const revision = '0123456789abcdef0123456789abcdef01234567'; + const repositorySelection = { + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: '/local/cloud', + repositoryRelativeCwd: 'packages/web', + workerCwd: '/srv/agent-workforce/cloud/packages/web', + }; + const events: string[] = []; + const materializeCloudRelayfileRepository = vi.fn(async () => { + events.push('materialize'); + return { + cloudWorkspaceId: 'cloud-workspace', + repository: 'AgentWorkforce/cloud', + revision, + filesWritten: 4312, + contentRoot: '/github/repos/AgentWorkforce/cloud/contents', + sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', + }; + }); + const ensureCloudFleetSandbox = vi.fn(async () => { + events.push('ensure'); + return { + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-live', + nodeName: 'live-node', + sandboxId: 'sandbox-live', + providerSandboxId: 'provider-live', + relayWorkspaceId: 'rw_abc', + relaycastTarget: AGENT37_RELAYCAST_TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + }; + }); + const placement = { + spawn: vi.fn(async () => { + events.push('spawn'); + return { invocationId: 'inv_live', node: { name: 'live-node' } }; + }), + }; + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { + info: vi.fn(async () => ({ id: 'rw_abc' })), + register: vi.fn(async () => ({ token: 'at_live_launcher' })), + release: vi.fn(async () => ({ released: true, deleted: true })), + }, + })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository: vi.fn(() => repositorySelection), + materializeCloudRelayfileRepository, + ensureCloudFleetSandbox, + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn() as never, + }, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_test', + source: 'project', + origin: '/local/cloud/.agentworkforce/relay/workspace-key.json', + workspaceId: 'rw_abc', + }), + persistWorkspaceRelaycastTarget: () => true, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--sandbox-relayfile-path', + '/memory/**', + '--name', + 'cloud-live', + '--task', + 'Inspect this repository', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + + expect(events).toEqual(['materialize', 'ensure', 'spawn']); + expect(materializeCloudRelayfileRepository).toHaveBeenCalledWith({ + workspaceId: 'rw_abc', + repository: 'AgentWorkforce/cloud', + revision, + }); + expect(ensureCloudFleetSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + mountRelayfile: true, + relayfilePaths: [ + '/github/repos/AgentWorkforce/cloud/contents/**', + '/github/repos/AgentWorkforce/cloud/.relayfile/**', + '/.skills/**', + '/memory/**', + ], + }) + ); + const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0] as Record; + expect(ensureInput.repos).toBeUndefined(); + expect(ensureInput.repoRevisions).toBeUndefined(); + expect(placement.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + task: expect.stringContaining( + 'Inspect this repository\n\nAgent Relay sandbox context: AgentWorkforce/cloud is mounted as a live Relayfile working tree' + ), + worker_cwd: '/workspace/github/repos/AgentWorkforce/cloud/contents/packages/web', + }), + }) + ); + const spawnInput = placement.spawn.mock.calls[0]?.[0]?.input as { task?: string }; + expect(spawnInput.task).toContain(revision); + expect(spawnInput.task).toContain('/workspace/github/repos/AgentWorkforce/cloud/.relayfile/clone.json'); + expect(spawnInput.task).not.toContain('/local/cloud'); + }); + it('--checkout infers the Git root and forwards only the public revision attestation', async () => { const revision = '0123456789abcdef0123456789abcdef01234567'; const repositorySelection = { @@ -1062,6 +1196,7 @@ describe('fleet command support', () => { repositoryName: 'cloud', revision, projectRoot: '/local/cloud', + repositoryRelativeCwd: 'packages/web', workerCwd: '/srv/agent-workforce/cloud/packages/web', }; const resolveSandboxRepository = vi.fn(() => repositorySelection); @@ -1181,6 +1316,7 @@ describe('fleet command support', () => { repositoryName: 'legacyprovider', revision, projectRoot: '/actual/legacyprovider', + repositoryRelativeCwd: 'packages/web', workerCwd: '/srv/agent-workforce/legacyprovider/packages/web', })); const placement = { @@ -1394,6 +1530,7 @@ describe('fleet command support', () => { repositoryName: 'cloud', revision, projectRoot: '/local/cloud', + repositoryRelativeCwd: 'packages/web', workerCwd: '/srv/agent-workforce/cloud/packages/web', })); const deleteCloudFleetSandbox = vi.fn(async () => undefined); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index cdf3d3b583..5c8b3761c8 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -7,8 +7,10 @@ import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, resolveWorkspaceByKey, type CloudFleetSandboxProviderId, + type CloudRelayfileRepositoryMaterialization, type EnsureCloudFleetSandboxResult, } from '@agent-relay/cloud'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; @@ -97,6 +99,48 @@ function pathContains(parent: string, child: string): boolean { ); } +function liveRelayfileMountPaths( + materialization: CloudRelayfileRepositoryMaterialization, + requested: readonly string[] | undefined +): string[] { + const contentRoot = materialization.contentRoot; + const sentinelRoot = path.posix.dirname(materialization.sentinelPath); + const requestedPaths = requested ?? []; + if (requestedPaths.length > 13) { + throw new Error( + '--sandbox-relayfile-path accepts at most 13 paths when a live repository, its source metadata, and workspace skills are mounted.' + ); + } + const contentAncestor = requestedPaths.find((candidate) => { + const root = candidate + .trim() + .replace(/\/\*\*$/, '') + .replace(/\/$/, ''); + return contentRoot === root || contentRoot.startsWith(`${root}/`); + }); + if (contentAncestor && contentAncestor.trim() !== `${contentRoot}/**`) { + throw new Error( + `Relayfile path ${JSON.stringify(contentAncestor)} contains the repository source root; omit it so Relay can mount ${contentRoot}/** as a decoded working tree.` + ); + } + return [...new Set([`${contentRoot}/**`, `${sentinelRoot}/**`, '/.skills/**', ...requestedPaths])]; +} + +function liveRelayfileWorkerCwd( + mountRoot: string, + materialization: CloudRelayfileRepositoryMaterialization, + relativeCwd: string +): string { + const root = mountRoot.replace(/\/+$/, '') || '/'; + const sourceRoot = `${root === '/' ? '' : root}${materialization.contentRoot}`; + return relativeCwd ? `${sourceRoot}/${relativeCwd}` : sourceRoot; +} + +function mountedRelayfilePath(mountRoot: string, remotePath: string): string { + const root = mountRoot.replace(/\/+$/, '') || '/'; + return `${root === '/' ? '' : root}${remotePath}`; +} + // The targeted spawn path (relay.messaging.placement.spawn) returns an // invocation whose `placement` is the SDK's own evidence object // (`state: 'accepted' | 'ready'`, `confirmed`). `spawnPlacementReceipt` @@ -151,6 +195,7 @@ export interface FleetCommandDependencies { resolveWorkspaceByKey: typeof resolveWorkspaceByKey; persistWorkspaceRelaycastTarget: typeof persistWorkspaceRelaycastTarget; ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; + materializeCloudRelayfileRepository: typeof materializeCloudRelayfileRepository; deleteCloudFleetSandbox: typeof deleteCloudFleetSandbox; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; @@ -174,6 +219,7 @@ function withFleetDefaults(overrides: Partial = {}): F resolveWorkspaceByKey, persistWorkspaceRelaycastTarget, ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, deleteCloudFleetSandbox, log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), @@ -261,7 +307,7 @@ export function registerFleetCommands( .option('--target-node ', 'Alias for --node') .option( '--sandbox', - 'Provision a fresh Cloud sandbox node, mount this Relayfile workspace, and spawn there' + 'Provision a fresh Cloud sandbox and spawn with a live Relayfile workspace/repository mount' ) .option( '--checkout', @@ -380,6 +426,7 @@ export function registerFleetCommands( let sandbox: EnsureCloudFleetSandboxResult | undefined; let sandboxRepository: SandboxRepositorySelection | undefined; + let liveRepository: CloudRelayfileRepositoryMaterialization | undefined; let attachProjectRoot: string | undefined; let workspaceRelay: ReturnType | undefined; let relaycastClientOptions = clientOptions; @@ -389,19 +436,30 @@ export function registerFleetCommands( const hasExplicitProjectOverride = Boolean( deps.core.env?.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim() ); - if (checkoutRepository) { + if (checkoutRepository || mountSandboxRelayfile) { // AGENT_RELAY_PROJECT selects the workspace namespace, while static - // checkout inference remains anchored to the actual Git tree. This - // also lets --cwd point at a sibling checkout when explicitly asked. + // checkout and live Relayfile source inference remain anchored to + // the actual Git tree. This also lets --cwd point at a sibling + // checkout when explicitly asked. const repositoryRootHint = hasExplicitProjectOverride ? process.cwd() : coreProjectRoot; sandboxRepository = deps.resolveSandboxRepository(repositoryRootHint, requestedCwd); - if (!sandboxRepository) { + if (checkoutRepository && !sandboxRepository) { throw new Error('--checkout requires a GitHub checkout with a clean, pushed commit.'); } - workerCwd = sandboxRepository.workerCwd; + if (checkoutRepository && sandboxRepository) { + workerCwd = sandboxRepository.workerCwd; + } else if (sandboxRepository) { + // Relative/local --cwd values were consumed by repository + // inference and must be remapped beneath the remote live source + // root after Cloud returns its provider-specific mount path. + workerCwd = + requestedCwd && /^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) + ? requestedCwd + : undefined; + } } const localRequestedCwd = - checkoutRepository && + sandboxRepository && requestedCwd && !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) ? path.resolve(process.cwd(), requestedCwd) @@ -470,6 +528,13 @@ export function registerFleetCommands( : 'The current Relay workspace did not report an ID for Cloud provisioning.' ); } + if (!checkoutRepository && mountSandboxRelayfile && sandboxRepository) { + liveRepository = await deps.materializeCloudRelayfileRepository({ + workspaceId: relayWorkspaceId, + repository: sandboxRepository.repository, + revision: sandboxRepository.revision, + }); + } const sandboxId = sandboxIdOption ?? (sandboxName === undefined ? `sbx_${randomUUID()}` : undefined); const deterministicSandboxName = sandboxId === undefined ? undefined : `fleet-sandbox-${sandboxId.slice('sbx_'.length)}`; @@ -505,19 +570,23 @@ export function registerFleetCommands( requiredCapability: `spawn:${cli}`, maxAgents: 1, mountRelayfile: mountSandboxRelayfile, - ...(sandboxRelayfilePaths === undefined ? {} : { relayfilePaths: sandboxRelayfilePaths }), + ...(liveRepository + ? { relayfilePaths: liveRelayfileMountPaths(liveRepository, sandboxRelayfilePaths) } + : sandboxRelayfilePaths === undefined + ? {} + : { relayfilePaths: sandboxRelayfilePaths }), ...(sandboxId === undefined ? {} : { sandboxId }), forceProvision: true, ...(sandboxProvider === undefined ? {} : { providerId: sandboxProvider }), workloadProfile, waitTimeoutMs: 90_000, ...(effectiveSandboxName === undefined ? {} : { name: effectiveSandboxName }), - ...(sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), - ...(sandboxRepository + ...(checkoutRepository && sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), + ...(checkoutRepository && sandboxRepository ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } } : {}), }); - assertSandboxRepositoryRevision(sandbox, sandboxRepository); + assertSandboxRepositoryRevision(sandbox, checkoutRepository ? sandboxRepository : undefined); } catch (error) { if ( shouldCleanupSandbox && @@ -695,6 +764,19 @@ export function registerFleetCommands( throw new Error('Cloud returned a sandbox node without the required Relayfile mount.'); } targetNode = sandbox.nodeName; + if ( + liveRepository && + sandboxRepository && + !workerCwd && + sandbox.outcome === 'provisioned' && + sandbox.relayfileMounted + ) { + workerCwd = liveRelayfileWorkerCwd( + sandbox.relayfileMountPath ?? '/workspace', + liveRepository, + sandboxRepository.repositoryRelativeCwd + ); + } if (!workerCwd && sandbox.outcome === 'provisioned' && sandbox.relayfileMounted) { workerCwd = sandbox.relayfileMountPath ?? '/workspace'; } @@ -745,6 +827,20 @@ export function registerFleetCommands( // the invocation and launches nothing, which is indistinguishable from // success here — so wait for the node to confirm unless asked not to. const confirm = options.confirm !== false; + const liveSandboxContext = + sandbox?.outcome === 'provisioned' && liveRepository && sandboxRepository + ? `Agent Relay sandbox context: ${liveRepository.repository} is mounted as a live Relayfile working tree at ${liveRelayfileWorkerCwd( + sandbox.relayfileMountPath ?? '/workspace', + liveRepository, + '' + )}. Its exact source revision is ${liveRepository.revision}; the same attestation is recorded at ${mountedRelayfilePath( + sandbox.relayfileMountPath ?? '/workspace', + liveRepository.sentinelPath + )}. Workspace skills are under ${mountedRelayfilePath( + sandbox.relayfileMountPath ?? '/workspace', + '/.skills' + )}. The Relayfile daemon synchronizes this tree; it intentionally has no .git directory.` + : undefined; const invocation = await relay.messaging.placement.spawn({ capability: `spawn:${cli}`, node: targetNode, @@ -755,9 +851,15 @@ export function registerFleetCommands( name, cli, task: - sandbox && sandboxRepository && mountSandboxRelayfile && sandbox.outcome === 'provisioned' + sandbox && + checkoutRepository && + sandboxRepository && + mountSandboxRelayfile && + sandbox.outcome === 'provisioned' ? `${task}\n\nAgent Relay sandbox context: Relayfile records are available at ${sandbox.relayfileMountPath ?? '/workspace'}. The source checkout is separate; use ${workerCwd ?? 'the worker checkout'} for repository files and the mount for Relayfile records.` - : task, + : liveSandboxContext + ? `${task}\n\n${liveSandboxContext}` + : task, ...(channel ? { channels: [channel] } : {}), ...(model ? { model } : {}), ...(workerCwd ? { worker_cwd: workerCwd } : {}), diff --git a/packages/cli/src/cli/lib/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts index 5abe48d9dc..7379a330a8 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.test.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -10,6 +10,7 @@ function gitMock(): ReturnType { return vi.fn((_command: string, args: readonly string[]) => { if (args.includes('--show-toplevel')) return '/checkout\n'; if (args.includes('get-url')) return 'git@github.com:AgentWorkforce/relay.git\n'; + if (args.includes('--remotes')) return 'origin/main\n'; if (args.includes('HEAD')) return '0123456789abcdef0123456789abcdef01234567\n'; if (args.includes('status')) return ''; if (args.includes('@{u}')) return 'origin/main\n'; @@ -28,6 +29,7 @@ describe('resolveSandboxRepository', () => { repositoryName: 'relay', revision: '0123456789abcdef0123456789abcdef01234567', projectRoot: '/checkout', + repositoryRelativeCwd: 'packages/cli', workerCwd: '/srv/agent-workforce/relay/packages/cli', }); }); @@ -126,7 +128,7 @@ describe('resolveSandboxRepository', () => { } }); - it('accepts SSH URL origins and does not require an upstream for detached HEAD', () => { + it('accepts SSH URL origins and verifies a detached HEAD against an origin-tracking branch', () => { const run = gitMock(); run.mockImplementation((command: string, args: readonly string[]) => { if (args.includes('get-url')) return 'ssh://git@github.com/AgentWorkforce/relay.git\n'; @@ -139,6 +141,21 @@ describe('resolveSandboxRepository', () => { ).toBe('AgentWorkforce/relay'); }); + it('rejects a detached HEAD that is not present in an origin-tracking branch', () => { + const run = gitMock(); + run.mockImplementation((command: string, args: readonly string[]) => { + if (args.includes('@{u}')) throw new Error('detached'); + if (args.includes('--remotes')) return ''; + return gitMock()(command, args); + }); + expect(() => + resolveSandboxRepository('/checkout', undefined, { + cwd: () => '/checkout', + execFileSync: run as never, + }) + ).toThrow(/not present in an origin remote-tracking branch/); + }); + it('honors a selected Relay project when the shell is outside Git', () => { const run = gitMock(); run.mockImplementation((command: string, args: readonly string[]) => { @@ -271,6 +288,7 @@ describe('resolveSandboxRepository', () => { repositoryName: 'cloud', revision, projectRoot: checkout, + repositoryRelativeCwd: 'packages/web', workerCwd: '/srv/agent-workforce/cloud/packages/web', }); mkdirSync(path.join(checkout, '.agentworkforce', 'relay'), { recursive: true }); diff --git a/packages/cli/src/cli/lib/sandbox-repo.ts b/packages/cli/src/cli/lib/sandbox-repo.ts index 9999ef75d5..1cecaec927 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.ts @@ -10,6 +10,8 @@ export interface SandboxRepositorySelection { repositoryName: string; revision: string; projectRoot: string; + /** Caller location beneath projectRoot, using portable `/` separators. */ + repositoryRelativeCwd: string; workerCwd: string; } @@ -165,6 +167,28 @@ export function resolveSandboxRepository( `Sandbox commit ${revision} is not pushed to ${upstream}. Push the exact commit before retrying.` ); } + } else { + let containingRemoteBranches: string; + try { + containingRemoteBranches = run( + 'git', + ['-C', root, 'branch', '--remotes', '--contains', 'HEAD', '--format=%(refname:short)'], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + } + ); + } catch { + throw new Error( + `Cannot verify that sandbox commit ${revision} is pushed to origin. Fetch or push the exact commit before retrying.` + ); + } + if (!containingRemoteBranches.split(/\r?\n/).some((branch) => branch.trim().startsWith('origin/'))) { + throw new Error( + `Sandbox commit ${revision} is not present in an origin remote-tracking branch. Push the exact commit before retrying.` + ); + } } const relative = path.relative(canonicalRoot, localCwd); @@ -182,6 +206,7 @@ export function resolveSandboxRepository( repositoryName, revision, projectRoot: canonicalRoot, + repositoryRelativeCwd: relativePosix, workerCwd: remoteCwd ?? (relativePosix ? `${remoteRoot}/${relativePosix}` : remoteRoot), }; } diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index fa4560e196..6d49c2cdae 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -16,6 +16,7 @@ import { CloudFleetSandboxProvisionError, deleteCloudFleetSandbox, ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, normalizeRelaycastTarget, } from './fleet-sandbox.js'; @@ -99,6 +100,117 @@ describe('Cloud fleet sandbox client', () => { ).toThrow(/API key/); }); + it('waits for an exact Relayfile repository working tree without receiving GitHub credentials', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + mocks.authorizedApiFetch + .mockResolvedValueOnce({ + response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), + auth: refreshedAuth, + }) + .mockResolvedValueOnce({ + response: Response.json({ ok: true, jobId: 'clone-job-1', status: 'queued' }, { status: 202 }), + auth: refreshedAuth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'running', + }, + }), + auth: refreshedAuth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'completed', + headSha: revision, + filesWritten: 4312, + materialization: { + mode: 'relayfile_export', + headSha: revision, + filesExpected: 4312, + contentRoot: '/github/repos/AgentWorkforce/cloud/contents', + sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', + exportParams: { format: 'tar', decode: 'github-working-tree', gzip: false }, + }, + }, + }), + auth: refreshedAuth, + }); + + await expect( + materializeCloudRelayfileRepository( + { + workspaceId: 'rw_abc', + repository: 'AgentWorkforce/cloud', + revision, + }, + { pollIntervalMs: 0 } + ) + ).resolves.toEqual({ + cloudWorkspaceId: CLOUD_WORKSPACE_ID, + repository: 'AgentWorkforce/cloud', + revision, + filesWritten: 4312, + contentRoot: '/github/repos/AgentWorkforce/cloud/contents', + sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', + }); + + const requestCall = mocks.authorizedApiFetch.mock.calls[1]; + expect(requestCall?.[1]).toBe('/api/v1/github/clone/request'); + expect(JSON.parse(String(requestCall?.[2]?.body))).toEqual({ + workspaceId: CLOUD_WORKSPACE_ID, + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + mode: 'full', + }); + expect(JSON.stringify(requestCall)).not.toContain('githubToken'); + }); + + it('rejects a completed clone that did not produce the requested live Relayfile revision', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + mocks.authorizedApiFetch + .mockResolvedValueOnce({ + response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json({ ok: true, jobId: 'clone-job-2', status: 'queued' }, { status: 202 }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'completed', + headSha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + filesWritten: 12, + materialization: { mode: 'local_archive' }, + }, + }), + auth, + }); + + await expect( + materializeCloudRelayfileRepository( + { workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', revision }, + { pollIntervalMs: 0 } + ) + ).rejects.toThrow(/did not prove a live Relayfile working tree/); + }); + it('rejects a provisioned response with an untrusted server-owned Relaycast route', async () => { mocks.authorizedApiFetch .mockResolvedValueOnce({ diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index a79f39a71c..e253c8959c 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -25,6 +25,8 @@ const DEFAULT_RESOLUTION_TIMEOUT_MS = 120_000; // identity (which prevents the CLI from cleaning it up safely). const DEFAULT_ENSURE_TIMEOUT_MS = 480_000; const DEFAULT_DELETE_TIMEOUT_MS = 30_000; +const DEFAULT_RELAYFILE_REPOSITORY_MATERIALIZE_TIMEOUT_MS = 20 * 60_000; +const DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS = 2_000; export type CloudFleetSandboxRequestOptions = { apiUrl?: string; @@ -32,6 +34,28 @@ export type CloudFleetSandboxRequestOptions = { timeoutMs?: number; }; +export type MaterializeCloudRelayfileRepositoryInput = { + /** Cloud UUID or unified rw_* workspace id. */ + workspaceId: string; + /** Canonical GitHub owner/name identity. */ + repository: string; + /** Exact reachable commit to seed into Relayfile. */ + revision: string; +}; + +export type CloudRelayfileRepositoryMaterialization = { + cloudWorkspaceId: string; + repository: string; + revision: string; + filesWritten: number; + contentRoot: string; + sentinelPath: string; +}; + +export type CloudRelayfileRepositoryMaterializeOptions = CloudFleetSandboxRequestOptions & { + pollIntervalMs?: number; +}; + export type CloudFleetSandboxProviderId = | 'daytona' | 'e2b' @@ -408,6 +432,44 @@ function validateRepoRevisions( return Object.fromEntries(entries); } +function parseRepositoryIdentity(repository: string): { owner: string; repo: string } { + const normalized = repository.trim(); + if (!REPOSITORY_KEY_PATTERN.test(normalized) || normalized.includes('..')) { + throw new Error('Cloud Relayfile repository must use GitHub owner/name form.'); + } + const [owner, repo] = normalized.split('/'); + return { owner: owner!, repo: repo! }; +} + +function expectedRelayfileRepositoryPaths( + owner: string, + repo: string +): { + contentRoot: string; + sentinelPath: string; +} { + const root = `/github/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + return { + contentRoot: `${root}/contents`, + sentinelPath: `${root}/.relayfile/clone.json`, + }; +} + +function waitForDelay(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + function readRepoRevisions(payload: JsonRecord): Record | undefined { const value = payload.repoRevisions; if (value === undefined) return undefined; @@ -681,6 +743,135 @@ function normalizeEnsureResult( throw new Error('Cloud fleet sandbox response has an unknown outcome.'); } +/** + * Materialize one exact GitHub revision into the selected workspace's live + * Relayfile tree and wait until the decoded working-tree mount can consume it. + * + * Cloud owns GitHub credential selection. The CLI sends only owner/name and + * the exact pushed SHA; provider credentials never cross this boundary. + */ +export async function materializeCloudRelayfileRepository( + input: MaterializeCloudRelayfileRepositoryInput, + options: CloudRelayfileRepositoryMaterializeOptions = {} +): Promise { + const workspaceId = input.workspaceId.trim(); + if (!workspaceId) throw new Error('A workspace ID is required to materialize a Relayfile repository.'); + const { owner, repo } = parseRepositoryIdentity(input.repository); + const revision = input.revision.trim().toLowerCase(); + if (!REPOSITORY_REVISION_PATTERN.test(revision)) { + throw new Error('Cloud Relayfile repository revision must be exactly 40 hexadecimal characters.'); + } + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS; + if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) { + throw new Error( + 'Cloud Relayfile repository poll interval must be a non-negative number of milliseconds.' + ); + } + + const session = await ensureCloudSession({ + apiUrl: options.apiUrl || defaultApiUrl(), + interactive: false, + }); + const resolutionSignal = boundedSignal(options, DEFAULT_RESOLUTION_TIMEOUT_MS); + const resolved = await resolveCloudWorkspaceId(workspaceId, session.auth, resolutionSignal); + const signal = boundedSignal(options, DEFAULT_RELAYFILE_REPOSITORY_MATERIALIZE_TIMEOUT_MS); + let activeAuth = resolved.auth; + + const requestResult = await authorizedApiFetch( + activeAuth, + '/api/v1/github/clone/request', + { + method: 'POST', + signal, + body: JSON.stringify({ + workspaceId: resolved.cloudWorkspaceId, + owner, + repo, + ref: revision, + mode: 'full', + }), + }, + { interactive: false } + ); + activeAuth = requestResult.auth; + const requestPayload = await readJson(requestResult.response); + if (!requestResult.response.ok) { + throw endpointError('materialize the repository into Relayfile', requestResult.response, requestPayload); + } + if (!isObject(requestPayload)) { + throw new Error('Cloud Relayfile repository materializer returned an invalid response.'); + } + const jobId = requiredString(requestPayload, 'jobId', 'Cloud Relayfile repository materializer'); + const expectedPaths = expectedRelayfileRepositoryPaths(owner, repo); + + for (;;) { + const statusResult = await authorizedApiFetch( + activeAuth, + `/api/v1/github/clone/status/${encodeURIComponent(jobId)}`, + { method: 'GET', signal }, + { interactive: false } + ); + activeAuth = statusResult.auth; + const statusPayload = await readJson(statusResult.response); + if (!statusResult.response.ok) { + throw endpointError( + 'read Relayfile repository materialization status', + statusResult.response, + statusPayload + ); + } + if (!isObject(statusPayload) || !isObject(statusPayload.job)) { + throw new Error('Cloud Relayfile repository materialization status was invalid.'); + } + const job = statusPayload.job; + const status = readString(job, 'status'); + if (status === 'failed') { + const detail = readString(job, 'lastError'); + throw new Error( + redactCredentialValues( + `Cloud could not materialize ${owner}/${repo} into Relayfile${detail ? `: ${detail}` : '.'}` + ) + ); + } + if (status === 'completed') { + const jobOwner = readString(job, 'owner'); + const jobRepo = readString(job, 'repo'); + const jobRef = readString(job, 'ref'); + const headSha = readString(job, 'headSha')?.toLowerCase(); + const filesWritten = readNumber(job, 'filesWritten'); + const materialization = job.materialization; + if ( + jobOwner !== owner || + jobRepo !== repo || + jobRef?.toLowerCase() !== revision || + headSha !== revision || + filesWritten === undefined || + filesWritten <= 0 || + !isObject(materialization) || + readString(materialization, 'mode') !== 'relayfile_export' || + readString(materialization, 'headSha')?.toLowerCase() !== revision || + readString(materialization, 'contentRoot') !== expectedPaths.contentRoot || + readString(materialization, 'sentinelPath') !== expectedPaths.sentinelPath + ) { + throw new Error( + `Cloud did not prove a live Relayfile working tree for ${owner}/${repo} at ${revision}.` + ); + } + return { + cloudWorkspaceId: resolved.cloudWorkspaceId, + repository: `${owner}/${repo}`, + revision, + filesWritten, + ...expectedPaths, + }; + } + if (status !== 'queued' && status !== 'running' && status !== 'retrying') { + throw new Error('Cloud Relayfile repository materialization reported an unknown status.'); + } + await waitForDelay(pollIntervalMs, signal); + } +} + /** Resolve a Relay workspace in Cloud, provision/reuse a node, and wait for readiness. */ export async function ensureCloudFleetSandbox( input: EnsureCloudFleetSandboxInput, diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 7620de1525..6784283885 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -84,6 +84,7 @@ export { export { redactCredentialValues } from './redact.js'; export { ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, deleteCloudFleetSandbox, CloudFleetSandboxProvisionError, normalizeRelaycastTarget, @@ -91,6 +92,9 @@ export { AGENT37_RELAYCAST_ORIGIN, type EnsureCloudFleetSandboxInput, type EnsureCloudFleetSandboxResult, + type MaterializeCloudRelayfileRepositoryInput, + type CloudRelayfileRepositoryMaterialization, + type CloudRelayfileRepositoryMaterializeOptions, type CloudFleetSandboxReady, type CloudFleetSandboxReused, type CloudFleetSandboxProvisioningTimeout, diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/case.json b/tests/relayflows/cases/1763-zero-config-sandbox/case.json index 9e2d8774dd..c5e07ca0a6 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/case.json +++ b/tests/relayflows/cases/1763-zero-config-sandbox/case.json @@ -2,7 +2,7 @@ "version": 1, "id": "1763-zero-config-sandbox", "kind": "feature", - "title": "Opt into exact static repository checkout for sandbox provisioning", + "title": "Materialize a live exact repository tree through Relayfile by default", "runner": { "command": ["node", "tests/relayflows/cases/1763-zero-config-sandbox/run.mjs"] }, @@ -11,11 +11,11 @@ "expected": { "base": { "outcome": "absent", - "signature": "sandbox_repository_revision_contract_absent" + "signature": "live_relayfile_repository_contract_absent" }, "head": { "outcome": "fixed", - "signature": "sandbox_repository_revision_contract_forwarded" + "signature": "live_relayfile_repository_contract_forwarded" } } } diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index c6b556d494..505b511b3d 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -38,7 +38,7 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => ({ ...(await im import { registerFleetCommands } from '../../cli/src/cli/commands/fleet.js'; const revision = '${revision}'; const auth = { accessToken: 'probe', refreshToken: 'probe', accessTokenExpiresAt: '2099-01-01T00:00:00Z', apiUrl: 'https://relayflow.invalid' }; -test('fleet sandbox CLI forwards exact repo revision and uses returned provider identity for cleanup', async () => { +test('fleet sandbox CLI materializes exact source through a scoped live Relayfile mount', async () => { const output = process.env.RELAY_PR1763_OBSERVATION_PATH; if (!output) throw new Error('Missing observation path.'); const requests = []; @@ -49,26 +49,27 @@ test('fleet sandbox CLI forwards exact repo revision and uses returned provider if (requests.length === 1) return { response: Response.json({ cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99' }), auth }; return { response: Response.json({ outcome: 'provisioned', cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', nodeId: 'node-proof', nodeName: body?.name ?? 'sandbox-proof', sandboxId: body?.sandboxId ?? 'sbx_123e4567-e89b-42d3-a456-426614174000', relayWorkspaceId: 'rw-proof', relayfileMounted: true, providerId: 'agent37', relaycastTarget: { route: 'agent37-isolated', baseUrl: 'https://agent37-cast.agentrelay.com', workspaceId: 'rw-proof', relaycastApiKey: 'rk_live_probe' }, repoRevisions: body?.repoRevisions ?? undefined }, { status: 201 }), auth }; }); - const logs = [], warnings = [], deletes = [], releases = []; + const logs = [], warnings = [], deletes = [], releases = [], materializations = [], spawnInputs = []; const program = new Command(); program.exitOverride(); registerFleetCommands(program, { core: { getProjectPaths: () => ({ projectRoot: process.cwd() }), env: {} }, resolveWorkspaceSelection: () => ({ workspaceId: 'rw-proof', key: 'probe-key', source: 'project' }), sdk: { - createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn(async () => { throw new Error('synthetic dispatch failure'); }) } } })), + createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: vi.fn(async (input) => { spawnInputs.push(input); throw new Error('synthetic dispatch failure'); }) } } })), createWorkspaceRelay: vi.fn(() => ({ workspace: { info: vi.fn(async () => ({ id: 'rw-proof' })), register: vi.fn(async () => ({ token: 'launcher' })), release: vi.fn(async (input) => { releases.push(input); return { deleted: true }; }) } })), createWorkspace: vi.fn(), log: (value) => logs.push(String(value)), error: vi.fn(), exit: vi.fn((code) => { throw new Error('CLI exit ' + code); }), }, + resolveSandboxRepository: vi.fn(() => ({ repository: 'AgentWorkforce/relay', repositoryName: 'relay', revision, projectRoot: process.cwd(), repositoryRelativeCwd: 'packages/cli', workerCwd: '/srv/agent-workforce/relay/packages/cli' })), + materializeCloudRelayfileRepository: vi.fn(async (input) => { materializations.push(input); return { cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', repository: input.repository, revision: input.revision, filesWritten: 1234, contentRoot: '/github/repos/AgentWorkforce/relay/contents', sentinelPath: '/github/repos/AgentWorkforce/relay/.relayfile/clone.json' }; }), deleteCloudFleetSandbox: vi.fn(async (input) => { deletes.push(input); }), persistWorkspaceRelaycastTarget: () => true, log: () => undefined, warn: (...args) => warnings.push(args.join(' ')), error: () => undefined, }); - const checkoutArgs = ${JSON.stringify(arm)} === 'head' ? ['--checkout'] : []; - await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', ...checkoutArgs, '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); + await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', '--no-confirm'], { from: 'user' })).rejects.toThrow('CLI exit 1'); const body = requests[1]?.body ?? {}; - await writeFile(output, JSON.stringify({ requestCount: requests.length, requestRepos: body.repos ?? null, requestRepoRevisions: body.repoRevisions ?? null, resultRepoRevisions: body.repoRevisions ?? null, workloadProfile: body.workloadProfile ?? null, cleanupProviderIds: deletes.map((x) => x.providerId ?? null), launcherReleases: releases.length, warnings }, null, 2)); - if (${JSON.stringify(arm)} === 'head') { expect(body.repos).toEqual(['AgentWorkforce/relay']); expect(body.repoRevisions).toEqual({ 'AgentWorkforce/relay': revision }); expect(body.workloadProfile).toBe('long-running-agent'); expect(releases).toHaveLength(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } - else { expect(body.repoRevisions ?? null).toBe(null); expect(body.workloadProfile).toBe('long-running-agent'); } + await writeFile(output, JSON.stringify({ requestCount: requests.length, materializations, requestRepos: body.repos ?? null, requestRepoRevisions: body.repoRevisions ?? null, relayfilePaths: body.relayfilePaths ?? null, workloadProfile: body.workloadProfile ?? null, workerCwd: spawnInputs[0]?.input?.worker_cwd ?? null, task: spawnInputs[0]?.input?.task ?? null, cleanupProviderIds: deletes.map((x) => x.providerId ?? null), launcherReleases: releases.length, warnings }, null, 2)); + if (${JSON.stringify(arm)} === 'head') { expect(materializations).toEqual([{ workspaceId: 'rw-proof', repository: 'AgentWorkforce/relay', revision }]); expect(body.repos ?? null).toBe(null); expect(body.repoRevisions ?? null).toBe(null); expect(body.relayfilePaths).toEqual(['/github/repos/AgentWorkforce/relay/contents/**', '/github/repos/AgentWorkforce/relay/.relayfile/**', '/.skills/**']); expect(spawnInputs[0]?.input?.worker_cwd).toBe('/workspace/github/repos/AgentWorkforce/relay/contents/packages/cli'); expect(spawnInputs[0]?.input?.task).toContain(revision); expect(body.workloadProfile).toBe('long-running-agent'); expect(releases).toHaveLength(1); expect(deletes).toHaveLength(1); expect(deletes[0].providerId).toBe('agent37'); } + else { expect(materializations).toHaveLength(0); expect(body.workloadProfile).toBe('long-running-agent'); } }); `; try { @@ -100,15 +101,25 @@ try { ); const observation = JSON.parse(await readFile(observationPath, 'utf8')); const forwarded = - JSON.stringify(observation.requestRepoRevisions) === JSON.stringify({ 'AgentWorkforce/relay': revision }); - const absent = observation.requestRepoRevisions === null; + observation.materializations?.length === 1 && + observation.requestRepos === null && + observation.requestRepoRevisions === null && + JSON.stringify(observation.relayfilePaths) === + JSON.stringify([ + '/github/repos/AgentWorkforce/relay/contents/**', + '/github/repos/AgentWorkforce/relay/.relayfile/**', + '/.skills/**', + ]) && + observation.workerCwd === '/workspace/github/repos/AgentWorkforce/relay/contents/packages/cli' && + observation.task?.includes(revision); + const absent = observation.materializations?.length === 0; const outcome = arm === 'head' && forwarded ? 'fixed' : arm === 'base' && absent ? 'absent' : null; if (!outcome) throw new Error(`Unexpected repository revision observation: ${JSON.stringify(observation)}.`); await mkdir(path.dirname(resultPath), { recursive: true }); await writeFile( resultPath, - `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'sandbox_repository_revision_contract_forwarded' : 'sandbox_repository_revision_contract_absent', details: outcome === 'fixed' ? 'The real fleet spawn --checkout command inferred the repository, forwarded its exact revision to Cloud, and retained the returned provider attribution through the CLI path.' : 'The base fleet spawn command omitted the opt-in checkout and exact repository revision contract.' })}\n` + `${JSON.stringify({ version: 1, caseId: CASE_ID, arm, outcome, signature: outcome === 'fixed' ? 'live_relayfile_repository_contract_forwarded' : 'live_relayfile_repository_contract_absent', details: outcome === 'fixed' ? 'The real plain fleet spawn --sandbox command inferred the repository, materialized its exact revision through Relayfile, mounted source metadata and skills, mapped the caller-relative cwd, and sent no static clone request.' : 'The base plain fleet spawn command did not materialize or mount the inferred repository as a live decoded Relayfile working tree.' })}\n` ); } finally { await rm(probePath, { force: true }); From 461b0c9e2a2426f40bb2673886aa365a0ba48a3e Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 10:23:28 +0200 Subject: [PATCH 35/41] fix(cloud): accept attested empty live repositories Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/fleet-sandbox.test.ts | 6 +++--- packages/cloud/src/fleet-sandbox.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 6d49c2cdae..567b4c21b5 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -132,11 +132,11 @@ describe('Cloud fleet sandbox client', () => { ref: revision, status: 'completed', headSha: revision, - filesWritten: 4312, + filesWritten: 0, materialization: { mode: 'relayfile_export', headSha: revision, - filesExpected: 4312, + filesExpected: 0, contentRoot: '/github/repos/AgentWorkforce/cloud/contents', sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', exportParams: { format: 'tar', decode: 'github-working-tree', gzip: false }, @@ -159,7 +159,7 @@ describe('Cloud fleet sandbox client', () => { cloudWorkspaceId: CLOUD_WORKSPACE_ID, repository: 'AgentWorkforce/cloud', revision, - filesWritten: 4312, + filesWritten: 0, contentRoot: '/github/repos/AgentWorkforce/cloud/contents', sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', }); diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index e253c8959c..e3837b039f 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -829,7 +829,9 @@ export async function materializeCloudRelayfileRepository( const detail = readString(job, 'lastError'); throw new Error( redactCredentialValues( - `Cloud could not materialize ${owner}/${repo} into Relayfile${detail ? `: ${detail}` : '.'}` + `Cloud could not materialize ${owner}/${repo} at ${revision} into Relayfile${ + detail ? `: ${detail}.` : '.' + } Verify that this exact commit is pushed to GitHub and that the pinned workspace's GitHub connection can read the repository, then retry.` ) ); } @@ -846,7 +848,8 @@ export async function materializeCloudRelayfileRepository( jobRef?.toLowerCase() !== revision || headSha !== revision || filesWritten === undefined || - filesWritten <= 0 || + !Number.isSafeInteger(filesWritten) || + filesWritten < 0 || !isObject(materialization) || readString(materialization, 'mode') !== 'relayfile_export' || readString(materialization, 'headSha')?.toLowerCase() !== revision || From 8c14a1afc940937e947bf49b8fb08fe291d97576 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 10:39:42 +0200 Subject: [PATCH 36/41] fix(cli): validate live repository materialization inputs Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 11 ++-- packages/cli/src/cli/commands/fleet.test.ts | 72 ++++++++++++++++++++- packages/cli/src/cli/commands/fleet.ts | 14 ++-- packages/cloud/src/fleet-sandbox.test.ts | 57 ++++++++++++++++ packages/cloud/src/fleet-sandbox.ts | 31 ++++++--- 5 files changed, 162 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c086fc95e..3595b05725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- From a pinned GitHub repository, plain `fleet spawn --sandbox` securely seeds - its exact clean, pushed `HEAD` through the workspace's connected GitHub - credential, mounts the decoded source tree and `.skills` through Relayfile, - maps the caller's relative directory, and keeps the working tree synchronized - by the Relayfile daemon. `--checkout` opts into a separate static Git clone. +- Plain `fleet spawn --sandbox` starts from a clean, pushed GitHub `HEAD`, mounts + its decoded source tree and `.skills` through Relayfile, maps the caller's + relative directory, and keeps the tree synchronized as GitHub changes flow + through the connected workspace integration. +- `fleet spawn --sandbox --checkout` opts into a separate static Git clone at + the exact pushed `HEAD` when a task needs Git metadata or checkout semantics. - `node agent attach ` automatically routes to a unique live Fleet node; ambiguous placements require `--node`. - `fleet spawn --sandbox` keeps temporary routing credentials out of project files. diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 4c99675314..1f7ce222e9 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -173,7 +173,9 @@ describe('fleet command support', () => { createWorkspace: vi.fn() as never, log: vi.fn() as never, error: vi.fn(), - exit: vi.fn() as never, + exit: vi.fn((code) => { + throw new Error(`CLI exit ${code}`); + }) as never, }, log: () => undefined, warn: () => undefined, @@ -1189,6 +1191,74 @@ describe('fleet command support', () => { expect(spawnInput.task).not.toContain('/local/cloud'); }); + it('rejects an explicit workspace mismatch before live repository materialization', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + const materializeCloudRelayfileRepository = vi.fn(); + const ensureCloudFleetSandbox = vi.fn(); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + resolveSandboxRepository: vi.fn(() => ({ + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: '/local/cloud', + repositoryRelativeCwd: '', + workerCwd: '/srv/agent-workforce/cloud', + })), + materializeCloudRelayfileRepository, + ensureCloudFleetSandbox, + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn((message) => { + throw new Error(String(message)); + }), + exit: vi.fn((code) => { + throw new Error(`CLI exit ${code}`); + }) as never, + }, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_test', + source: 'project', + origin: '/local/cloud/.agentworkforce/relay/workspace-key.json', + workspaceId: 'rw_captured', + }), + persistWorkspaceRelaycastTarget: vi.fn(() => true), + deleteCloudFleetSandbox: vi.fn(async () => undefined), + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await expect( + program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--workspace-id', + 'rw_explicit', + '--name', + 'cloud-live', + '--task', + 'Inspect this repository', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('--workspace-id does not match the captured workspace identity'); + expect(materializeCloudRelayfileRepository).not.toHaveBeenCalled(); + expect(ensureCloudFleetSandbox).not.toHaveBeenCalled(); + }); + it('--checkout infers the Git root and forwards only the public revision attestation', async () => { const revision = '0123456789abcdef0123456789abcdef01234567'; const repositorySelection = { diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 5c8b3761c8..bbb18aa48a 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -528,6 +528,13 @@ export function registerFleetCommands( : 'The current Relay workspace did not report an ID for Cloud provisioning.' ); } + if ( + explicitWorkspaceId !== undefined && + workspaceSelection?.workspaceId !== undefined && + explicitWorkspaceId !== workspaceSelection.workspaceId.trim() + ) { + throw new Error('--workspace-id does not match the captured workspace identity.'); + } if (!checkoutRepository && mountSandboxRelayfile && sandboxRepository) { liveRepository = await deps.materializeCloudRelayfileRepository({ workspaceId: relayWorkspaceId, @@ -557,13 +564,6 @@ export function registerFleetCommands( sandboxProvider === undefined || sandboxProvider === 'agent37' ? 'long-running-agent' : 'standard-long-running-agent'; - if ( - explicitWorkspaceId !== undefined && - workspaceSelection?.workspaceId !== undefined && - explicitWorkspaceId !== workspaceSelection.workspaceId.trim() - ) { - throw new Error('--workspace-id does not match the captured workspace identity.'); - } try { sandbox = await deps.ensureCloudFleetSandbox({ workspaceId: relayWorkspaceId, diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 567b4c21b5..faa59d7f4f 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -211,6 +211,44 @@ describe('Cloud fleet sandbox client', () => { ).rejects.toThrow(/did not prove a live Relayfile working tree/); }); + it('rejects a clone whose materialization file count disagrees with the job', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + mocks.authorizedApiFetch + .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), auth }) + .mockResolvedValueOnce({ + response: Response.json({ ok: true, jobId: 'clone-job-count', status: 'queued' }, { status: 202 }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'completed', + headSha: revision, + filesWritten: 4, + materialization: { + mode: 'relayfile_export', + headSha: revision, + filesExpected: 3, + contentRoot: '/github/repos/AgentWorkforce/cloud/contents', + sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', + }, + }, + }), + auth, + }); + + await expect( + materializeCloudRelayfileRepository( + { workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', revision }, + { pollIntervalMs: 0 } + ) + ).rejects.toThrow(/did not prove a live Relayfile working tree/); + }); + it('rejects a provisioned response with an untrusted server-owned Relaycast route', async () => { mocks.authorizedApiFetch .mockResolvedValueOnce({ @@ -1828,6 +1866,25 @@ describe('Cloud fleet sandbox client', () => { expect(signals[1]?.aborted).toBe(false); }); + it.each([ + ['fractional poll interval', { pollIntervalMs: 0.5 }], + ['infinite poll interval', { pollIntervalMs: Number.POSITIVE_INFINITY }], + ['fractional request timeout', { timeoutMs: 0.5 }], + ['oversized request timeout', { timeoutMs: 2_147_483_648 }], + ])('rejects invalid timer values before making a request (%s)', async (_label, options) => { + await expect( + materializeCloudRelayfileRepository( + { + workspaceId: 'rw_abc', + repository: 'AgentWorkforce/cloud', + revision: '0123456789abcdef0123456789abcdef01234567', + }, + options + ) + ).rejects.toThrow(/milliseconds/); + expect(mocks.authorizedApiFetch).not.toHaveBeenCalled(); + }); + it('keeps the default provisioning budget beyond the mounted server deadline', async () => { const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); mocks.authorizedApiFetch diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index e3837b039f..123200b37d 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -27,6 +27,7 @@ const DEFAULT_ENSURE_TIMEOUT_MS = 480_000; const DEFAULT_DELETE_TIMEOUT_MS = 30_000; const DEFAULT_RELAYFILE_REPOSITORY_MATERIALIZE_TIMEOUT_MS = 20 * 60_000; const DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS = 2_000; +const MAX_TIMER_MS = 2_147_483_647; export type CloudFleetSandboxRequestOptions = { apiUrl?: string; @@ -327,14 +328,24 @@ function assertProviderRelaycastTarget( } function boundedSignal(options: CloudFleetSandboxRequestOptions, defaultTimeoutMs: number): AbortSignal { - const timeoutMs = options.timeoutMs ?? defaultTimeoutMs; - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error('Cloud fleet sandbox request timeout must be a positive number of milliseconds.'); - } + const timeoutMs = normalizeTimerMs( + options.timeoutMs ?? defaultTimeoutMs, + false, + 'Cloud fleet request timeout' + ); const timeoutSignal = AbortSignal.timeout(timeoutMs); return options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; } +function normalizeTimerMs(value: number, allowZero: boolean, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || (!allowZero && value === 0) || value > MAX_TIMER_MS) { + throw new Error( + `${label} must be an integer between ${allowZero ? 0 : 1} and ${MAX_TIMER_MS} milliseconds.` + ); + } + return value; +} + async function readJson(response: Response): Promise { try { return await response.json(); @@ -761,12 +772,11 @@ export async function materializeCloudRelayfileRepository( if (!REPOSITORY_REVISION_PATTERN.test(revision)) { throw new Error('Cloud Relayfile repository revision must be exactly 40 hexadecimal characters.'); } - const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS; - if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) { - throw new Error( - 'Cloud Relayfile repository poll interval must be a non-negative number of milliseconds.' - ); - } + const pollIntervalMs = normalizeTimerMs( + options.pollIntervalMs ?? DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS, + true, + 'Cloud Relayfile repository poll interval' + ); const session = await ensureCloudSession({ apiUrl: options.apiUrl || defaultApiUrl(), @@ -852,6 +862,7 @@ export async function materializeCloudRelayfileRepository( filesWritten < 0 || !isObject(materialization) || readString(materialization, 'mode') !== 'relayfile_export' || + readNumber(materialization, 'filesExpected') !== filesWritten || readString(materialization, 'headSha')?.toLowerCase() !== revision || readString(materialization, 'contentRoot') !== expectedPaths.contentRoot || readString(materialization, 'sentinelPath') !== expectedPaths.sentinelPath From ebca720af9ae809adb27dcd228975ef2098c3388 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 13 Sep 2026 10:40:48 +0200 Subject: [PATCH 37/41] fix(cloud): reject zero materialization poll intervals Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- packages/cloud/src/fleet-sandbox.test.ts | 7 ++++--- packages/cloud/src/fleet-sandbox.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index faa59d7f4f..cb824ac81f 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -153,7 +153,7 @@ describe('Cloud fleet sandbox client', () => { repository: 'AgentWorkforce/cloud', revision, }, - { pollIntervalMs: 0 } + { pollIntervalMs: 1 } ) ).resolves.toEqual({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, @@ -206,7 +206,7 @@ describe('Cloud fleet sandbox client', () => { await expect( materializeCloudRelayfileRepository( { workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', revision }, - { pollIntervalMs: 0 } + { pollIntervalMs: 1 } ) ).rejects.toThrow(/did not prove a live Relayfile working tree/); }); @@ -244,7 +244,7 @@ describe('Cloud fleet sandbox client', () => { await expect( materializeCloudRelayfileRepository( { workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', revision }, - { pollIntervalMs: 0 } + { pollIntervalMs: 1 } ) ).rejects.toThrow(/did not prove a live Relayfile working tree/); }); @@ -1867,6 +1867,7 @@ describe('Cloud fleet sandbox client', () => { }); it.each([ + ['zero poll interval', { pollIntervalMs: 0 }], ['fractional poll interval', { pollIntervalMs: 0.5 }], ['infinite poll interval', { pollIntervalMs: Number.POSITIVE_INFINITY }], ['fractional request timeout', { timeoutMs: 0.5 }], diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 123200b37d..59d690b8a8 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -774,7 +774,7 @@ export async function materializeCloudRelayfileRepository( } const pollIntervalMs = normalizeTimerMs( options.pollIntervalMs ?? DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS, - true, + false, 'Cloud Relayfile repository poll interval' ); From 6bc94ac897b5cf217d2dedbe7b941b59966fbc0a Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 13 Sep 2026 15:58:44 +0200 Subject: [PATCH 38/41] feat(cli): complete live sandbox source contract Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 5 +- packages/cli/README.md | 26 +- .../fleet-lifecycle-integration.test.ts | 259 ++++++++++++++ .../commands/fleet-sandbox-regression.test.ts | 329 ++++++++++++++++++ packages/cli/src/cli/commands/fleet.test.ts | 1 + packages/cli/src/cli/commands/fleet.ts | 5 +- packages/cli/src/cli/lib/sandbox-repo.test.ts | 11 + packages/cli/src/cli/lib/sandbox-repo.ts | 5 +- packages/cloud/src/fleet-sandbox.test.ts | 6 + packages/cloud/src/fleet-sandbox.ts | 7 + 10 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts create mode 100644 packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3595b05725..ac0f7facf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plain `fleet spawn --sandbox` starts from a clean, pushed GitHub `HEAD`, mounts its decoded source tree and `.skills` through Relayfile, maps the caller's - relative directory, and keeps the tree synchronized as GitHub changes flow - through the connected workspace integration. + relative directory, preserves tracked files, symlinks, and executable modes, + and keeps the tree synchronized as GitHub changes flow through the connected + workspace integration. - `fleet spawn --sandbox --checkout` opts into a separate static Git clone at the exact pushed `HEAD` when a task needs Git metadata or checkout semantics. - `node agent attach ` automatically routes to a unique live Fleet node; diff --git a/packages/cli/README.md b/packages/cli/README.md index 1e8c5e8439..5f5ade2e10 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -263,6 +263,12 @@ provisioning ends with an unknown outcome, rerun the command with the warning's `--sandbox-id` to replay the same Cloud identity instead of adopting another fleet node. +The live source profile includes tracked dotfiles, lockfiles, generated and +binary files, large files within Relayfile's import limit, symlinks, and +executable permissions. Relayfile never places `.git` in this tree. If a +repository entry cannot be represented safely, spawn fails with the entry and +corrective action instead of reporting a partial working tree. + Both live and checkout modes require a clean working tree whose exact `HEAD` is reachable from a configured GitHub remote. This prevents a remote worker from silently starting at a different revision. Commit and push local work before @@ -338,17 +344,23 @@ use `--mode view` to observe. Releasing a worker does not delete its sandbox. To resume its retained sandbox, repeat spawn with the reported `--sandbox-id `; when using `--checkout`, the retained clone must still have the same clean HEAD. -A failed resume preserves retained work. Delete an unused sandbox in Cloud -Fleet to stop future provider usage; monthly accounting reservations remain -until their normal reset. +A failed resume preserves retained work. For a live Relayfile sandbox, reusing +`--sandbox-id` intentionally re-materializes the exact clean, pushed `HEAD` from +the current checkout before the provider resumes, so a new commit becomes the +source tree for that retained sandbox. With `--checkout`, the retained static +clone remains pinned to its original revision and the current checkout must +still resolve to that same clean, pushed `HEAD`. Delete an unused sandbox in +Cloud Fleet to stop future provider usage; monthly accounting reservations +remain until their normal reset. If the workspace is not pinned yet, use `agent-relay workspace rebind ` with an existing stored workspace. A missing or mismatched stored route credential requires rerunning sandbox provisioning for that workspace. -`--base-url`, `--workspace-id`, `--node`, provider selection, `--cwd`, and the -static `--checkout` mode remain advanced overrides. Outside Git, plain -`--sandbox` preserves the existing full-workspace Relayfile mount at -`/workspace`. +`--base-url`, `--workspace-id`, `--node`, provider selection, and the static +`--checkout` mode remain advanced overrides. For `--cwd`, local repo-relative +paths are accepted to infer the sandbox repository; absolute remote paths are +advanced overrides. Outside Git, plain `--sandbox` preserves the existing +full-workspace Relayfile mount at `/workspace`. Pins created before workspace IDs were recorded are resolved automatically through Cloud at spawn time. The key travels in an authenticated POST body, diff --git a/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts new file mode 100644 index 0000000000..8052ab5c43 --- /dev/null +++ b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts @@ -0,0 +1,259 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerFleetCommands } from './fleet.js'; +import { registerLocalAgentCommands } from './local-agent.js'; +import { registerMessageCommands } from './message.js'; +import { + resolveWorkspaceSelection, + resolveWorkspaceTransport, + persistWorkspaceRelaycastTarget, +} from '../lib/sdk-client.js'; +import { resolveFleetAttachTarget } from '../lib/fleet-attach-target.js'; +import { readProjectWorkspaceSession, writeProjectWorkspaceKey } from '../lib/project-workspace-key.js'; + +const TARGET = { + route: 'agent37-isolated' as const, + baseUrl: 'https://agent37-cast.agentrelay.com', + workspaceId: 'rw_test', + relaycastApiKey: 'rk_live_cloud_returned', +}; + +const SANDBOX_ID = 'sbx_123e4567-e89b-42d3-a456-426614174000'; +const SANDBOX_NAME = 'fleet-sandbox-123e4567-e89b-42d3-a456-426614174000'; + +let projectRoot: string; +let relayHome: string; + +afterEach(() => { + vi.unstubAllEnvs(); + if (projectRoot) fs.rmSync(projectRoot, { recursive: true, force: true }); + if (relayHome) fs.rmSync(relayHome, { recursive: true, force: true }); +}); + +describe('fleet CLI lifecycle routing', () => { + it('reuses the persisted Cloud target across spawn, attach, message, list, and release without --base-url', async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fleet-lifecycle-project-')); + relayHome = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-fleet-lifecycle-home-')); + vi.stubEnv('AGENT_RELAY_PROJECT', projectRoot); + vi.stubEnv('AGENT_RELAY_HOME', relayHome); + const dataDir = path.join(projectRoot, '.agentworkforce', 'relay'); + writeProjectWorkspaceKey(dataDir, 'rk_live_workspace', { workspaceId: TARGET.workspaceId }); + + const nodeName = 'retained-sandbox-node'; + const workerName = 'unique-retained-worker'; + const transportCalls: { workspaceKey: string; baseUrl?: string }[] = []; + const attachNode = vi.fn(async () => 0); + const release = vi.fn(async () => ({ name: workerName, released: true, deleted: false })); + const direct = vi.fn(async (input: unknown) => ({ id: 'message-1', ...(input as object) })); + const nodesList = vi.fn(async () => [ + { + id: 'node-1', + nodeId: 'node-1', + name: nodeName, + status: 'online', + live: true, + handlersLive: true, + capabilities: [ + { + name: 'relay:live-agents:v1', + metadata: { names: [workerName] }, + }, + ], + }, + ]); + const workspaceClient = { + nodes: { list: nodesList }, + agents: { + list: vi.fn(async () => [{ name: workerName, status: 'online' }]), + release, + }, + workspace: { + info: vi.fn(async () => ({ id: TARGET.workspaceId })), + register: vi.fn(async () => ({ token: 'at_live_launcher' })), + release: vi.fn(async () => ({ released: true, deleted: true })), + }, + }; + const createWorkspaceRelay = vi.fn((options: { workspaceKey?: string; baseUrl?: string } = {}) => { + const transport = resolveWorkspaceTransport(options); + transportCalls.push({ + workspaceKey: transport.workspaceKey, + ...(transport.baseUrl ? { baseUrl: transport.baseUrl } : {}), + }); + return workspaceClient as never; + }); + const createAgentRelay = vi.fn((options: { workspaceKey?: string; baseUrl?: string } = {}) => { + const transport = resolveWorkspaceTransport(options); + transportCalls.push({ + workspaceKey: transport.workspaceKey, + ...(transport.baseUrl ? { baseUrl: transport.baseUrl } : {}), + }); + return { + messages: { direct }, + messaging: { placement }, + } as never; + }); + const placement = { + spawn: vi.fn(async () => ({ invocationId: 'inv_lifecycle', node: { name: nodeName } })), + }; + const ensureCloudFleetSandbox = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-1', + nodeName, + sandboxId: SANDBOX_ID, + providerSandboxId: 'provider-sandbox-1', + relayWorkspaceId: TARGET.workspaceId, + relaycastTarget: TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + })); + const materialize = vi.fn(async () => ({ + cloudWorkspaceId: 'cloud-workspace', + repository: 'AgentWorkforce/relay', + revision: '0123456789abcdef0123456789abcdef01234567', + filesWritten: 1, + sourceProfile: 'complete-v1' as const, + contentRoot: '/github/repos/AgentWorkforce/relay/contents', + sentinelPath: '/github/repos/AgentWorkforce/relay/.relayfile/clone.json', + })); + const sdk = { + createAgentRelay: createAgentRelay as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error('__exit__'); + }) as never, + }; + const core = { + env: process.env, + getProjectPaths: () => ({ projectRoot, dataDir, teamDir: projectRoot }), + exit: vi.fn(), + }; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + core: core as never, + sdk, + resolveWorkspaceSelection: resolveWorkspaceSelection as never, + resolveSandboxRepository: () => ({ + repository: 'AgentWorkforce/relay', + repositoryName: 'relay', + revision: '0123456789abcdef0123456789abcdef01234567', + projectRoot, + repositoryRelativeCwd: '', + workerCwd: '/srv/agent-workforce/relay', + }), + materializeCloudRelayfileRepository: materialize as never, + ensureCloudFleetSandbox: ensureCloudFleetSandbox as never, + persistWorkspaceRelaycastTarget: persistWorkspaceRelaycastTarget as never, + createFleetWorkspaceClient: createWorkspaceRelay as never, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }); + + const node = program.command('node'); + registerLocalAgentCommands(node, { + cwd: () => projectRoot, + env: process.env, + attachNode, + resolveFleetAttachTarget: (name) => + resolveFleetAttachTarget( + name, + () => workspaceClient as never, + () => resolveWorkspaceTransport(), + () => resolveWorkspaceSelection({ projectRoot }) + ), + attach: vi.fn(async () => 0), + attachRemote: vi.fn(async () => 0), + connect: vi.fn() as never, + connectLocal: vi.fn() as never, + readConnectionFile: vi.fn(), + getDefaultStateDir: () => dataDir, + fetch: globalThis.fetch, + redeemJoinTicket: vi.fn() as never, + persistWorkspaceSession: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error('__exit__'); + }) as never, + now: () => new Date('2026-01-01T00:00:00Z'), + }); + registerMessageCommands(program, { + createAgentRelay, + createWorkspaceRelay, + log: vi.fn(), + error: vi.fn(), + exit: sdk.exit, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--sandbox-id', + SANDBOX_ID, + '--sandbox-name', + SANDBOX_NAME, + '--workspace-id', + TARGET.workspaceId, + '--name', + workerName, + '--task', + 'Work', + '--workspace-key', + 'rk_live_workspace', + ], + { from: 'user' } + ); + expect(readProjectWorkspaceSession(dataDir)).toMatchObject({ + workspaceKey: 'rk_live_workspace', + workspaceId: TARGET.workspaceId, + relaycastRoute: TARGET.route, + relaycastBaseUrl: TARGET.baseUrl, + relaycastApiKey: TARGET.relaycastApiKey, + }); + expect(fs.readFileSync(path.join(dataDir, 'workspace-key.json'), 'utf8')).not.toContain( + TARGET.relaycastApiKey + ); + + await program.parseAsync(['node', 'agent', 'attach', workerName, '--mode', 'view'], { from: 'user' }); + await program.parseAsync(['message', 'dm', 'send', workerName, 'hello'], { from: 'user' }); + await program.parseAsync(['fleet', 'agent', 'list'], { from: 'user' }); + await program.parseAsync(['fleet', 'release', workerName], { from: 'user' }); + + expect(attachNode).toHaveBeenCalledWith(workerName, 'view', 'node-1', { + baseUrl: TARGET.baseUrl, + workspaceKey: undefined, + json: undefined, + reasoning: undefined, + diagnostics: undefined, + }); + expect(direct).toHaveBeenCalledWith({ to: workerName, text: 'hello' }); + expect(nodesList).toHaveBeenCalled(); + expect(release).toHaveBeenCalledWith(expect.objectContaining({ name: workerName, deleteAgent: false })); + expect(transportCalls.length).toBeGreaterThanOrEqual(4); + expect(transportCalls).toEqual( + expect.arrayContaining([ + { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, + { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, + { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, + ]) + ); + expect(transportCalls.every((call) => call.baseUrl === TARGET.baseUrl)).toBe(true); + }); +}); diff --git a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts new file mode 100644 index 0000000000..4c9e5fdb10 --- /dev/null +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -0,0 +1,329 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Command } from 'commander'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerFleetCommands } from './fleet.js'; +import { resolveSandboxRepository } from '../lib/sandbox-repo.js'; + +const REPLAY_SANDBOX_ID = 'sbx_123e4567-e89b-42d3-a456-426614174000'; +const REPLAY_SANDBOX_NAME = 'fleet-sandbox-123e4567-e89b-42d3-a456-426614174000'; +const TARGET = { + route: 'agent37-isolated' as const, + baseUrl: 'https://agent37-cast.agentrelay.com', + workspaceId: 'rw_test', + relaycastApiKey: 'rk_live_test_target', +}; + +const REVISION = '0123456789abcdef0123456789abcdef01234567'; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } + vi.unstubAllEnvs(); +}); + +function temporaryDirectory(prefix: string): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirectories.push(directory); + return directory; +} + +function registerSandboxCommand(overrides: { + projectRoot: string; + resolveSandboxRepository: typeof resolveSandboxRepository; + materializeCloudRelayfileRepository?: ReturnType; + ensureCloudFleetSandbox: ReturnType; + placement?: ReturnType; +}): Command { + const placement = overrides.placement ?? vi.fn(async () => ({ invocationId: 'inv_test' })); + const register = vi.fn(async () => ({ token: 'at_live_launcher' })); + const release = vi.fn(async () => ({ released: true, deleted: true })); + const createWorkspaceRelay = vi.fn(() => ({ + workspace: { + info: vi.fn(async () => ({ id: TARGET.workspaceId })), + register, + release, + }, + })); + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + core: { + getProjectPaths: () => ({ + projectRoot: overrides.projectRoot, + dataDir: path.join(overrides.projectRoot, '.agentworkforce', 'relay'), + teamDir: overrides.projectRoot, + }), + env: {}, + exit: vi.fn(() => { + throw new Error('__exit__'); + }), + } as never, + resolveSandboxRepository: overrides.resolveSandboxRepository, + ...(overrides.materializeCloudRelayfileRepository + ? { materializeCloudRelayfileRepository: overrides.materializeCloudRelayfileRepository as never } + : {}), + ensureCloudFleetSandbox: overrides.ensureCloudFleetSandbox as never, + resolveWorkspaceSelection: () => ({ + key: 'rk_live_workspace', + source: 'project', + origin: path.join(overrides.projectRoot, '.agentworkforce', 'relay', 'workspace-key.json'), + workspaceId: TARGET.workspaceId, + }), + persistWorkspaceRelaycastTarget: () => true, + deleteCloudFleetSandbox: vi.fn(async () => undefined), + sdk: { + createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: placement } } })) as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error('__exit__'); + }) as never, + }, + createFleetWorkspaceClient: vi.fn() as never, + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }); + return program; +} + +function materialization(revision: string) { + return { + cloudWorkspaceId: 'cloud-workspace', + repository: 'AgentWorkforce/relay', + revision, + filesWritten: 3, + sourceProfile: 'complete-v1' as const, + contentRoot: '/github/repos/AgentWorkforce/relay/contents', + sentinelPath: '/github/repos/AgentWorkforce/relay/.relayfile/clone.json', + }; +} + +describe('fleet sandbox command regressions', () => { + it('documents local repo-relative cwd inference and absolute remote cwd overrides', () => { + const program = registerSandboxCommand({ + projectRoot: temporaryDirectory('relay-fleet-help-'), + resolveSandboxRepository: () => undefined, + ensureCloudFleetSandbox: vi.fn(), + }); + const fleet = program.commands.find((command) => command.name() === 'fleet'); + const spawn = fleet?.commands.find((command) => command.name() === 'spawn'); + const cwd = spawn?.options.find((option) => option.long === '--cwd'); + + expect(cwd?.description).toMatch(/local repo-relative path/); + expect(cwd?.description).toMatch(/absolute remote paths.*advanced overrides/); + }); + + it('keeps the full /workspace Relayfile mount outside Git without materializing a repository', async () => { + const outsideGit = temporaryDirectory('relay-fleet-outside-git-'); + const resolveRepository = vi.fn((projectRoot: string, requestedCwd: string | undefined) => + resolveSandboxRepository(projectRoot, requestedCwd, { cwd: () => outsideGit }) + ); + const materialize = vi.fn(); + const ensure = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-outside-git', + nodeName: 'outside-git-node', + sandboxId: 'sandbox-outside-git', + providerSandboxId: 'provider-outside-git', + relayWorkspaceId: TARGET.workspaceId, + relaycastTarget: TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + })); + const placement = vi.fn(async () => ({ invocationId: 'inv_outside_git' })); + const program = registerSandboxCommand({ + projectRoot: outsideGit, + resolveSandboxRepository: resolveRepository, + materializeCloudRelayfileRepository: materialize, + ensureCloudFleetSandbox: ensure, + placement, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--workspace-id', + TARGET.workspaceId, + '--name', + 'outside-git-worker', + '--task', + 'Work', + '--workspace-key', + 'rk_live_workspace', + ], + { from: 'user' } + ); + + expect(resolveRepository).toHaveBeenCalledWith(outsideGit, undefined); + expect(materialize).not.toHaveBeenCalled(); + expect(ensure).toHaveBeenCalledWith(expect.objectContaining({ mountRelayfile: true })); + expect((ensure.mock.calls[0]?.[0] as Record).relayfilePaths).toBeUndefined(); + expect(placement).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ worker_cwd: '/workspace' }) }) + ); + }); + + it('re-materializes the current HEAD before resuming a retained live Relayfile sandbox', async () => { + const projectRoot = temporaryDirectory('relay-fleet-live-resume-'); + const selection = { + repository: 'AgentWorkforce/relay', + repositoryName: 'relay', + revision: REVISION, + projectRoot, + repositoryRelativeCwd: '', + workerCwd: '/srv/agent-workforce/relay', + }; + const events: string[] = []; + const materialize = vi.fn(async () => { + events.push('materialize'); + return materialization(REVISION); + }); + const ensure = vi.fn(async () => { + events.push('ensure'); + return { + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-retained', + nodeName: 'retained-node', + sandboxId: REPLAY_SANDBOX_ID, + providerSandboxId: 'provider-retained', + relayWorkspaceId: TARGET.workspaceId, + relaycastTarget: TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + }; + }); + const placement = vi.fn(async () => { + events.push('spawn'); + return { invocationId: 'inv_live_resume' }; + }); + const program = registerSandboxCommand({ + projectRoot, + resolveSandboxRepository: () => selection, + materializeCloudRelayfileRepository: materialize, + ensureCloudFleetSandbox: ensure, + placement, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--sandbox-id', + REPLAY_SANDBOX_ID, + '--sandbox-name', + REPLAY_SANDBOX_NAME, + '--name', + 'retained-live-worker', + '--task', + 'Resume', + '--workspace-key', + 'rk_live_workspace', + ], + { from: 'user' } + ); + + expect(events).toEqual(['materialize', 'ensure', 'spawn']); + expect(materialize).toHaveBeenCalledWith({ + workspaceId: TARGET.workspaceId, + repository: selection.repository, + revision: REVISION, + }); + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + sandboxId: REPLAY_SANDBOX_ID, + name: REPLAY_SANDBOX_NAME, + mountRelayfile: true, + }) + ); + const ensureInput = ensure.mock.calls[0]?.[0] as Record; + expect(ensureInput.repos).toBeUndefined(); + expect(ensureInput.repoRevisions).toBeUndefined(); + }); + + it('keeps retained checkout resumes pinned to the exact current revision', async () => { + const projectRoot = temporaryDirectory('relay-fleet-checkout-resume-'); + const selection = { + repository: 'AgentWorkforce/relay', + repositoryName: 'relay', + revision: REVISION, + projectRoot, + repositoryRelativeCwd: '', + workerCwd: '/srv/agent-workforce/relay', + }; + const ensure = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-retained-checkout', + nodeName: 'retained-checkout-node', + sandboxId: REPLAY_SANDBOX_ID, + providerSandboxId: 'provider-retained-checkout', + relayWorkspaceId: TARGET.workspaceId, + relaycastTarget: TARGET, + relayfileMounted: true, + relayfileMountPath: '/workspace', + repoRevisions: { [selection.repository]: REVISION }, + })); + const materialize = vi.fn(); + const program = registerSandboxCommand({ + projectRoot, + resolveSandboxRepository: () => selection, + materializeCloudRelayfileRepository: materialize, + ensureCloudFleetSandbox: ensure, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--checkout', + '--sandbox-provider', + 'agent37', + '--sandbox-id', + REPLAY_SANDBOX_ID, + '--sandbox-name', + REPLAY_SANDBOX_NAME, + '--name', + 'retained-checkout-worker', + '--task', + 'Resume', + '--workspace-key', + 'rk_live_workspace', + ], + { from: 'user' } + ); + + expect(materialize).not.toHaveBeenCalled(); + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + repos: [selection.repository], + repoRevisions: { [selection.repository]: REVISION }, + }) + ); + }); +}); diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 1f7ce222e9..164614c5ee 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1074,6 +1074,7 @@ describe('fleet command support', () => { repository: 'AgentWorkforce/cloud', revision, filesWritten: 4312, + sourceProfile: 'complete-v1' as const, contentRoot: '/github/repos/AgentWorkforce/cloud/contents', sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', }; diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index bbb18aa48a..bac770b625 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -328,7 +328,10 @@ export function registerFleetCommands( .option('--channel ', 'Channel for the worker to join') .option('--persona ', 'Worker persona (automatic placement)') .option('--model ', 'Model powering the worker') - .option('--cwd ', 'Absolute working directory for the spawned worker') + .option( + '--cwd ', + 'Worker directory: a local repo-relative path is accepted for sandbox repository inference; absolute remote paths are advanced overrides' + ) .option('--organization ', 'Declared organization for workforce reporting') .option('--project ', 'Declared project for workforce reporting') .option('--workstream ', 'Declared workstream for workforce reporting') diff --git a/packages/cli/src/cli/lib/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts index 7379a330a8..0ce8c88e95 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.test.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -86,6 +86,17 @@ describe('resolveSandboxRepository', () => { ); }); + it('rejects credentials embedded in an SCP-style GitHub remote', () => { + const run = gitMock(); + run.mockImplementation((_command: string, args: readonly string[]) => { + if (args.includes('get-url')) return 'token@github.com:AgentWorkforce/relay.git\n'; + return (gitMock() as never)(_command, args); + }); + expect(() => resolveSandboxRepository('/checkout', undefined, { execFileSync: run as never })).toThrow( + /GitHub owner\/name/ + ); + }); + it('rejects a branch whose exact HEAD is ahead of its upstream', () => { const ahead = gitMock(); ahead.mockImplementation((_command: string, args: readonly string[]) => { diff --git a/packages/cli/src/cli/lib/sandbox-repo.ts b/packages/cli/src/cli/lib/sandbox-repo.ts index 1cecaec927..2ff16c1973 100644 --- a/packages/cli/src/cli/lib/sandbox-repo.ts +++ b/packages/cli/src/cli/lib/sandbox-repo.ts @@ -231,7 +231,10 @@ function parseRepository(remote: string): string | undefined { let name: string | undefined; const scp = value.match(/^([^@/]+)@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/); if (scp) { - if (scp[2].toLowerCase() !== 'github.com') return undefined; + // SCP-style GitHub remotes have no URL parser boundary, so reject any + // username other than GitHub's literal SSH user. A token or other secret + // must never be accepted as part of the local remote identity. + if (scp[1] !== 'git' || scp[2].toLowerCase() !== 'github.com') return undefined; owner = scp[3]; name = scp[4]; } else { diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index cb824ac81f..8927470ca8 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -133,8 +133,10 @@ describe('Cloud fleet sandbox client', () => { status: 'completed', headSha: revision, filesWritten: 0, + sourceProfile: 'complete-v1', materialization: { mode: 'relayfile_export', + sourceProfile: 'complete-v1', headSha: revision, filesExpected: 0, contentRoot: '/github/repos/AgentWorkforce/cloud/contents', @@ -160,6 +162,7 @@ describe('Cloud fleet sandbox client', () => { repository: 'AgentWorkforce/cloud', revision, filesWritten: 0, + sourceProfile: 'complete-v1', contentRoot: '/github/repos/AgentWorkforce/cloud/contents', sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', }); @@ -172,6 +175,7 @@ describe('Cloud fleet sandbox client', () => { repo: 'cloud', ref: revision, mode: 'full', + sourceProfile: 'complete-v1', }); expect(JSON.stringify(requestCall)).not.toContain('githubToken'); }); @@ -229,8 +233,10 @@ describe('Cloud fleet sandbox client', () => { status: 'completed', headSha: revision, filesWritten: 4, + sourceProfile: 'complete-v1', materialization: { mode: 'relayfile_export', + sourceProfile: 'complete-v1', headSha: revision, filesExpected: 3, contentRoot: '/github/repos/AgentWorkforce/cloud/contents', diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 59d690b8a8..b89c1aa742 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -28,6 +28,7 @@ const DEFAULT_DELETE_TIMEOUT_MS = 30_000; const DEFAULT_RELAYFILE_REPOSITORY_MATERIALIZE_TIMEOUT_MS = 20 * 60_000; const DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS = 2_000; const MAX_TIMER_MS = 2_147_483_647; +const LIVE_RELAYFILE_SOURCE_PROFILE = 'complete-v1' as const; export type CloudFleetSandboxRequestOptions = { apiUrl?: string; @@ -49,6 +50,7 @@ export type CloudRelayfileRepositoryMaterialization = { repository: string; revision: string; filesWritten: number; + sourceProfile: typeof LIVE_RELAYFILE_SOURCE_PROFILE; contentRoot: string; sentinelPath: string; }; @@ -799,6 +801,7 @@ export async function materializeCloudRelayfileRepository( repo, ref: revision, mode: 'full', + sourceProfile: LIVE_RELAYFILE_SOURCE_PROFILE, }), }, { interactive: false } @@ -851,6 +854,7 @@ export async function materializeCloudRelayfileRepository( const jobRef = readString(job, 'ref'); const headSha = readString(job, 'headSha')?.toLowerCase(); const filesWritten = readNumber(job, 'filesWritten'); + const sourceProfile = readString(job, 'sourceProfile'); const materialization = job.materialization; if ( jobOwner !== owner || @@ -860,8 +864,10 @@ export async function materializeCloudRelayfileRepository( filesWritten === undefined || !Number.isSafeInteger(filesWritten) || filesWritten < 0 || + sourceProfile !== LIVE_RELAYFILE_SOURCE_PROFILE || !isObject(materialization) || readString(materialization, 'mode') !== 'relayfile_export' || + readString(materialization, 'sourceProfile') !== LIVE_RELAYFILE_SOURCE_PROFILE || readNumber(materialization, 'filesExpected') !== filesWritten || readString(materialization, 'headSha')?.toLowerCase() !== revision || readString(materialization, 'contentRoot') !== expectedPaths.contentRoot || @@ -876,6 +882,7 @@ export async function materializeCloudRelayfileRepository( repository: `${owner}/${repo}`, revision, filesWritten, + sourceProfile: LIVE_RELAYFILE_SOURCE_PROFILE, ...expectedPaths, }; } From 6c3616b269ccfd2a3c40fca21bbc5fc65bd5c04b Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 13 Sep 2026 16:29:38 +0200 Subject: [PATCH 39/41] fix(cli): address PR 1763 review findings Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 7 +- .../fleet-lifecycle-integration.test.ts | 13 ++- .../commands/fleet-sandbox-regression.test.ts | 4 +- packages/cli/src/cli/commands/fleet.ts | 2 +- packages/cloud/src/fleet-sandbox.test.ts | 88 ++++++++++++++++++- packages/cloud/src/fleet-sandbox.ts | 6 +- 6 files changed, 100 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0f7facf1..ce1015204f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plain `fleet spawn --sandbox` starts from a clean, pushed GitHub `HEAD`, mounts its decoded source tree and `.skills` through Relayfile, maps the caller's - relative directory, preserves tracked files, symlinks, and executable modes, - and keeps the tree synchronized as GitHub changes flow through the connected - workspace integration. + relative directory, and keeps the tree synchronized as GitHub changes flow + through the connected workspace integration. +- `fleet spawn --sandbox` preserves tracked files, symlinks, and executable modes + in the mounted source tree. - `fleet spawn --sandbox --checkout` opts into a separate static Git clone at the exact pushed `HEAD` when a task needs Git metadata or checkout semantics. - `node agent attach ` automatically routes to a unique live Fleet node; diff --git a/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts index 8052ab5c43..5840fbf539 100644 --- a/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts +++ b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts @@ -247,13 +247,10 @@ describe('fleet CLI lifecycle routing', () => { expect(nodesList).toHaveBeenCalled(); expect(release).toHaveBeenCalledWith(expect.objectContaining({ name: workerName, deleteAgent: false })); expect(transportCalls.length).toBeGreaterThanOrEqual(4); - expect(transportCalls).toEqual( - expect.arrayContaining([ - { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, - { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, - { workspaceKey: TARGET.relaycastApiKey, baseUrl: TARGET.baseUrl }, - ]) - ); - expect(transportCalls.every((call) => call.baseUrl === TARGET.baseUrl)).toBe(true); + expect( + transportCalls.every( + (call) => call.workspaceKey === TARGET.relaycastApiKey && call.baseUrl === TARGET.baseUrl + ) + ).toBe(true); }); }); diff --git a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts index 4c9e5fdb10..0ca56084f0 100644 --- a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -119,8 +119,8 @@ describe('fleet sandbox command regressions', () => { const spawn = fleet?.commands.find((command) => command.name() === 'spawn'); const cwd = spawn?.options.find((option) => option.long === '--cwd'); - expect(cwd?.description).toMatch(/local repo-relative path/); - expect(cwd?.description).toMatch(/absolute remote paths.*advanced overrides/); + expect(cwd?.description).toMatch(/With --sandbox, a local repo-relative path/); + expect(cwd?.description).toMatch(/without --sandbox, use an absolute remote path/); }); it('keeps the full /workspace Relayfile mount outside Git without materializing a repository', async () => { diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index bac770b625..97b3d5fa9b 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -330,7 +330,7 @@ export function registerFleetCommands( .option('--model ', 'Model powering the worker') .option( '--cwd ', - 'Worker directory: a local repo-relative path is accepted for sandbox repository inference; absolute remote paths are advanced overrides' + 'Working directory. With --sandbox, a local repo-relative path resolves to the materialized source tree; without --sandbox, use an absolute remote path (relative paths are forwarded verbatim)' ) .option('--organization ', 'Declared organization for workforce reporting') .option('--project ', 'Declared project for workforce reporting') diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 8927470ca8..d90e1800f4 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -1874,10 +1874,8 @@ describe('Cloud fleet sandbox client', () => { it.each([ ['zero poll interval', { pollIntervalMs: 0 }], - ['fractional poll interval', { pollIntervalMs: 0.5 }], ['infinite poll interval', { pollIntervalMs: Number.POSITIVE_INFINITY }], - ['fractional request timeout', { timeoutMs: 0.5 }], - ['oversized request timeout', { timeoutMs: 2_147_483_648 }], + ['negative request timeout', { timeoutMs: -1 }], ])('rejects invalid timer values before making a request (%s)', async (_label, options) => { await expect( materializeCloudRelayfileRepository( @@ -1892,6 +1890,90 @@ describe('Cloud fleet sandbox client', () => { expect(mocks.authorizedApiFetch).not.toHaveBeenCalled(); }); + it('floors a positive fractional materialization poll interval', async () => { + const revision = '0123456789abcdef0123456789abcdef01234567'; + mocks.authorizedApiFetch + .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), auth }) + .mockResolvedValueOnce({ + response: Response.json({ ok: true, jobId: 'clone-job-fraction', status: 'queued' }, { status: 202 }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'running', + }, + }), + auth, + }) + .mockResolvedValueOnce({ + response: Response.json({ + ok: true, + job: { + owner: 'AgentWorkforce', + repo: 'cloud', + ref: revision, + status: 'completed', + headSha: revision, + filesWritten: 0, + sourceProfile: 'complete-v1', + materialization: { + mode: 'relayfile_export', + sourceProfile: 'complete-v1', + headSha: revision, + filesExpected: 0, + contentRoot: '/github/repos/AgentWorkforce/cloud/contents', + sentinelPath: '/github/repos/AgentWorkforce/cloud/.relayfile/clone.json', + }, + }, + }), + auth, + }); + const delaySpy = vi.spyOn(globalThis, 'setTimeout'); + + await materializeCloudRelayfileRepository( + { workspaceId: 'rw_abc', repository: 'AgentWorkforce/cloud', revision }, + { pollIntervalMs: 0.5 } + ); + + expect(delaySpy).toHaveBeenCalledWith(expect.any(Function), 1); + }); + + it('clamps oversized request timeouts to the maximum timer duration', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + mocks.authorizedApiFetch + .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), auth }) + .mockResolvedValueOnce({ + response: Response.json( + { + outcome: 'provisioned', + nodeId: 'node-1', + nodeName: 'daytona-codex', + sandboxId: 'sandbox-1', + providerSandboxId: DAYTONA_PROVIDER_SANDBOX_ID, + relayWorkspaceId: 'rw_abc', + relaycastTarget: CANONICAL_RELAYCAST_TARGET, + relayfileMounted: true, + providerId: 'daytona', + }, + { status: 201 } + ), + auth, + }); + + await ensureCloudFleetSandbox( + { workspaceId: 'rw_abc', requiredCapability: 'spawn:codex' }, + { timeoutMs: 2_147_483_648 } + ); + + expect(timeoutSpy).toHaveBeenNthCalledWith(1, 2_147_483_647); + expect(timeoutSpy).toHaveBeenNthCalledWith(2, 2_147_483_647); + }); + it('keeps the default provisioning budget beyond the mounted server deadline', async () => { const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); mocks.authorizedApiFetch diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index b89c1aa742..a698a92a59 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -340,12 +340,12 @@ function boundedSignal(options: CloudFleetSandboxRequestOptions, defaultTimeoutM } function normalizeTimerMs(value: number, allowZero: boolean, label: string): number { - if (!Number.isSafeInteger(value) || value < 0 || (!allowZero && value === 0) || value > MAX_TIMER_MS) { + if (!Number.isFinite(value) || value < 0 || (!allowZero && value === 0)) { throw new Error( - `${label} must be an integer between ${allowZero ? 0 : 1} and ${MAX_TIMER_MS} milliseconds.` + `${label} must be a finite ${allowZero ? 'non-negative' : 'positive'} number of milliseconds.` ); } - return value; + return value === 0 ? 0 : Math.min(MAX_TIMER_MS, Math.max(1, Math.floor(value))); } async function readJson(response: Response): Promise { From 0cb856eeb32ceb3c32c9e5a2091cf08a1de9eaa6 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Sun, 13 Sep 2026 16:51:06 +0200 Subject: [PATCH 40/41] ci: wait for published CLI tarballs before verification Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- .github/workflows/verify-publish.yml | 51 ++++++++++++++----- .../wait-for-package-tarballs.sh | 43 ++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) create mode 100755 scripts/post-publish-verify/wait-for-package-tarballs.sh diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index f85f963aa5..3ef045245b 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -72,12 +72,21 @@ jobs: echo "spec=$SPEC" >> $GITHUB_OUTPUT echo "Testing: $SPEC" - - name: Wait for package metadata + - name: Wait for package metadata and tarball if: inputs.version != 'latest' run: | - scripts/post-publish-verify/retry-command.sh \ - "Wait for npm metadata for agent-relay@${{ inputs.version }}" \ - npm view agent-relay@${{ inputs.version }} version + VERSION="${{ inputs.version }}" + scripts/post-publish-verify/wait-for-package-tarballs.sh \ + "${{ steps.pkg.outputs.spec }}" \ + "@agent-relay/cloud@$VERSION" \ + "@agent-relay/config@$VERSION" \ + "@agent-relay/fleet@$VERSION" \ + "@agent-relay/harness-driver@$VERSION" \ + "@agent-relay/harnesses@$VERSION" \ + "@agent-relay/sdk@$VERSION" \ + "@agent-relay/session@$VERSION" \ + "@agent-relay/utils@$VERSION" \ + "@agent-relay/broker-linux-x64@$VERSION" # Test 1: Global npm install - name: 'Test: Global npm install' @@ -298,12 +307,21 @@ jobs: echo "spec=$SPEC" >> $GITHUB_OUTPUT echo "Testing: $SPEC" - - name: Wait for package metadata + - name: Wait for package metadata and tarball if: inputs.version != 'latest' run: | - scripts/post-publish-verify/retry-command.sh \ - "Wait for npm metadata for agent-relay@${{ inputs.version }}" \ - npm view agent-relay@${{ inputs.version }} version + VERSION="${{ inputs.version }}" + scripts/post-publish-verify/wait-for-package-tarballs.sh \ + "${{ steps.pkg.outputs.spec }}" \ + "@agent-relay/cloud@$VERSION" \ + "@agent-relay/config@$VERSION" \ + "@agent-relay/fleet@$VERSION" \ + "@agent-relay/harness-driver@$VERSION" \ + "@agent-relay/harnesses@$VERSION" \ + "@agent-relay/sdk@$VERSION" \ + "@agent-relay/session@$VERSION" \ + "@agent-relay/utils@$VERSION" \ + "@agent-relay/broker-darwin-arm64@$VERSION" - name: 'Test: npm install' run: | @@ -390,12 +408,21 @@ jobs: with: node-version: '22.14.0' - - name: Wait for package metadata + - name: Wait for package metadata and tarball if: inputs.version != 'latest' run: | - scripts/post-publish-verify/retry-command.sh \ - "Wait for npm metadata for agent-relay@${{ inputs.version }}" \ - npm view agent-relay@${{ inputs.version }} version + VERSION="${{ inputs.version }}" + scripts/post-publish-verify/wait-for-package-tarballs.sh \ + "agent-relay@$VERSION" \ + "@agent-relay/cloud@$VERSION" \ + "@agent-relay/config@$VERSION" \ + "@agent-relay/fleet@$VERSION" \ + "@agent-relay/harness-driver@$VERSION" \ + "@agent-relay/harnesses@$VERSION" \ + "@agent-relay/sdk@$VERSION" \ + "@agent-relay/session@$VERSION" \ + "@agent-relay/utils@$VERSION" \ + "@agent-relay/broker-linux-x64@$VERSION" - name: Build verification image run: | diff --git a/scripts/post-publish-verify/wait-for-package-tarballs.sh b/scripts/post-publish-verify/wait-for-package-tarballs.sh new file mode 100755 index 0000000000..ec150476de --- /dev/null +++ b/scripts/post-publish-verify/wait-for-package-tarballs.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 ..." >&2 + exit 2 +fi + +ATTEMPTS="${RETRY_ATTEMPTS:-60}" +DELAY="${RETRY_DELAY_SECONDS:-10}" + +for attempt in $(seq 1 "$ATTEMPTS"); do + missing=() + for spec in "$@"; do + if ! npm view "$spec" version >/dev/null 2>&1; then + missing+=("$spec (metadata)") + continue + fi + + tarball_url="$(npm view "$spec" dist.tarball 2>/dev/null || true)" + if [ -z "$tarball_url" ]; then + missing+=("$spec (no tarball URL)") + continue + fi + status="$(curl -fsSIL --connect-timeout 5 --max-time 15 -o /dev/null -w '%{http_code}' "$tarball_url" || true)" + if [ "$status" != "200" ]; then + missing+=("$spec (tarball HTTP ${status:-000})") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + echo "npm metadata and tarballs are available: $*" + exit 0 + fi + + echo "[$attempt/$ATTEMPTS] waiting for npm propagation: ${missing[*]}" + if [ "$attempt" -lt "$ATTEMPTS" ]; then + sleep "$DELAY" + fi +done + +echo "Timed out waiting for npm metadata/tarballs: ${missing[*]}" >&2 +exit 1 From f78b9be64d9569cf0daa97a92842f80a93eacfb0 Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 15 Sep 2026 06:22:50 +0200 Subject: [PATCH 41/41] fix(cli): keep default agent spawns in the caller checkout Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 Session-Id: 01a09c42-6dcd-72a2-84b5-adf1d8e49fa6 --- CHANGELOG.md | 2 + packages/cli/README.md | 18 ++- .../commands/fleet-sandbox-regression.test.ts | 5 +- packages/cli/src/cli/commands/fleet.test.ts | 137 +++++++++++++++++- packages/cli/src/cli/commands/fleet.ts | 48 +++++- .../cli/src/cli/commands/local-agent.test.ts | 44 +++++- packages/cli/src/cli/commands/local-agent.ts | 10 +- 7 files changed, 251 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce1015204f..838ba6ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `fleet spawn` without placement options starts locally in the caller's exact + directory; `--auto-place` explicitly requests automatic fleet placement. - Plain `fleet spawn --sandbox` starts from a clean, pushed GitHub `HEAD`, mounts its decoded source tree and `.skills` through Relayfile, maps the caller's relative directory, and keeps the tree synchronized as GitHub changes flow diff --git a/packages/cli/README.md b/packages/cli/README.md index 5f5ade2e10..44e019ca06 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -185,7 +185,7 @@ when available and otherwise tells you to retry without `--background`; a child that already exited is no longer misreported as an unkillable half-started broker. -## Remote fleet agents +## Local and remote fleet agents The `fleet` command group lists and controls agents across all live nodes in the active project workspace: @@ -208,9 +208,12 @@ agent-relay fleet spawn codex \ --node sf-mini \ --session-ref -# Omit --node for automatic eligible-node placement. +# No placement options: use the local broker and this exact working directory. agent-relay fleet spawn codex --name api-worker --task "Review the current diff." +# Opt into automatic eligible-node placement. +agent-relay fleet spawn codex --name api-worker --task "Review the current diff." --auto-place + # Provision a fresh E2B node, require the current Relayfile workspace to mount # at /workspace, wait for readiness, then spawn Codex there. agent-relay fleet spawn codex \ @@ -238,6 +241,17 @@ set `RELAY_AGENT_TOKEN` to the token returned by `agent-relay agent register `. `fleet spawn --sandbox` needs a Cloud login (`agent-relay cloud login`) but does not need an agent token: when one is absent, it creates and removes a short-lived launcher identity automatically. + +Without placement options, `fleet spawn` connects to the local project's broker +and passes the caller's exact directory, including a nested package, to the +worker. Start the local broker with `agent-relay node up` if it is not running; +a local connection failure never falls back to remote placement. `--cwd` selects +a different local directory on this path. Model and channel options stay local. +`--auto-place`, `--node`, and `--sandbox` select remote placement explicitly. +Legacy invocations with an explicit `--workspace-key`/`--wk`, `--token`, +`--base-url`, or `--persona` retain automatic fleet placement when no node or +sandbox is selected. Ambient credentials and persisted Cloud routing do not +change the local default. Workforce reporting metadata requires remote placement. Automatic placement and release need only the workspace key. The sandbox path provisions a fresh hosted instance and makes the Relayfile diff --git a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts index 0ca56084f0..6a7aa5e235 100644 --- a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -119,8 +119,9 @@ describe('fleet sandbox command regressions', () => { const spawn = fleet?.commands.find((command) => command.name() === 'spawn'); const cwd = spawn?.options.find((option) => option.long === '--cwd'); - expect(cwd?.description).toMatch(/With --sandbox, a local repo-relative path/); - expect(cwd?.description).toMatch(/without --sandbox, use an absolute remote path/); + expect(cwd?.description).toContain('caller directory for local spawn'); + expect(cwd?.description).toContain('maps a local repo-relative path with --sandbox'); + expect(cwd?.description).toContain('selects a path on the remote node'); }); it('keeps the full /workspace Relayfile mount outside Git without materializing a repository', async () => { diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index 164614c5ee..e1414923d8 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { Command } from 'commander'; import { CloudFleetSandboxProvisionError } from '@agent-relay/cloud'; +import { HarnessDriverClient } from '@agent-relay/harness-driver'; import { defineNode, invokeNodeHandler, spawn as fleetSpawn } from '@agent-relay/fleet'; import { RelayPlacementError } from '@agent-relay/sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -22,6 +23,7 @@ vi.mock('../lib/broker-lifecycle.js', () => ({ vi.mock('@agent-relay/harness-driver', async (importOriginal) => ({ ...(await importOriginal()), HarnessDriverClient: class { + static connect = vi.fn(); async getSession() { return { workspace_key: 'rk_live_secret', @@ -3336,7 +3338,140 @@ describe('fleet command support', () => { expect(String(errors.join('\n'))).toContain('--confirm-timeout'); }); - it('fleet spawn uses workspace-scoped automatic placement when no node is named', async () => { + describe('local default spawn', () => { + function setup(useDefaultConnector = false) { + const cwd = path.resolve(os.tmpdir(), 'relay-local-fixture', 'packages', 'web'); + const projectRoot = path.resolve(cwd, '../..'); + const local = { spawnPty: vi.fn(async () => undefined), disconnect: vi.fn() }; + const connectLocalBroker = vi.fn(async () => local); + const remoteSpawn = vi.fn(async () => ({ invocation_id: 'inv_explicit_auto', status: 'accepted' })); + const createFleetWorkspaceClient = vi.fn(() => ({ agents: { spawn: remoteSpawn } })); + const ensureCloudFleetSandbox = vi.fn(); + const createWorkspaceRelay = vi.fn(); + const logs: string[] = []; + const errors: string[] = []; + const program = new Command(); + program.exitOverride(); + registerFleetCommands(program, { + cwd: () => cwd, + findProjectRoot: (directory) => { + expect(directory).toBe(cwd); + return projectRoot; + }, + ...(useDefaultConnector ? {} : { connectLocalBroker: connectLocalBroker as never }), + createFleetWorkspaceClient: createFleetWorkspaceClient as never, + ensureCloudFleetSandbox, + sdk: { + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: createWorkspaceRelay as never, + createWorkspace: vi.fn() as never, + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: vi.fn(() => { + throw new Error('__exit__'); + }) as never, + }, + warn: vi.fn(), + }); + const parse = (extra: string[] = []) => + program.parseAsync( + ['fleet', 'spawn', 'codex', '--name', 'local-worker', '--task', 'Inspect the checkout', ...extra], + { from: 'user' } + ); + return { + cwd, + projectRoot, + local, + connectLocalBroker, + createFleetWorkspaceClient, + remoteSpawn, + ensureCloudFleetSandbox, + createWorkspaceRelay, + logs, + errors, + parse, + }; + } + + it('uses the caller nested directory and local broker despite ambient hosted credentials', async () => { + vi.stubEnv('RELAY_WORKSPACE_KEY', 'rk_live_ambient_test'); + vi.stubEnv('RELAY_AGENT_TOKEN', 'at_live_ambient_test'); + vi.stubEnv('RELAY_BASE_URL', 'https://agent37-cast.agentrelay.com'); + const fixture = setup(); + await fixture.parse(); + expect(fixture.connectLocalBroker).toHaveBeenCalledWith(fixture.projectRoot); + expect(fixture.local.spawnPty).toHaveBeenCalledWith({ + name: 'local-worker', + cli: 'codex', + task: 'Inspect the checkout', + channels: ['general'], + cwd: fixture.cwd, + }); + expect(fixture.local.disconnect).toHaveBeenCalledOnce(); + expect(fixture.createFleetWorkspaceClient).not.toHaveBeenCalled(); + expect(fixture.createWorkspaceRelay).not.toHaveBeenCalled(); + expect(fixture.ensureCloudFleetSandbox).not.toHaveBeenCalled(); + expect(JSON.parse(fixture.logs[0]!)).toEqual({ + local: { name: 'local-worker', cli: 'codex', cwd: fixture.cwd }, + }); + }); + + it('ignores another checkout state directory when selecting the default local broker', async () => { + vi.stubEnv('AGENT_RELAY_STATE_DIR', '/tmp/unrelated-checkout/relay'); + const fixture = setup(true); + vi.mocked(HarnessDriverClient.connect).mockReturnValueOnce(fixture.local as never); + await fixture.parse(); + expect(HarnessDriverClient.connect).toHaveBeenLastCalledWith({ + cwd: fixture.projectRoot, + connectionPath: path.join(fixture.projectRoot, '.agentworkforce/relay/connection.json'), + }); + expect(fixture.local.spawnPty).toHaveBeenCalledWith(expect.objectContaining({ cwd: fixture.cwd })); + expect(fixture.createFleetWorkspaceClient).not.toHaveBeenCalled(); + }); + + it('keeps model and channel options local and resolves cwd relative to the caller', async () => { + const fixture = setup(); + await fixture.parse(['--cwd', '../core', '--model', 'gpt-5', '--channel', 'review']); + expect(fixture.local.spawnPty).toHaveBeenCalledWith({ + name: 'local-worker', + cli: 'codex', + task: 'Inspect the checkout', + channels: ['review'], + model: 'gpt-5', + cwd: path.resolve(fixture.cwd, '../core'), + }); + expect(fixture.createFleetWorkspaceClient).not.toHaveBeenCalled(); + }); + + it('never falls back to remote placement after a local connection or spawn failure', async () => { + const unavailable = setup(); + unavailable.connectLocalBroker.mockRejectedValue(new Error('Local broker is unavailable')); + await expect(unavailable.parse()).rejects.toThrow('__exit__'); + expect(unavailable.createFleetWorkspaceClient).not.toHaveBeenCalled(); + expect(unavailable.ensureCloudFleetSandbox).not.toHaveBeenCalled(); + const failedSpawn = setup(); + failedSpawn.local.spawnPty.mockRejectedValue(new Error('Local harness failed')); + await expect(failedSpawn.parse()).rejects.toThrow('__exit__'); + expect(failedSpawn.local.disconnect).toHaveBeenCalledOnce(); + expect(failedSpawn.createFleetWorkspaceClient).not.toHaveBeenCalled(); + }); + + it('requires explicit automatic placement and rejects conflicting placement options', async () => { + const automatic = setup(); + await automatic.parse(['--auto-place']); + expect(automatic.remoteSpawn).toHaveBeenCalledOnce(); + expect(automatic.connectLocalBroker).not.toHaveBeenCalled(); + for (const selection of [['--sandbox'], ['--node', 'sf-mini']]) { + const conflict = setup(); + await expect(conflict.parse(['--auto-place', ...selection])).rejects.toThrow('__exit__'); + expect(conflict.errors.join('\n')).toContain('--auto-place cannot be combined'); + expect(conflict.ensureCloudFleetSandbox).not.toHaveBeenCalled(); + expect(conflict.connectLocalBroker).not.toHaveBeenCalled(); + } + }); + }); + + it('fleet spawn preserves explicitly selected workspace automatic placement when no node is named', async () => { const spawn = vi.fn(async () => ({ invocation_id: 'inv_auto', status: 'accepted' })); const createFleetWorkspaceClient = vi.fn(() => ({ agents: { spawn } })); const logs: string[] = []; diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index 97b3d5fa9b..50ab4f2f3f 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -32,6 +32,8 @@ import { type RosterAgent, } from './fleet-agent.js'; import { readBrokerConnection } from '../lib/broker-lifecycle.js'; +import { spawnAgentWithClient } from '../lib/client-factory.js'; +import { connectProjectBrokerClient } from '../lib/project-broker-client.js'; import { isAvailableFleetNode } from '../lib/fleet-live-agents.js'; import { declaredWorkforceMetadata } from '../lib/registration-metadata.js'; import { redactSecrets } from '../lib/redact.js'; @@ -188,6 +190,8 @@ function throwForTerminalSpawnFailure(invocation: Record): void export interface FleetCommandDependencies { core: CoreDependencies; sdk: SdkCommandDeps; + cwd: () => string; + connectLocalBroker: (cwd: string) => Promise; createFleetWorkspaceClient: (options: SdkClientOptions) => RelayWorkspaceThinClient; resolveWorkspaceSelection: typeof resolveWorkspaceSelection; resolveSandboxRepository: typeof resolveSandboxRepository; @@ -209,6 +213,8 @@ function withFleetDefaults(overrides: Partial = {}): F return { core, sdk, + cwd: () => process.cwd(), + connectLocalBroker: async (cwd) => connectProjectBrokerClient(cwd), createFleetWorkspaceClient: (options) => { const { workspaceKey, baseUrl } = resolveWorkspaceTransport(options); return createWorkspaceClient({ workspaceKey, baseUrl }); @@ -299,10 +305,11 @@ export function registerFleetCommands( addSdkOptions( group .command('spawn') - .description('Spawn an agent on a fleet node') + .description('Spawn locally by default, or select a fleet node or Cloud sandbox explicitly') .argument('', 'AI CLI to launch', parseFleetCli) .requiredOption('--name ', 'Worker agent name') .requiredOption('--task ', 'Initial task instructions') + .option('--auto-place', 'Request automatic eligible-node placement in the Relay workspace') .option('--node ', 'Target a specific fleet node') .option('--target-node ', 'Alias for --node') .option( @@ -330,7 +337,7 @@ export function registerFleetCommands( .option('--model ', 'Model powering the worker') .option( '--cwd ', - 'Working directory. With --sandbox, a local repo-relative path resolves to the materialized source tree; without --sandbox, use an absolute remote path (relative paths are forwarded verbatim)' + 'Working directory: defaults to the caller directory for local spawn; maps a local repo-relative path with --sandbox; otherwise selects a path on the remote node' ) .option('--organization ', 'Declared organization for workforce reporting') .option('--project ', 'Declared project for workforce reporting') @@ -349,12 +356,23 @@ export function registerFleetCommands( ) ).action(async (cli: string, options: Record) => { await runSdk(deps.sdk, async () => { - warnIfInferredFromProjectSession(options, deps.warn); const clientOptions = sdkOptionsFromOpts(options); const name = requiredText(options.name, 'Worker name'); const task = requiredText(options.task, 'Task'); let targetNode = optionalText(options.targetNode, 'Target node') ?? optionalText(options.node, 'Node'); const useSandbox = options.sandbox === true; + // Explicit hosted credentials/transport and personas retain their legacy + // automatic-placement contract. Ambient credentials and a persisted + // Cloud target must never turn a flag-free local spawn into a remote one. + const automaticPlacement = + options.autoPlace === true || + ['workspaceKey', 'token', 'baseUrl', 'persona'].some((key) => options[key] !== undefined); + if (options.autoPlace === true && (useSandbox || targetNode)) { + throw new Error('--auto-place cannot be combined with --sandbox, --node, or --target-node.'); + } + if (useSandbox || targetNode || automaticPlacement) { + warnIfInferredFromProjectSession(options, deps.warn); + } const checkoutRepository = options.checkout === true; const sandboxName = optionalText(options.sandboxName, 'Sandbox name'); const sandboxIdOption = optionalText(options.sandboxId, 'Sandbox ID'); @@ -935,6 +953,30 @@ export function registerFleetCommands( throw new Error('--session-ref requires --node or --target-node.'); } const persona = optionalText(options.persona, 'Persona'); + if (!automaticPlacement) { + if (organization || project || workstream || role || objective) { + throw new Error('Workforce metadata requires --auto-place, --node, or --sandbox.'); + } + const callerCwd = deps.cwd(); + const localCwd = path.resolve(callerCwd, requestedCwd ?? '.'); + const local = await deps.connectLocalBroker(deps.findProjectRoot(callerCwd)); + try { + await spawnAgentWithClient(local, { + name, + cli, + task, + channels: [channel ?? 'general'], + ...(model ? { model } : {}), + // A broker can be shared by nested packages. Never inherit its + // startup directory when the caller requested a local checkout. + cwd: localCwd, + }); + printJson(deps.sdk, { local: { name, cli, cwd: localCwd } }); + } finally { + local.disconnect(); + } + return; + } const workspace = deps.createFleetWorkspaceClient(clientOptions); const invocation = await workspace.agents.spawn({ name, diff --git a/packages/cli/src/cli/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index 30816a511d..8ddba82891 100644 --- a/packages/cli/src/cli/commands/local-agent.test.ts +++ b/packages/cli/src/cli/commands/local-agent.test.ts @@ -1,3 +1,7 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + import { Command } from 'commander'; import { describe, expect, it, vi } from 'vitest'; @@ -897,7 +901,10 @@ describe('local agent subtree', () => { await program.parseAsync(['local', 'agent', 'list'], { from: 'user' }); - expect(harnessConnectMock).toHaveBeenCalledWith({ cwd: '/tmp/project' }); + expect(harnessConnectMock).toHaveBeenCalledWith({ + cwd: '/tmp/project', + connectionPath: '/tmp/project/.agentworkforce/relay/connection.json', + }); expect(client.listAgents).toHaveBeenCalled(); expect(log).toHaveBeenCalledWith('[]'); expect(client.disconnect).toHaveBeenCalled(); @@ -914,6 +921,41 @@ describe('local agent subtree', () => { ); }); + it('ignores stale state directories and connects to the enclosing checkout broker while spawning in a nested package', async () => { + vi.stubEnv('AGENT_RELAY_STATE_DIR', '/tmp/unrelated-checkout/relay'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'relay-local-nested-')); + const nested = path.join(root, 'packages', 'web'); + fs.mkdirSync(path.join(root, '.git')); + fs.mkdirSync(nested, { recursive: true }); + const client = { spawnPty: vi.fn(async () => undefined), disconnect: vi.fn() }; + harnessConnectMock.mockReturnValueOnce(client); + const program = new Command(); + program.exitOverride(); + registerLocalAgentCommands(program.command('local'), { cwd: () => nested, log: vi.fn() }); + try { + await program.parseAsync(['local', 'agent', 'spawn', 'codex'], { from: 'user' }); + expect(harnessConnectMock).toHaveBeenLastCalledWith({ + cwd: root, + connectionPath: path.join(root, '.agentworkforce/relay/connection.json'), + }); + expect(client.spawnPty).toHaveBeenCalledWith(expect.objectContaining({ cwd: nested })); + expect(client.disconnect).toHaveBeenCalledOnce(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(['spawn', 'new'])( + '%s passes the caller directory rather than the broker startup directory', + async (command) => { + const { program, client } = harness({ cwd: () => '/tmp/project/packages/web' }); + await program.parseAsync(['local', 'agent', command, 'codex', '--name', 'Nested'], { from: 'user' }); + expect(client.spawnPty).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Nested', cli: 'codex', cwd: '/tmp/project/packages/web' }) + ); + } + ); + it('release calls client.release', async () => { const { program, client } = harness(); await program.parseAsync(['local', 'agent', 'release', 'lead'], { from: 'user' }); diff --git a/packages/cli/src/cli/commands/local-agent.ts b/packages/cli/src/cli/commands/local-agent.ts index d46fad8f99..512894d4ef 100644 --- a/packages/cli/src/cli/commands/local-agent.ts +++ b/packages/cli/src/cli/commands/local-agent.ts @@ -1,7 +1,8 @@ import type { Command } from 'commander'; import { AGENT37_RELAYCAST_ORIGIN } from '@agent-relay/cloud'; -import { HarnessDriverClient } from '@agent-relay/harness-driver'; +import { findProjectRoot } from '@agent-relay/config'; +import type { HarnessDriverClient } from '@agent-relay/harness-driver'; import type { InboundDeliveryMode, ListAgent, PendingRelayMessage } from '@agent-relay/harness-driver'; import type { HarnessRuntime } from '@agent-relay/harnesses'; import { stripAnsiFast } from '@agent-relay/utils'; @@ -23,6 +24,7 @@ import { type BrokerConnectionOptions, } from '../lib/broker-connection.js'; import { resolvedSpawnRuntime, spawnAgentWithClient } from '../lib/client-factory.js'; +import { connectProjectBrokerClient } from '../lib/project-broker-client.js'; import { describeError } from '../lib/describe-error.js'; import { defaultExit } from '../lib/exit.js'; import { resolveFleetAttachTarget, type FleetAttachResolution } from '../lib/fleet-attach-target.js'; @@ -208,7 +210,7 @@ export interface LocalAgentDependencies { function withDefaults(overrides: Partial = {}): LocalAgentDependencies { const deps = { - connect: async (cwd: string) => HarnessDriverClient.connect({ cwd }), + connect: async (cwd: string) => connectProjectBrokerClient(findProjectRoot(cwd)), cwd: () => process.cwd(), readConnectionFile: readConnectionFileFromDisk, getDefaultStateDir: defaultStateDir, @@ -787,7 +789,7 @@ export function registerLocalAgentCommands( channels: (opts.channels as string[] | undefined) ?? ['general'], task: resolved.task, model: resolved.model, - cwd: opts.cwd as string | undefined, + cwd: (opts.cwd as string | undefined) ?? deps.cwd(), spawnMode, exitAfterTask: opts.exitAfterTask as boolean | undefined, runtime: runtime.requested, @@ -846,7 +848,7 @@ export function registerLocalAgentCommands( channels: (options.channels as string[] | undefined) ?? ['general'], task: resolved.task, model: resolved.model, - cwd: options.cwd as string | undefined, + cwd: (options.cwd as string | undefined) ?? deps.cwd(), spawnMode, exitAfterTask: options.exitAfterTask as boolean | undefined, runtime: runtime.requested,