diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d2e7dd9806..7e5a287e97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,37 @@ 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@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 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 + + - 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/.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/CHANGELOG.md b/CHANGELOG.md index 3c8e928e45..838ba6ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,23 @@ 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` 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 + 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; + ambiguous placements require `--node`. +- `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 757f2dd910..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 \ @@ -219,6 +222,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 @@ -235,11 +241,29 @@ 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 -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. 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 @@ -253,10 +277,115 @@ 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. -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: +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 +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. 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. + +`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`, 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 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. + +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 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 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 \ + --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. 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. + +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 +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. 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, 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, +never a URL. Nested packages share the repository pin; an existing subproject +pin or `AGENT_RELAY_PROJECT` remains an explicit workspace override. + +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-lifecycle-integration.test.ts b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts new file mode 100644 index 0000000000..5840fbf539 --- /dev/null +++ b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts @@ -0,0 +1,256 @@ +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.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 new file mode 100644 index 0000000000..6a7aa5e235 --- /dev/null +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -0,0 +1,330 @@ +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).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 () => { + 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 70d7d2d5d9..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', @@ -123,6 +125,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,13 +168,16 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: createWorkspaceRelay as never, 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, @@ -195,6 +201,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 +246,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 +293,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 +358,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 +444,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 +500,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 +543,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 +595,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 +640,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 +782,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 +855,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, @@ -923,116 +940,829 @@ describe('fleet command support', () => { relayfileMountPath: '/workspace', })); const deleteCloudFleetSandbox = vi.fn(async () => undefined); + const resolveSandboxRepository = vi.fn(() => undefined); const logs: string[] = []; const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository, + sdk: { + createAgentRelay: createAgentRelay 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: () => ({ + key: 'rk_live_test', + source: 'project', + origin: '/tmp/agent-relay-test/workspace-key.json', + workspaceId: 'rw_abc', + }), + persistWorkspaceRelaycastTarget: () => true, + deleteCloudFleetSandbox, + createFleetWorkspaceClient: vi.fn() as never, + log: () => undefined, + warn: () => undefined, + error: () => undefined, + }); + + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--sandbox-id', + REPLAY_SANDBOX_ID, + '--sandbox-name', + REPLAY_SANDBOX_NAME, + '--sandbox-relayfile-path', + '/live-review/run-123/**', + '--name', + 'sandbox-worker', + '--task', + 'Wait for VERIFY', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0]; + expect(ensureInput).toEqual({ + workspaceId: 'rw_abc', + requiredCapability: 'spawn:codex', + maxAgents: 1, + mountRelayfile: true, + relayfilePaths: ['/live-review/run-123/**'], + sandboxId: REPLAY_SANDBOX_ID, + forceProvision: true, + providerId: 'agent37', + workloadProfile: 'long-running-agent', + waitTimeoutMs: 90_000, + name: REPLAY_SANDBOX_NAME, + }); + expect(resolveSandboxRepository).toHaveBeenCalled(); + expect(ensureInput?.name).toBe(`fleet-sandbox-${ensureInput?.sandboxId?.slice('sbx_'.length)}`); + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringMatching(/^fleet-spawn-launcher-[a-f0-9]{8}$/), + metadata: { purpose: 'fleet-spawn-launcher' }, + }), + { strict: true } + ); + expect(createAgentRelay).toHaveBeenCalledWith({ + token: 'at_live_launcher', + baseUrl: 'https://agent37-cast.agentrelay.com', + }); + expect(placement.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'spawn:codex', + node: 'e2b-codex', + confirm: true, + input: expect.objectContaining({ + name: 'sandbox-worker', + task: 'Wait for VERIFY', + worker_cwd: '/workspace', + }), + }) + ); + expect(release).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringMatching(/^fleet-spawn-launcher-/), + deleteAgent: true, + }) + ); + expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); + expect(createWorkspaceRelay).toHaveBeenNthCalledWith(1, { + projectRoot: process.cwd(), + token: undefined, + workspaceKey: 'rk_live_agent37_target', + baseUrl: 'https://agent37-cast.agentrelay.com', + }); + expect(JSON.parse(logs[0]!)).toMatchObject({ + sandbox: { + sandboxId: 'sandbox-1', + providerSandboxId: 'provider-sandbox-1', + providerId: 'agent37', + nodeName: 'e2b-codex', + relayfileMountPath: '/workspace', + }, + invocation: { invocationId: 'inv_sandbox' }, + attachCommand: "agent-relay node agent attach 'sandbox-worker' --mode drive", + }); + }); + + 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, + sourceProfile: 'complete-v1' as const, + 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('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 = { + repository: 'AgentWorkforce/cloud', + repositoryName: 'cloud', + revision, + projectRoot: '/local/cloud', + repositoryRelativeCwd: 'packages/web', + workerCwd: '/srv/agent-workforce/cloud/packages/web', + }; + const resolveSandboxRepository = vi.fn(() => repositorySelection); + const findProjectRoot = vi.fn(() => "/local/cloud/packages/web team's"); + 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, + findProjectRoot, + 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', + '--checkout', + '--sandbox-provider', + 'agent37', + '--workspace-id', + 'rw_abc', + '--cwd', + 'packages/web', + '--name', + 'cloud-worker', + '--task', + 'Review the Cloud web package', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ); + + 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 team's" }); + 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( + "cd '/local/cloud/packages/web team'\\''s' && agent-relay node agent attach 'cloud-worker' --mode drive" + ); + }); + + 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(() => ({ + repository: 'AgentWorkforce/legacyprovider', + repositoryName: 'legacyprovider', + revision, + projectRoot: '/actual/legacyprovider', + repositoryRelativeCwd: 'packages/web', + 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', + '--checkout', + '--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' }], + ] as const)( + '--checkout 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', + repositoryRelativeCwd: 'packages/web', + 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', + '--checkout', + '--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('--checkout 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', + '--checkout', + '--name', + 'cloud-worker', + '--task', + 'Work', + '--workspace-key', + 'rk_live_test', + ], + { from: 'user' } + ) + ).rejects.toThrow('__exit__'); + 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: createAgentRelay as never, - createWorkspaceRelay: createWorkspaceRelay as never, + createAgentRelay: vi.fn() as never, + createWorkspaceRelay: vi.fn() as never, createWorkspace: vi.fn() as never, - log: (message: unknown) => logs.push(String(message)), - error: vi.fn(), - exit: vi.fn() as never, + log: vi.fn(), + error: (message: unknown) => errors.push(String(message)), + exit: (() => { + throw new Error('__exit__'); + }) as never, }, - ensureCloudFleetSandbox, - resolveWorkspaceSelection: () => ({ - key: 'rk_live_test', - source: 'project', - origin: '/tmp/agent-relay-test/workspace-key.json', - workspaceId: 'rw_abc', - }), - persistWorkspaceRelaycastTarget: () => true, - deleteCloudFleetSandbox, + ensureCloudFleetSandbox: ensureCloudFleetSandbox as never, + 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-id', - REPLAY_SANDBOX_ID, - '--sandbox-name', - REPLAY_SANDBOX_NAME, - '--sandbox-relayfile-path', - '/live-review/run-123/**', - '--name', - 'sandbox-worker', - '--task', - 'Wait for VERIFY', - '--workspace-key', - 'rk_live_test', - ], - { from: 'user' } - ); - const ensureInput = ensureCloudFleetSandbox.mock.calls[0]?.[0]; - expect(ensureInput).toEqual({ - workspaceId: 'rw_abc', - requiredCapability: 'spawn:codex', - maxAgents: 1, - mountRelayfile: true, - relayfilePaths: ['/live-review/run-123/**'], - sandboxId: REPLAY_SANDBOX_ID, - forceProvision: true, - providerId: 'agent37', - workloadProfile: 'long-running-agent', - waitTimeoutMs: 90_000, - name: REPLAY_SANDBOX_NAME, - }); - expect(ensureInput?.name).toBe(`fleet-sandbox-${ensureInput?.sandboxId?.slice('sbx_'.length)}`); - expect(register).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.stringMatching(/^fleet-spawn-launcher-[a-f0-9]{8}$/), - metadata: { purpose: 'fleet-spawn-launcher' }, - }), - { strict: true } - ); - expect(createAgentRelay).toHaveBeenCalledWith({ - token: 'at_live_launcher', - baseUrl: 'https://agent37-cast.agentrelay.com', - }); - expect(placement.spawn).toHaveBeenCalledWith( - expect.objectContaining({ - capability: 'spawn:codex', - node: 'e2b-codex', - confirm: true, - input: expect.objectContaining({ - name: 'sandbox-worker', - worker_cwd: '/workspace', - }), - }) - ); - expect(release).toHaveBeenCalledWith( - expect.objectContaining({ - name: expect.stringMatching(/^fleet-spawn-launcher-/), - deleteAgent: true, - }) - ); - expect(deleteCloudFleetSandbox).not.toHaveBeenCalled(); - expect(createWorkspaceRelay).toHaveBeenNthCalledWith(1, { - workspaceKey: 'rk_live_agent37_target', - baseUrl: 'https://agent37-cast.agentrelay.com', - }); - expect(JSON.parse(logs[0]!)).toMatchObject({ - sandbox: { - sandboxId: 'sandbox-1', - providerSandboxId: 'provider-sandbox-1', - providerId: 'agent37', - nodeName: 'e2b-codex', - 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'", - }); + 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 () => { @@ -1058,6 +1788,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, @@ -1103,6 +1834,8 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: AGENT37_RELAYCAST_TARGET.relaycastApiKey, baseUrl: AGENT37_RELAYCAST_TARGET.baseUrl, }); @@ -1113,6 +1846,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 +1957,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1184,6 +2003,8 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: CANONICAL_RELAYCAST_TARGET.relaycastApiKey, baseUrl: CANONICAL_RELAYCAST_TARGET.baseUrl, }); @@ -1222,6 +2043,7 @@ describe('fleet command support', () => { const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: createAgentRelay as never, createWorkspaceRelay: createWorkspaceRelay as never, @@ -1279,6 +2101,7 @@ describe('fleet command support', () => { ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), workspaceKey: 'rk_live_test', token: undefined, baseUrl: undefined, @@ -1306,6 +2129,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 +2215,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 +2307,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, @@ -1526,6 +2352,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, @@ -1546,6 +2373,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 +2434,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 +2509,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, @@ -1721,6 +2551,8 @@ describe('fleet command support', () => { expect.objectContaining({ workspaceId: cloudWorkspaceId }) ); expect(createWorkspaceRelay).toHaveBeenCalledWith({ + projectRoot: process.cwd(), + token: undefined, workspaceKey: target.relaycastApiKey, baseUrl: target.baseUrl, }); @@ -1741,6 +2573,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 +2664,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 +2723,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 +2781,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 +2834,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 +2893,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 +2909,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 +2956,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 +2967,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 +3028,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 +3037,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 +3100,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 +3165,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 +3250,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 +3298,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, @@ -2504,13 +3338,147 @@ 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[] = []; const program = new Command(); program.exitOverride(); registerFleetCommands(program, { + resolveSandboxRepository: () => undefined, sdk: { createAgentRelay: vi.fn() as never, createWorkspaceRelay: vi.fn() as never, @@ -2598,6 +3566,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 +3616,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 +3672,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 +3756,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 +3820,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 +3859,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 +3901,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 +3938,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..50ab4f2f3f 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -1,11 +1,16 @@ 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, ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, + resolveWorkspaceByKey, type CloudFleetSandboxProviderId, + type CloudRelayfileRepositoryMaterialization, type EnsureCloudFleetSandboxResult, } from '@agent-relay/cloud'; import { HarnessDriverClient } from '@agent-relay/harness-driver'; @@ -27,10 +32,13 @@ 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'; 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 +69,80 @@ 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( + `Sandbox node '${sandbox.nodeName}' did not become ready within ${sandbox.waitedMs}ms; the repository revision was not verified.` + ); + } + 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}.`); + } +} + +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)) + ); +} + +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` @@ -108,10 +190,16 @@ 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; + findProjectRoot: typeof findProjectRoot; + resolveWorkspaceByKey: typeof resolveWorkspaceByKey; persistWorkspaceRelaycastTarget: typeof persistWorkspaceRelaycastTarget; ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; + materializeCloudRelayfileRepository: typeof materializeCloudRelayfileRepository; deleteCloudFleetSandbox: typeof deleteCloudFleetSandbox; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; @@ -125,13 +213,19 @@ 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 }); }, resolveWorkspaceSelection, + resolveSandboxRepository, + findProjectRoot, + resolveWorkspaceByKey, persistWorkspaceRelaycastTarget, ensureCloudFleetSandbox, + materializeCloudRelayfileRepository, deleteCloudFleetSandbox, log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), @@ -211,15 +305,20 @@ 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( '--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', + 'Materialize the current Git checkout at its exact pushed HEAD (static; requires --sandbox)' ) .option( '--sandbox-name ', @@ -236,7 +335,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 ', + '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') .option('--workstream ', 'Declared workstream for workforce reporting') @@ -254,14 +356,30 @@ 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'); + // 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.'); @@ -286,6 +404,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.'); } @@ -306,7 +427,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'); @@ -324,27 +446,95 @@ 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; let legacyWorkspaceClientOptions = clientOptions; if (useSandbox) { + const coreProjectRoot = deps.core.getProjectPaths().projectRoot; + const hasExplicitProjectOverride = Boolean( + deps.core.env?.AGENT_RELAY_PROJECT?.trim() || process.env.AGENT_RELAY_PROJECT?.trim() + ); + if (checkoutRepository || mountSandboxRelayfile) { + // AGENT_RELAY_PROJECT selects the workspace namespace, while static + // 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 (checkoutRepository && !sandboxRepository) { + throw new Error('--checkout requires a GitHub checkout with a clean, pushed commit.'); + } + 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 = + sandboxRepository && + requestedCwd && + !/^\/(?:srv\/agent-workforce|workspace)(?:\/|$)/.test(requestedCwd) + ? path.resolve(process.cwd(), requestedCwd) + : undefined; + // 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 + ? deps.findProjectRoot(localRequestedCwd) + : sandboxRepository && pathContains(sandboxRepository.projectRoot, coreProjectRoot) + ? coreProjectRoot + : (sandboxRepository?.projectRoot ?? coreProjectRoot); + if (path.resolve(workspaceProjectRoot) !== path.resolve(coreProjectRoot)) { + attachProjectRoot = workspaceProjectRoot; + } + 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); + const workspaceSelection = deps.resolveWorkspaceSelection({ + ...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 // 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,10 +545,24 @@ 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.' ); } + 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, + 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)}`; @@ -381,29 +585,32 @@ 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, 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 }), + ...(checkoutRepository && sandboxRepository ? { repos: [sandboxRepository.repository] } : {}), + ...(checkoutRepository && sandboxRepository + ? { repoRevisions: { [sandboxRepository.repository]: sandboxRepository.revision } } + : {}), }); + assertSandboxRepositoryRevision(sandbox, checkoutRepository ? sandboxRepository : undefined); } catch (error) { if ( + shouldCleanupSandbox && error instanceof CloudFleetSandboxProvisionError && error.confirmedProvisioned && error.cloudWorkspaceId && @@ -431,6 +638,7 @@ export function registerFleetCommands( } so a sandbox is not left running.` ); } else if ( + shouldCleanupSandbox && error instanceof CloudFleetSandboxProvisionError && error.cloudWorkspaceId && error.sandboxId @@ -449,6 +657,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) { @@ -474,7 +697,7 @@ export function registerFleetCommands( ); } relaycastClientOptions = { - ...clientOptions, + ...relaycastClientOptions, workspaceKey: target.relaycastApiKey, baseUrl: target.baseUrl, }; @@ -496,7 +719,7 @@ export function registerFleetCommands( ); } } catch (error) { - if (sandbox.outcome === 'provisioned') { + if (shouldCleanupSandbox && sandbox.outcome === 'provisioned') { await deps .deleteCloudFleetSandbox({ cloudWorkspaceId: sandbox.cloudWorkspaceId, @@ -521,19 +744,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 +767,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, @@ -560,6 +785,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'; } @@ -610,6 +848,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, @@ -619,7 +871,16 @@ export function registerFleetCommands( input: { name, cli, - task, + task: + 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.` + : liveSandboxContext + ? `${task}\n\n${liveSandboxContext}` + : task, ...(channel ? { channels: [channel] } : {}), ...(model ? { model } : {}), ...(workerCwd ? { worker_cwd: workerCwd } : {}), @@ -642,19 +903,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: sandboxAttachCommand(name, attachProjectRoot), } : {}), invocation: spawnInvocationWithMergedPlacement(invocation as unknown as Record), }); } catch (error) { if ( + shouldCleanupSandbox && sandbox?.outcome === 'provisioned' && !(error instanceof RelayPlacementError && error.state === 'unconfirmed_may_be_running') ) { @@ -697,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, @@ -829,6 +1109,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/commands/local-agent.test.ts b/packages/cli/src/cli/commands/local-agent.test.ts index 8e827dc12d..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'; @@ -82,6 +86,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'], { @@ -874,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(); @@ -891,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' }); @@ -934,6 +999,53 @@ 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) => { + 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( + 'To target the local broker instead, use --broker-url / --api-key or read connection.json from --state-dir' + ) + ); + 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 9f3ae3599b..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,8 +24,10 @@ 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'; import { redeemJoinTicket } from '../lib/join-ticket.js'; import { describeClearedEnrollment, persistWorkspaceSession } from '../lib/workspace-session.js'; @@ -191,6 +194,7 @@ export interface LocalAgentDependencies { node: string, options: FleetNodeAttachCliOptions ) => Promise; + resolveFleetAttachTarget: (name: string) => Promise; cwd: () => string; readConnectionFile: (stateDir: string) => unknown; getDefaultStateDir: () => string; @@ -206,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, @@ -217,6 +221,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, @@ -528,6 +533,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; @@ -618,7 +636,27 @@ 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; + if (!node && opts.workspaceKey !== undefined) { + 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; + } + let targetBaseUrl: string | undefined; + if (!node && !hasLocalBrokerSelection(deps, opts)) { + 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 +688,7 @@ async function withDeliveryModeClient( mode: sessionMode, env: deps.env, fetch: deps.fetch, + ...(targetBaseUrl ? { baseUrl: targetBaseUrl } : {}), ...(workspaceKey ? { workspaceKey } : {}), }); return await run( @@ -750,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, @@ -809,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, @@ -968,6 +1007,33 @@ 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 (!hasLocalBrokerSelection(deps, options)) { + 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/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/fleet-attach-target.test.ts b/packages/cli/src/cli/lib/fleet-attach-target.test.ts new file mode 100644 index 0000000000..0f1ec29e36 --- /dev/null +++ b/packages/cli/src/cli/lib/fleet-attach-target.test.ts @@ -0,0 +1,95 @@ +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-id', 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('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', + (() => { + 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..0253485a4f --- /dev/null +++ b/packages/cli/src/cli/lib/fleet-attach-target.ts @@ -0,0 +1,85 @@ +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 || + selection?.relaycastApiKey || + selection?.relaycastApiKeyRef + ); + 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.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/sandbox-repo.test.ts b/packages/cli/src/cli/lib/sandbox-repo.test.ts new file mode 100644 index 0000000000..0ce8c88e95 --- /dev/null +++ b/packages/cli/src/cli/lib/sandbox-repo.test.ts @@ -0,0 +1,324 @@ +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('--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'; + 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', + repositoryRelativeCwd: 'packages/cli', + 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 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[]) => { + 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 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'; + 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('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[]) => { + 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('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( + 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, + repositoryRelativeCwd: 'packages/web', + 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..2ff16c1973 --- /dev/null +++ b/packages/cli/src/cli/lib/sandbox-repo.ts @@ -0,0 +1,266 @@ +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; + /** Caller location beneath projectRoot, using portable `/` separators. */ + repositoryRelativeCwd: 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.` + ); + } + } 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); + 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, + repositoryRelativeCwd: relativePosix, + workerCwd: remoteCwd ?? (relativePosix ? `${remoteRoot}/${relativePosix}` : remoteRoot), + }; +} + +function isGeneratedRelayMetadata(entry: string): boolean { + const normalized = entry.replaceAll('\\', '/'); + 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' || + 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) { + // 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 { + 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..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'); @@ -206,6 +228,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 = { @@ -224,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) => { @@ -254,6 +309,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 +338,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 +411,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 +419,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..fe9bf17169 100644 --- a/packages/cli/src/cli/lib/sdk-client.ts +++ b/packages/cli/src/cli/lib/sdk-client.ts @@ -15,6 +15,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,9 +42,12 @@ export type WorkspaceTransport = { /** Resolve the selected key and any previously persisted Relay workspace identity. */ export function resolveWorkspaceSelection(options: SdkClientOptions = {}): WorkspaceSelection | undefined { + const explicitProject = trimOrUndefined(env(options).AGENT_RELAY_PROJECT); + const projectRoot = explicitProject ? path.resolve(explicitProject) : options.projectRoot; return resolveCloudWorkspaceSelection({ workspaceKey: options.workspaceKey, env: env(options), + ...(projectRoot ? { projectRoot } : {}), }); } @@ -77,6 +82,7 @@ function selectionForTransport(options: SdkClientOptions): WorkspaceSelection | relaycastRoute: _relaycastRoute, relaycastBaseUrl: _relaycastBaseUrl, relaycastApiKey: _relaycastApiKey, + relaycastApiKeyRef: _relaycastApiKeyRef, ...canonicalSelection } = selection; return canonicalSelection; @@ -125,8 +131,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/cli/src/cli/lib/workspace-session.test.ts b/packages/cli/src/cli/lib/workspace-session.test.ts index 7535553f34..c94a658649 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'); @@ -212,6 +254,7 @@ describe('workspace session persistence', () => { relaycastRoute: 'canonical', relaycastBaseUrl: 'https://cast.agentrelay.com', relaycastApiKey: 'rk_live_stale_canonical', + env, }); persistWorkspaceSession({ @@ -224,12 +267,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/auth.test.ts b/packages/cloud/src/auth.test.ts index d7e5c6d5f2..7b294546aa 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -855,6 +855,94 @@ 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', + 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 2338b80218..609dabe89f 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); @@ -701,7 +712,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; @@ -727,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); @@ -743,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, @@ -760,6 +789,7 @@ function createCloudSession(auth: StoredAuth, options: { refreshTimeoutMs?: numb force: refreshOptions.force, refreshTimeoutMs: options.refreshTimeoutMs, signal: refreshOptions.signal, + validateApiUrl: options.validateApiUrl, }) ); } @@ -816,9 +846,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 +864,7 @@ export async function authorizedApiFetch( force: true, refreshTimeoutMs: options.refreshTimeoutMs, signal: init.signal ?? undefined, + validateApiUrl: options.validateApiUrl, }); activeAuth = refreshableAuth; } catch (error) { @@ -860,6 +894,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/credential-directory-windows.native.test.ts b/packages/cloud/src/credential-directory-windows.native.test.ts new file mode 100644 index 0000000000..209bcfe86d --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.native.test.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +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 directory under the user profile without changing its ACL', () => { + directory = fs.mkdtempSync(path.join(os.homedir(), '.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.homedir(), '.relay-acl-native-unsafe-')); + expect(() => assertWindowsCredentialDirectory(directory!)).not.toThrow(); + 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' + ); + }); + + it('rejects an untrusted generic-all grant on the credential directory itself', () => { + 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, + }); + 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 new file mode 100644 index 0000000000..e3b42c7258 --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.test.ts @@ -0,0 +1,108 @@ +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(); + vi.unstubAllEnvs(); + 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', () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }); + assertWindowsCredentialDirectory('/tmp/relay-credentials'); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + 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('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) }), + encoding: 'utf8', + timeout: expect.any(Number), + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const script = String(args[args.length - 1]); + 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'); + 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'); + }); + + 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' + ); + }); + + 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 new file mode 100644 index 0000000000..9032af24b7 --- /dev/null +++ b/packages/cloud/src/credential-directory-windows.ts @@ -0,0 +1,182 @@ +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 = [IO.DirectoryInfo]::new($leafPath) + if (-not $leaf.Exists) { + Emit-Failure 'credential-parent-not-directory' + exit 0 + } + + $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-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 = $null + if ($value -match '^S-\d-(?:\d+-){1,}\d+$') { + $sid = ([Security.Principal.SecurityIdentifier]::new($value)).Value + } else { + $sid = ([Security.Principal.NTAccount]::new($value)).Translate([Security.Principal.SecurityIdentifier]).Value + } + # 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 } + return $sid + } catch { + return $null + } + } + + function Is-Trusted([string]$Sid) { + return $null -ne $Sid -and $trustedSids.Contains($Sid) + } + + $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 + # 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]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 + # 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 + $ancestorRiskMask = $ancestorRiskMask -bor [int64]268435456 # GENERIC_ALL + + $cursor = $leaf + while ($null -ne $cursor) { + if (($cursor.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + Emit-Failure 'credential-parent-reparse-point' + exit 0 + } + + $acl = $cursor.GetAccessControl() + $ownerSid = Resolve-IdentitySid $acl.Owner '' + if (-not (Is-Trusted $ownerSid)) { + Emit-Failure 'credential-parent-untrusted-owner' + exit 0 + } + + $isLeaf = [StringComparer]::OrdinalIgnoreCase.Equals($cursor.FullName, $leaf.FullName) + foreach ($entry in $acl.Access) { + if ($entry.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + # 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 + } + $rightsValue = ([int64]$entry.FileSystemRights -band 4294967295) + $riskMask = if ($isLeaf) { $leafRiskMask } else { $ancestorRiskMask } + if (($rightsValue -band $riskMask) -ne 0) { + 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); + 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, + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_ACL_SCRIPT], + { + input: JSON.stringify({ directory: absoluteDirectory }), + encoding: 'utf8', + timeout: WINDOWS_ACL_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 64 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + } 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(); + } +} diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 4807c2cd5c..d90e1800f4 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,161 @@ 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: 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', + exportParams: { format: 'tar', decode: 'github-working-tree', gzip: false }, + }, + }, + }), + auth: refreshedAuth, + }); + + await expect( + materializeCloudRelayfileRepository( + { + workspaceId: 'rw_abc', + repository: 'AgentWorkforce/cloud', + revision, + }, + { pollIntervalMs: 1 } + ) + ).resolves.toEqual({ + cloudWorkspaceId: CLOUD_WORKSPACE_ID, + repository: 'AgentWorkforce/cloud', + revision, + filesWritten: 0, + sourceProfile: 'complete-v1', + 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', + sourceProfile: 'complete-v1', + }); + 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: 1 } + ) + ).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, + sourceProfile: 'complete-v1', + materialization: { + mode: 'relayfile_export', + sourceProfile: 'complete-v1', + 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: 1 } + ) + ).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({ @@ -857,6 +1013,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 +1028,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 +1040,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({ @@ -918,6 +1125,63 @@ 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 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 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( + 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({ @@ -1608,6 +1872,108 @@ describe('Cloud fleet sandbox client', () => { expect(signals[1]?.aborted).toBe(false); }); + it.each([ + ['zero poll interval', { pollIntervalMs: 0 }], + ['infinite poll interval', { pollIntervalMs: Number.POSITIVE_INFINITY }], + ['negative request timeout', { timeoutMs: -1 }], + ])('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('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 6b58151ed8..a698a92a59 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -25,6 +25,10 @@ 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; +const MAX_TIMER_MS = 2_147_483_647; +const LIVE_RELAYFILE_SOURCE_PROFILE = 'complete-v1' as const; export type CloudFleetSandboxRequestOptions = { apiUrl?: string; @@ -32,6 +36,29 @@ 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; + sourceProfile: typeof LIVE_RELAYFILE_SOURCE_PROFILE; + contentRoot: string; + sentinelPath: string; +}; + +export type CloudRelayfileRepositoryMaterializeOptions = CloudFleetSandboxRequestOptions & { + pollIntervalMs?: number; +}; + export type CloudFleetSandboxProviderId = | 'daytona' | 'e2b' @@ -108,6 +135,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 +166,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 +191,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 = { @@ -297,14 +330,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.isFinite(value) || value < 0 || (!allowZero && value === 0)) { + throw new Error( + `${label} must be a finite ${allowZero ? 'non-negative' : 'positive'} number of milliseconds.` + ); + } + return value === 0 ? 0 : Math.min(MAX_TIMER_MS, Math.max(1, Math.floor(value))); +} + async function readJson(response: Response): Promise { try { return await response.json(); @@ -342,6 +385,141 @@ 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 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(); + const seenCheckoutNames = new Set(); + for (const repo of repos) { + const normalizedRepo = repo.toLowerCase(); + if (seen.has(normalizedRepo)) { + throw new Error('Cloud fleet sandbox repositories must not contain duplicates.'); + } + 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); + } +} + +function validateRepoRevisions( + repos: readonly string[] | undefined, + repoRevisions: Readonly> | undefined +): Record | undefined { + if (repoRevisions === undefined) return undefined; + const entries = Object.entries(repoRevisions); + const allowedRepos = new Set(repos ?? []); + 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).' + ); + } + 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 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; + 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 +654,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 +687,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 +705,7 @@ function normalizeEnsureResult( ...(relaycastTarget === undefined ? {} : { relaycastTarget }), relayfileMounted: payload.relayfileMounted, ...(providerId === undefined ? {} : { providerId }), + ...(repoRevisions === undefined ? {} : { repoRevisions }), ...(readString(payload, 'relayfileMountPath') ? { relayfileMountPath: readString(payload, 'relayfileMountPath') } : {}), @@ -535,6 +716,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 +727,7 @@ function normalizeEnsureResult( maxAgents: readNumber(payload, 'maxAgents') ?? null, ...(providerId === undefined ? {} : { providerId }), ...(relaycastTarget === undefined ? {} : { relaycastTarget }), + ...(repoRevisions === undefined ? {} : { repoRevisions }), }; } @@ -573,6 +756,143 @@ 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 = normalizeTimerMs( + options.pollIntervalMs ?? DEFAULT_RELAYFILE_REPOSITORY_POLL_INTERVAL_MS, + false, + 'Cloud Relayfile repository poll interval' + ); + + 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', + sourceProfile: LIVE_RELAYFILE_SOURCE_PROFILE, + }), + }, + { 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} 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.` + ) + ); + } + 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 sourceProfile = readString(job, 'sourceProfile'); + const materialization = job.materialization; + if ( + jobOwner !== owner || + jobRepo !== repo || + jobRef?.toLowerCase() !== revision || + headSha !== revision || + 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 || + 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, + sourceProfile: LIVE_RELAYFILE_SOURCE_PROFILE, + ...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, @@ -586,6 +906,8 @@ 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({ apiUrl: options.apiUrl || defaultApiUrl(), @@ -615,6 +937,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 +1011,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..6784283885 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -75,10 +75,16 @@ 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, + materializeCloudRelayfileRepository, deleteCloudFleetSandbox, CloudFleetSandboxProvisionError, normalizeRelaycastTarget, @@ -86,6 +92,9 @@ export { AGENT37_RELAYCAST_ORIGIN, type EnsureCloudFleetSandboxInput, type EnsureCloudFleetSandboxResult, + type MaterializeCloudRelayfileRepositoryInput, + type CloudRelayfileRepositoryMaterialization, + type CloudRelayfileRepositoryMaterializeOptions, type CloudFleetSandboxReady, type CloudFleetSandboxReused, type CloudFleetSandboxProvisioningTimeout, @@ -148,6 +157,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..3977240d1a 100644 --- a/packages/cloud/src/project-workspace-key.test.ts +++ b/packages/cloud/src/project-workspace-key.test.ts @@ -10,6 +10,8 @@ import { readProjectWorkspaceSession, resolveWorkspaceKeyWithSource, resolveWorkspaceSelection, + writeProjectWorkspaceTargetIfSelectionCurrent, + writeProjectWorkspaceKeyPreservingSession, writeProjectWorkspaceKey, } from './project-workspace-key.js'; import { setWorkspaceKey } from './workspace-store.js'; @@ -36,6 +38,162 @@ 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.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', + 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; + 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; @@ -153,6 +311,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); @@ -185,27 +363,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..90cc9992d2 100644 --- a/packages/cloud/src/project-workspace-key.ts +++ b/packages/cloud/src/project-workspace-key.ts @@ -4,7 +4,14 @@ import path from 'node:path'; import { getProjectPaths } from '@agent-relay/config'; -import { readWorkspaceStore, workspaceStorePath } from './workspace-store.js'; +import { + readRelaycastCredential, + readWorkspaceStore, + relaycastCredentialRef, + relaycastCredentialStorePath, + writeRelaycastCredential, + workspaceStorePath, +} from './workspace-store.js'; const PROJECT_WORKSPACE_KEY_FILENAME = 'workspace-key.json'; const PROJECT_WORKSPACE_LOCK_SUFFIX = '.lock'; @@ -38,6 +45,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,10 +84,13 @@ 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. */ 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`. */ @@ -89,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'); @@ -111,7 +125,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, env) + : undefined; + const relaycastApiKey = relaycastApiKeyRef + ? storedCredential && + storedCredential.workspaceId === workspaceId && + storedCredential.route === relaycastRoute && + storedCredential.baseUrl === relaycastBaseUrl + ? storedCredential.apiKey + : undefined + : legacyApiKey; return { workspaceKey, ...(enrolledNodeId ? { enrolledNodeId } : {}), @@ -119,6 +150,7 @@ export function readProjectWorkspaceSession( ...(relaycastRoute ? { relaycastRoute } : {}), ...(relaycastBaseUrl ? { relaycastBaseUrl } : {}), ...(relaycastApiKey ? { relaycastApiKey } : {}), + ...(relaycastApiKeyRef ? { relaycastApiKeyRef } : {}), }; } catch { return undefined; @@ -132,23 +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; - } = {} + 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; @@ -157,6 +185,20 @@ 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, + }, + credentialEnv + ); + } 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 +210,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 @@ -344,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 && @@ -353,20 +400,42 @@ 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; } - writeProjectWorkspaceKeyUnlocked(dataDir, selection.key, { - ...(current?.enrolledNodeId ? { enrolledNodeId: current.enrolledNodeId } : {}), - workspaceId: target.workspaceId, - relaycastRoute: target.relaycastRoute, - relaycastBaseUrl: target.relaycastBaseUrl, - relaycastApiKey: target.relaycastApiKey, - }); + const credentialRef = relaycastCredentialRef( + dataDir, + target.workspaceId, + target.relaycastRoute, + target.relaycastBaseUrl + ); + 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; }); } @@ -384,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 ? { @@ -399,23 +469,34 @@ export function writeProjectWorkspaceKeyPreservingSession( ...(existing?.relaycastRoute ? { relaycastRoute: existing.relaycastRoute } : {}), ...(existing?.relaycastBaseUrl ? { relaycastBaseUrl: existing.relaycastBaseUrl } : {}), ...(existing?.relaycastApiKey ? { relaycastApiKey: existing.relaycastApiKey } : {}), + ...(existing?.relaycastApiKeyRef ? { relaycastApiKeyRef: existing.relaycastApiKeyRef } : {}), } : {}; - 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) } - : {}), - }); + 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 + ); }); } @@ -441,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 } @@ -459,6 +543,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 } : {}), @@ -472,6 +559,7 @@ export function resolveWorkspaceSelection( key: envKey, source: 'env', origin: `$${name}`, + ...credentialHomeSelection, ...(project?.workspaceKey === envKey && project.workspaceId ? { workspaceId: project.workspaceId } : {}), @@ -484,6 +572,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 } : {}), @@ -496,10 +587,12 @@ 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 } : {}), ...(project.relaycastApiKey ? { relaycastApiKey: project.relaycastApiKey } : {}), + ...(project.relaycastApiKeyRef ? { relaycastApiKeyRef: project.relaycastApiKeyRef } : {}), ...(dataDir ? { projectDataDir: dataDir, projectSessionPresent: true } : {}), }; } @@ -527,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; @@ -546,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 { 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..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 = { @@ -281,8 +283,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/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..66d41b700c 100644 --- a/packages/cloud/src/workspace-store.test.ts +++ b/packages/cloud/src/workspace-store.test.ts @@ -1,22 +1,33 @@ 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; 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; }); @@ -62,6 +73,272 @@ 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.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'); + 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.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', + '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'); + 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'), + 'export function assertWindowsCredentialDirectory() {}\n' + ); + 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', + ...(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 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`; + 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('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-')); + 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', () => { + 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.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'); + 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(caseAliasDataDir, '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 11ae269738..f455b77131 100644 --- a/packages/cloud/src/workspace-store.ts +++ b/packages/cloud/src/workspace-store.ts @@ -1,6 +1,8 @@ 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 @@ -14,6 +16,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 +30,345 @@ 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(`${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 +): 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_REPLACE_TIMEOUT_MS = 2_000; +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 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 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`; + const ownerToken = randomUUID(); + 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.' + ); + } + 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 { + 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 (!isRelaycastCredentialLockContention(error)) throw error; + } + try { + 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')) { + continue; + } + throw error; + } + continue; + } + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') continue; + throw error; + } + 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)) { + let cleanupError: unknown; + try { + 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; + } + } + } +} + +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; +} { + assertRelaycastCredentialLockDirectory(lock); + 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); + replaceRelaycastCredentialFile(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 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; } diff --git a/packages/cloud/src/workspaces.test.ts b/packages/cloud/src/workspaces.test.ts index 0384351684..9c8e7e4e0f 100644 --- a/packages/cloud/src/workspaces.test.ts +++ b/packages/cloud/src/workspaces.test.ts @@ -1,11 +1,13 @@ import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import os from 'node:os'; 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'; +import { AUTH_FILE_PATH } from './types.js'; let dir: string; const originalEnv = { ...process.env }; @@ -81,3 +83,224 @@ 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('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 () => + 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((init as RequestInit).redirect).toBe('error'); + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ + workspaceKey: 'rk_live_selected', + }); + }); + + 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', + '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 () => { + 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('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', + 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 () => + 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..696f68f167 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, ensureCloudSession, readStoredAuth } from './auth.js'; import { redactCredentialValues } from './redact.js'; import { type ActiveWorkspaceDescriptor, @@ -342,6 +342,67 @@ 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.'); + } + if (url.username || url.password || url.protocol !== 'https:') { + throw new Error('Project workspace resolution requires HTTPS.'); + } +} + +/** 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 env = options.env ?? process.env; + 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. + const stored = await readStoredAuth(env); + if (stored) assertWorkspaceResolverTransport(stored.apiUrl); + const { auth } = await ensureCloudSession({ + apiUrl, + interactive: false, + refreshTimeoutMs: options.refreshTimeoutMs, + env, + validateApiUrl: assertWorkspaceResolverTransport, + }); + 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), + }, + { + interactive: false, + env, + validateApiUrl: assertWorkspaceResolverTransport, + } + ); + 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..eb4b573c43 --- /dev/null +++ b/packages/config/src/project-namespace.test.ts @@ -0,0 +1,59 @@ +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('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(nested)).toThrow('Cannot resolve the repository workspace'); + }); +}); diff --git a/packages/config/src/project-namespace.ts b/packages/config/src/project-namespace.ts index 796b0b3c88..3d261d2235 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,43 @@ 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; + const gitMarkerPath = path.join(current, '.git'); + let marker: fs.Stats | undefined; + try { + 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; + } + if (current === root) break; current = path.dirname(current); } // Fallback to start directory - return path.resolve(startDir); + return nearestPackage ?? path.resolve(startDir); } /** 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 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..c5e07ca0a6 --- /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": "Materialize a live exact repository tree through Relayfile by default", + "runner": { + "command": ["node", "tests/relayflows/cases/1763-zero-config-sandbox/run.mjs"] + }, + "requirements": [], + "timeoutSeconds": 900, + "expected": { + "base": { + "outcome": "absent", + "signature": "live_relayfile_repository_contract_absent" + }, + "head": { + "outcome": "fixed", + "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 new file mode 100644 index 0000000000..505b511b3d --- /dev/null +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -0,0 +1,182 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +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 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 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 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']; +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 })); +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 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 = []; + mocks.ensureCloudSession.mockResolvedValue({ auth, client: {} }); + 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 = [], 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 (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, + }); + 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, 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 { + if (process.env.RELAY_PR1763_SKIP_INSTALL !== '1') + run( + 'npm', + ['ci', '--ignore-scripts', '--no-audit', '--no-fund'], + targetDir, + 'Cloud dependency installation', + INSTALL_TIMEOUT_MS + ); + 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); + run( + 'npm', + ['exec', '--', 'vitest', 'run', '--config', path.relative(targetDir, configPath)], + targetDir, + 'CLI repository revision proof', + PROBE_TIMEOUT_MS, + { + RELAY_PR1763_OBSERVATION_PATH: observationPath, + ...withGitExcludeEnv(excludePath), + } + ); + const observation = JSON.parse(await readFile(observationPath, 'utf8')); + const forwarded = + 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' ? '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 }); + await rm(configPath, { force: true }); + await rm(observationPath, { force: true }); + await rm(excludePath, { 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, 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}`); +} +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 { + 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); +}