From f099b48fdf402ddf5c4f2a5302b0cba544692215 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 12 Oct 2026 07:49:27 -0700 Subject: [PATCH 1/5] feat(fleet): start repository sandboxes from checkout before optional context --- CHANGELOG.md | 14 +- packages/cli/README.md | 66 ++++++---- .../fleet-lifecycle-integration.test.ts | 1 + .../commands/fleet-sandbox-regression.test.ts | 122 ++++++++++++++++++ packages/cli/src/cli/commands/fleet.test.ts | 3 +- packages/cli/src/cli/commands/fleet.ts | 65 ++++++++-- packages/cloud/src/fleet-sandbox.test.ts | 57 ++++++++ packages/cloud/src/fleet-sandbox.ts | 51 ++++++++ packages/cloud/src/index.ts | 3 + 9 files changed, 346 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aebbe86cd..839dd924f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,19 @@ 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 - Major] + +### Changed + +- `fleet spawn --sandbox` inside a repository starts from an exact shallow Git checkout without waiting for Relayfile. `--checkout` also omits Relayfile unless context is explicitly requested. + +### Added + +- `fleet relayfile attach` adds optional live context to an existing sandbox without restarting its worker. + +### Migration Guidance + +- Add `--sandbox-relayfile` to retain the previous required live repository mirror. Combine it with `--checkout` when both a Git checkout and live context must be ready before dispatch. ## [13.2.0] - 2026-10-08 diff --git a/packages/cli/README.md b/packages/cli/README.md index c6cf79265..c8c3e1895 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -324,19 +324,22 @@ 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. 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 +The sandbox path provisions a fresh hosted instance. Inside a GitHub checkout, +Relay infers the Git root, repository identity, exact pushed `HEAD`, and caller-relative +directory. Cloud makes a depth-1 clone, fetches that exact SHA, verifies `HEAD`, +and starts the worker under `/srv/agent-workforce/`. Repository tasks start +without waiting for Relayfile context. `--checkout` explicitly selects the same +static mode. Outside Git, the default still requires the workspace Relayfile mount. + +Use `--sandbox-relayfile` to require live context before dispatch. Without +`--checkout`, this retains the live repository mirror under +`/workspace/github/repos///contents`; with `--checkout`, it mounts +context separately from the Git clone. An explicit `--sandbox-relayfile-path` +also requires its selected context before startup. `--no-sandbox-relayfile` +explicitly disables mounting (combine with `--checkout` to clone repository files). +Use `--sandbox-provider daytona` or `--sandbox-provider e2b` to require an +operator-enabled provider; omit the flag to let Cloud's sandbox router choose. +If provisioning times out or the spawn fails, Relay asks Cloud to delete the newly created sandbox. Runs without a custom name use one `sbx_` identity and the matching `fleet-sandbox-` node name. Legacy custom `--sandbox-name` values remain supported when no @@ -358,7 +361,7 @@ 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 +In default repository mode or with `--checkout`, sandbox provisioning 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`, @@ -381,7 +384,7 @@ 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 +From a clean repository already pinned to a Relay workspace, the checkout-first path is: ```bash @@ -394,22 +397,33 @@ 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. +Invoking the command from `packages/web` starts the worker at +`/srv/agent-workforce//packages/web`, with `.git` available and `HEAD` +pinned to the requested revision. -For a static Git checkout, opt in explicitly: +Attach optional context later using `cloudWorkspaceId` and `sandboxId` from the +spawn response (this uses Cloud sign-in): ```bash -agent-relay fleet spawn codex \ - --name cloud-checkout \ - --task "Inspect this repository and report its current commit" \ - --sandbox \ - --checkout +agent-relay fleet relayfile attach \ + --cloud-workspace-id \ + --sandbox-id \ + --path '/linear/**' '/.skills/**' ``` +Omit `--path` for the full workspace. For a dev sandbox, add +`--cloud-api-url https://dev.agentrelay.com/cloud`. The command waits at most +five minutes by default, reports the separate mount path, and does not restart +the worker or retry a failed attachment. Tell an already running worker the +returned path when its task needs that context. The mount uses its own directory; +it never overlays the Git checkout. Attachment failure leaves the repository task +running. + +For a live repository mirror that must be ready before the task starts, add +`--sandbox-relayfile` to spawn. Its decoded tree contains no `.git`; its source +metadata sits beside `contents` under `.relayfile`. Use +`--checkout --sandbox-relayfile` for a Git checkout plus required live context. + 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 diff --git a/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts index a7e4ffe66..bed045a28 100644 --- a/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts +++ b/packages/cli/src/cli/commands/fleet-lifecycle-integration.test.ts @@ -206,6 +206,7 @@ describe('fleet CLI lifecycle routing', () => { 'spawn', 'codex', '--sandbox', + '--sandbox-relayfile', '--sandbox-provider', 'agent37', '--sandbox-id', diff --git a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts index b6f8f1ae4..69750d92a 100644 --- a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -40,6 +40,7 @@ function registerSandboxCommand(overrides: { materializeCloudRelayfileRepository?: ReturnType; ensureCloudFleetSandbox: ReturnType; placement?: ReturnType; + attachCloudFleetSandboxRelayfile?: ReturnType; }): Command { const placement = overrides.placement ?? vi.fn(async () => ({ invocationId: 'inv_test' })); const register = vi.fn(async () => ({ token: 'at_live_launcher' })); @@ -70,6 +71,9 @@ function registerSandboxCommand(overrides: { ? { materializeCloudRelayfileRepository: overrides.materializeCloudRelayfileRepository as never } : {}), ensureCloudFleetSandbox: overrides.ensureCloudFleetSandbox as never, + ...(overrides.attachCloudFleetSandboxRelayfile + ? { attachCloudFleetSandboxRelayfile: overrides.attachCloudFleetSandboxRelayfile as never } + : {}), resolveWorkspaceSelection: () => ({ key: 'rk_live_workspace', source: 'project', @@ -109,6 +113,123 @@ function materialization(revision: string) { } describe('fleet sandbox command regressions', () => { + it.each([{ extra: [] }, { extra: ['--checkout'] }])( + 'starts an exact repository checkout without waiting for Relayfile (%j)', + async ({ extra }) => { + const projectRoot = temporaryDirectory('relay-fleet-checkout-first-'); + const selection = { + repository: 'AgentWorkforce/relay', + repositoryName: 'relay', + revision: REVISION, + projectRoot, + repositoryRelativeCwd: 'packages/cli', + workerCwd: '/srv/agent-workforce/relay/packages/cli', + }; + // Any materialization call hangs forever: this task must dispatch without it. + const materialize = vi.fn(() => new Promise(() => {})); + const ensure = vi.fn(async () => ({ + outcome: 'provisioned' as const, + providerId: 'agent37' as const, + cloudWorkspaceId: 'cloud-workspace', + nodeId: 'node-checkout', + nodeName: 'checkout-node', + sandboxId: 'sandbox-checkout', + providerSandboxId: 'provider-checkout', + relayWorkspaceId: TARGET.workspaceId, + relaycastTarget: TARGET, + relayfileMounted: false, + repoRevisions: { 'AgentWorkforce/relay': REVISION }, + })); + const placement = vi.fn(async () => ({ invocationId: 'inv_checkout' })); + const program = registerSandboxCommand({ + projectRoot, + resolveSandboxRepository: () => selection, + materializeCloudRelayfileRepository: materialize, + ensureCloudFleetSandbox: ensure, + placement, + }); + await program.parseAsync( + [ + 'fleet', + 'spawn', + 'codex', + '--sandbox', + '--sandbox-provider', + 'agent37', + '--workspace-id', + TARGET.workspaceId, + '--name', + 'checkout-worker', + '--task', + 'Read the repository', + ...extra, + ], + { from: 'user' } + ); + expect(materialize).not.toHaveBeenCalled(); + expect(ensure).toHaveBeenCalledWith( + expect.objectContaining({ + mountRelayfile: false, + repos: ['AgentWorkforce/relay'], + repoRevisions: { 'AgentWorkforce/relay': REVISION }, + }), + expect.anything() + ); + expect(placement).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + worker_cwd: selection.workerCwd, + task: 'Read the repository', + }), + }) + ); + } + ); + + it('attaches optional context without re-provisioning or dispatching the repository task', async () => { + const ensure = vi.fn(); + const placement = vi.fn(); + const attach = vi.fn(async () => ({ + sandboxId: REPLAY_SANDBOX_ID, + relayfileMounted: true, + relayWorkspaceId: TARGET.workspaceId, + relayfileMountPath: '/home/node/workspace', + })); + const program = registerSandboxCommand({ + projectRoot: temporaryDirectory('relay-fleet-late-context-'), + resolveSandboxRepository: vi.fn(), + ensureCloudFleetSandbox: ensure, + placement, + attachCloudFleetSandboxRelayfile: attach, + }); + await program.parseAsync( + [ + 'fleet', + 'relayfile', + 'attach', + '--cloud-workspace-id', + '50587328-441d-4acb-b8f3-dbe1b3c5de99', + '--sandbox-id', + REPLAY_SANDBOX_ID, + '--path', + '/linear/**', + '--cloud-api-url', + 'https://dev.agentrelay.com/cloud', + ], + { from: 'user' } + ); + expect(attach).toHaveBeenCalledExactlyOnceWith( + { + cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', + sandboxId: REPLAY_SANDBOX_ID, + paths: ['/linear/**'], + }, + { timeoutMs: 300000, apiUrl: 'https://dev.agentrelay.com/cloud' } + ); + expect(ensure).not.toHaveBeenCalled(); + expect(placement).not.toHaveBeenCalled(); + }); + it('documents local repo-relative cwd inference and absolute remote cwd overrides', () => { const program = registerSandboxCommand({ projectRoot: temporaryDirectory('relay-fleet-help-'), @@ -233,6 +354,7 @@ describe('fleet sandbox command regressions', () => { 'spawn', 'codex', '--sandbox', + '--sandbox-relayfile', '--sandbox-provider', 'agent37', '--sandbox-id', diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index e12fc0df1..1f1659192 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1341,6 +1341,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--sandbox-relayfile', '--sandbox-provider', 'agent37', '--name', @@ -2230,7 +2231,7 @@ describe('fleet command support', () => { expect.objectContaining({ input: expect.objectContaining({ worker_cwd: '/srv/agent-workforce/cloud/packages/web', - task: expect.stringContaining('Relayfile records are available at /workspace'), + task: 'Review the Cloud web package', }), }) ); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index fd1ff0464..a8cb899a4 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -8,6 +8,7 @@ import { DEV_CLOUD_API_URL, DEV_RELAYCAST_ORIGIN, deleteCloudFleetSandbox, + attachCloudFleetSandboxRelayfile, ensureCloudFleetSandbox, materializeCloudRelayfileRepository, resolveWorkspaceByKey, @@ -248,6 +249,7 @@ export interface FleetCommandDependencies { ensureCloudFleetSandbox: typeof ensureCloudFleetSandbox; materializeCloudRelayfileRepository: typeof materializeCloudRelayfileRepository; deleteCloudFleetSandbox: typeof deleteCloudFleetSandbox; + attachCloudFleetSandboxRelayfile: typeof attachCloudFleetSandboxRelayfile; log: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; @@ -275,6 +277,7 @@ function withFleetDefaults(overrides: Partial = {}): F ensureCloudFleetSandbox, materializeCloudRelayfileRepository, deleteCloudFleetSandbox, + attachCloudFleetSandboxRelayfile, log: (...args: unknown[]) => console.log(...args), warn: (...args: unknown[]) => console.warn(...args), error: (...args: unknown[]) => console.error(...args), @@ -420,6 +423,37 @@ export function registerFleetCommands( } ); + group + .command('relayfile') + .description('Attach optional sandbox context') + .command('attach') + .description('Attach live Relayfile context to an existing sandbox using Cloud sign-in') + .requiredOption('--cloud-workspace-id ', 'Cloud workspace UUID returned by sandbox provisioning') + .requiredOption('--sandbox-id ', 'Exact Cloud sandbox identity (sbx_)') + .option('--path ', 'Only these context subtrees (each must end in /**)') + .option('--cloud-api-url ', 'Cloud API origin, including /cloud when required') + .option('--timeout ', 'Attachment timeout in milliseconds', '300000') + .action(async (options: Record) => { + await runSdk(deps.sdk, async () => { + const result = await deps.attachCloudFleetSandboxRelayfile( + { + cloudWorkspaceId: requiredText(options.cloudWorkspaceId, 'Cloud workspace ID'), + sandboxId: requiredText(options.sandboxId, 'Sandbox ID'), + ...(options.path === undefined + ? {} + : { paths: optionalTextList(options.path, 'Relayfile context path') }), + }, + { + timeoutMs: Number(requiredText(options.timeout, 'Attachment timeout')), + ...(options.cloudApiUrl === undefined + ? {} + : { apiUrl: requiredText(options.cloudApiUrl, 'Cloud API URL') }), + } + ); + printJson(deps.sdk, result); + }); + }); + // `fleet agent list` — the fleet-wide answer to `node agent list --pretty`. // See relay#1553 for the gap this fills and packages/cli/src/cli/commands/ // fleet-agent.ts for the join logic (three name spaces, per-node contributions). @@ -446,10 +480,7 @@ export function registerFleetCommands( .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 and spawn with a live Relayfile workspace/repository mount' - ) + .option('--sandbox', 'Provision a fresh Cloud sandbox; Git repositories start from an exact checkout') .option( '--checkout', 'Materialize the current Git checkout at its exact pushed HEAD (static; requires --sandbox)' @@ -468,6 +499,7 @@ export function registerFleetCommands( '--sandbox-relayfile-path ', 'Mount only these Relayfile subtrees (each path must end in /**)' ) + .option('--sandbox-relayfile', 'Require live Relayfile context before starting the task') .option('--no-sandbox-relayfile', 'Provision the sandbox without mounting Relayfile') .option('--channel ', 'Channel for the worker to join') .option('--persona ', 'Worker persona (automatic placement)') @@ -510,7 +542,7 @@ export function registerFleetCommands( if (useSandbox || targetNode || automaticPlacement) { warnIfInferredFromProjectSession(options, deps.warn); } - const checkoutRepository = options.checkout === true; + let 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 @@ -533,7 +565,7 @@ export function registerFleetCommands( if (sandboxProviderText !== undefined && sandboxProvider === undefined) { throw new Error('--sandbox-provider must be daytona, e2b, or agent37.'); } - const mountSandboxRelayfile = options.sandboxRelayfile !== false; + let mountSandboxRelayfile = options.sandboxRelayfile !== false; const sandboxRelayfilePaths = optionalTextList(options.sandboxRelayfilePath, 'Sandbox Relayfile path'); if (useSandbox && targetNode) { throw new Error('--sandbox cannot be combined with --node or --target-node.'); @@ -553,8 +585,8 @@ export function registerFleetCommands( if (!useSandbox && sandboxProvider) { throw new Error('--sandbox-provider requires --sandbox.'); } - if (!useSandbox && options.sandboxRelayfile === false) { - throw new Error('--no-sandbox-relayfile requires --sandbox.'); + if (!useSandbox && options.sandboxRelayfile !== undefined) { + throw new Error('--sandbox-relayfile and --no-sandbox-relayfile require --sandbox.'); } if (!useSandbox && sandboxRelayfilePaths) { throw new Error('--sandbox-relayfile-path requires --sandbox.'); @@ -624,6 +656,23 @@ export function registerFleetCommands( undefined, repositoryIdentityOnly ? 'identity' : 'strict' ); + // A repo-backed task can start from Git without waiting for the + // optional context mirror. Explicit mount intent keeps live mode. + if ( + sandboxRepository && + options.sandboxRelayfile === undefined && + sandboxRelayfilePaths === undefined + ) { + checkoutRepository = true; + mountSandboxRelayfile = false; + } + if ( + checkoutRepository && + options.sandboxRelayfile !== true && + sandboxRelayfilePaths === undefined + ) { + mountSandboxRelayfile = false; + } if (checkoutRepository && !sandboxRepository) { throw new Error('--checkout requires a GitHub checkout with a clean, pushed commit.'); } diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index c32e710ce..a47b61a92 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -17,6 +17,7 @@ import { DEV_CLOUD_API_URL, DEV_RELAYCAST_ORIGIN, deleteCloudFleetSandbox, + attachCloudFleetSandboxRelayfile, ensureCloudFleetSandbox, materializeCloudRelayfileRepository, normalizeRelaycastTarget, @@ -85,6 +86,62 @@ describe('Cloud fleet sandbox client', () => { vi.restoreAllMocks(); }); + it('attaches context once to the exact existing sandbox without choosing a checkout destination', async () => { + mocks.authorizedApiFetch.mockResolvedValue({ + auth, + response: Response.json({ + sandboxId: SANDBOX_ID, + relayWorkspaceId: 'rw_abc', + relayfileMounted: true, + relayfileMountPath: '/home/node/workspace', + }), + }); + const result = await attachCloudFleetSandboxRelayfile({ + cloudWorkspaceId: CLOUD_WORKSPACE_ID, + sandboxId: SANDBOX_ID, + paths: ['/linear/**'], + }); + expect(result.relayfileMountPath).toBe('/home/node/workspace'); + expect(mocks.authorizedApiFetch).toHaveBeenCalledExactlyOnceWith( + auth, + `/api/v1/workspaces/${CLOUD_WORKSPACE_ID}/sandboxes/${SANDBOX_ID}/relayfile-mount`, + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ paths: ['/linear/**'] }), + signal: expect.any(AbortSignal), + }), + { interactive: false } + ); + }); + + it.each([ + { sandboxId: 'sbx_wrong', relayfileMounted: true }, + { sandboxId: SANDBOX_ID, relayfileMounted: false }, + ])('rejects an attachment response that does not prove the requested mount (%j)', async (payload) => { + mocks.authorizedApiFetch.mockResolvedValue({ + auth, + response: Response.json({ + relayWorkspaceId: 'rw_abc', + relayfileMountPath: '/home/node/workspace', + ...payload, + }), + }); + await expect( + attachCloudFleetSandboxRelayfile({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }) + ).rejects.toThrow('did not confirm'); + }); + + it('does not retry a failed optional attachment or allocate a replacement sandbox', async () => { + mocks.authorizedApiFetch.mockResolvedValue({ + auth, + response: Response.json({ error: 'mount timed out' }, { status: 503 }), + }); + await expect( + attachCloudFleetSandboxRelayfile({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }) + ).rejects.toThrow('mount timed out'); + expect(mocks.authorizedApiFetch).toHaveBeenCalledTimes(1); + }); + it('normalizes a closed Agent37 Relaycast target', () => { expect( normalizeRelaycastTarget({ ...RELAYCAST_TARGET, baseUrl: `${AGENT37_RELAYCAST_ORIGIN}/` }) diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index b71bb4810..cdd310b1e 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -1747,3 +1747,54 @@ export async function deleteCloudFleetSandbox( const payload = await readJson(response); if (!response.ok) throw endpointError('delete the fleet sandbox', response, payload); } + +export type AttachCloudFleetSandboxRelayfileInput = { + cloudWorkspaceId: string; + sandboxId: string; + /** Optional context subtrees; the server chooses a separate mount directory. */ + paths?: string[]; +}; + +export type CloudFleetSandboxRelayfileAttachment = { + sandboxId: string; + relayWorkspaceId: string; + relayfileMounted: true; + relayfileMountPath: string; +}; + +/** Attach optional live context to an existing sandbox without allocating or dispatching. */ +export async function attachCloudFleetSandboxRelayfile( + input: AttachCloudFleetSandboxRelayfileInput, + options: CloudFleetSandboxRequestOptions = {} +): Promise { + const cloudWorkspaceId = input.cloudWorkspaceId.trim(); + const sandboxId = input.sandboxId.trim(); + if (!CLOUD_WORKSPACE_ID_PATTERN.test(cloudWorkspaceId) || !CLOUD_SANDBOX_ID_PATTERN.test(sandboxId)) { + throw new Error('A Cloud workspace UUID and sbx_ sandbox ID are required.'); + } + if ( + input.paths && + (input.paths.length === 0 || input.paths.some((path) => !path.startsWith('/') || !path.endsWith('/**'))) + ) { + throw new Error('Relayfile context paths must be absolute subtrees ending in /**.'); + } + const signal = boundedSignal(options, DEFAULT_ENSURE_TIMEOUT_MS); + const session = await ensureCloudSession({ apiUrl: options.apiUrl || defaultApiUrl(), interactive: false }); + const { response } = await authorizedApiFetch( + session.auth, + `/api/v1/workspaces/${encodeURIComponent(cloudWorkspaceId)}/sandboxes/${encodeURIComponent(sandboxId)}/relayfile-mount`, + { method: 'POST', signal, body: JSON.stringify(input.paths ? { paths: input.paths } : {}) }, + { interactive: false } + ); + const payload = await readJson(response); + if (!response.ok) throw endpointError('attach Relayfile context', response, payload); + if (!isObject(payload) || payload.sandboxId !== sandboxId || payload.relayfileMounted !== true) { + throw new Error('Cloud did not confirm Relayfile attachment to the requested sandbox.'); + } + return { + sandboxId, + relayfileMounted: true, + relayWorkspaceId: requiredString(payload, 'relayWorkspaceId', 'Relayfile attachment'), + relayfileMountPath: requiredString(payload, 'relayfileMountPath', 'Relayfile attachment'), + }; +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 2c5652db9..e07f7c68f 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -86,6 +86,9 @@ export { ensureCloudFleetSandbox, materializeCloudRelayfileRepository, deleteCloudFleetSandbox, + attachCloudFleetSandboxRelayfile, + type AttachCloudFleetSandboxRelayfileInput, + type CloudFleetSandboxRelayfileAttachment, CloudFleetSandboxProvisionError, normalizeRelaycastTarget, CANONICAL_RELAYCAST_ORIGIN, From f9f32a5f38f14adedbc2a3ad943bbec89f45c70f Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Mon, 12 Oct 2026 08:31:16 -0700 Subject: [PATCH 2/5] fix(fleet): bound attachment authentication and poll exact-root context preparation --- packages/cli/README.md | 10 +- .../commands/fleet-sandbox-regression.test.ts | 4 +- packages/cli/src/cli/commands/fleet.ts | 2 +- packages/cloud/src/auth.test.ts | 25 +++++ packages/cloud/src/auth.ts | 4 + packages/cloud/src/fleet-sandbox.test.ts | 92 +++++++++++++++- packages/cloud/src/fleet-sandbox.ts | 100 ++++++++++++++---- packages/cloud/src/types.ts | 2 + 8 files changed, 210 insertions(+), 29 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index c8c3e1895..e9b2a3879 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -408,13 +408,14 @@ spawn response (this uses Cloud sign-in): agent-relay fleet relayfile attach \ --cloud-workspace-id \ --sandbox-id \ - --path '/linear/**' '/.skills/**' + --path '/linear' '/.skills' ``` Omit `--path` for the full workspace. For a dev sandbox, add `--cloud-api-url https://dev.agentrelay.com/cloud`. The command waits at most five minutes by default, reports the separate mount path, and does not restart -the worker or retry a failed attachment. Tell an already running worker the +the worker or retry a failed attachment. Pending detached preparation is polled +against the same sandbox within that shared deadline. Tell an already running worker the returned path when its task needs that context. The mount uses its own directory; it never overlays the Git checkout. Attachment failure leaves the repository task running. @@ -430,9 +431,10 @@ 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 +With `--checkout --sandbox-relayfile`, the Git checkout and Relayfile mirror are +separate trees. Checkout-only mode has no Relayfile mirror or workspace skills. 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 +placing `.git` in the mirror. When a live mount is requested at startup, workspace `.skills` are exposed through the agent CLIs' usual skill directories, and the worker's task context identifies the mirror and exact source revision. diff --git a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts index 69750d92a..b9cc1a342 100644 --- a/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts +++ b/packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts @@ -212,7 +212,7 @@ describe('fleet sandbox command regressions', () => { '--sandbox-id', REPLAY_SANDBOX_ID, '--path', - '/linear/**', + '/linear', '--cloud-api-url', 'https://dev.agentrelay.com/cloud', ], @@ -222,7 +222,7 @@ describe('fleet sandbox command regressions', () => { { cloudWorkspaceId: '50587328-441d-4acb-b8f3-dbe1b3c5de99', sandboxId: REPLAY_SANDBOX_ID, - paths: ['/linear/**'], + paths: ['/linear'], }, { timeoutMs: 300000, apiUrl: 'https://dev.agentrelay.com/cloud' } ); diff --git a/packages/cli/src/cli/commands/fleet.ts b/packages/cli/src/cli/commands/fleet.ts index a8cb899a4..c61f7ec40 100644 --- a/packages/cli/src/cli/commands/fleet.ts +++ b/packages/cli/src/cli/commands/fleet.ts @@ -430,7 +430,7 @@ export function registerFleetCommands( .description('Attach live Relayfile context to an existing sandbox using Cloud sign-in') .requiredOption('--cloud-workspace-id ', 'Cloud workspace UUID returned by sandbox provisioning') .requiredOption('--sandbox-id ', 'Exact Cloud sandbox identity (sbx_)') - .option('--path ', 'Only these context subtrees (each must end in /**)') + .option('--path ', 'Only these exact context roots (for example /linear)') .option('--cloud-api-url ', 'Cloud API origin, including /cloud when required') .option('--timeout ', 'Attachment timeout in milliseconds', '300000') .action(async (options: Record) => { diff --git a/packages/cloud/src/auth.test.ts b/packages/cloud/src/auth.test.ts index 11487d1e8..17e65d7a7 100644 --- a/packages/cloud/src/auth.test.ts +++ b/packages/cloud/src/auth.test.ts @@ -699,6 +699,31 @@ describe('ensureAuthenticated', () => { }); }); +it('cancels an expired session refresh using its caller deadline', async () => { + const controller = new AbortController(); + let refreshSignal: AbortSignal | undefined; + vi.stubGlobal( + 'fetch', + vi.fn(async (_input: string | URL, init?: RequestInit) => { + refreshSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + refreshSignal?.addEventListener('abort', () => reject(refreshSignal?.reason), { once: true }); + }); + }) + ); + const failed = ensureCloudSession({ + env: createEnvAuth(), + interactive: false, + signal: controller.signal, + }).catch((error: unknown) => error); + await vi.waitFor(() => expect(refreshSignal).toBeDefined()); + const reason = new Error('Attachment canceled'); + controller.abort(reason); + expect(await failed).toBe(reason); + expect(refreshSignal?.aborted).toBe(true); + expect(fsMocks.writeFile).not.toHaveBeenCalled(); +}); + describe('refreshStoredAuth', () => { it('refreshes env-backed auth in memory only without touching the auth file', async () => { const env = createEnvAuth(); diff --git a/packages/cloud/src/auth.ts b/packages/cloud/src/auth.ts index 2d62560fb..774ab635f 100644 --- a/packages/cloud/src/auth.ts +++ b/packages/cloud/src/auth.ts @@ -761,12 +761,14 @@ export async function ensureAuthenticated( } export async function ensureCloudSession(options: CloudSessionOptions = {}): Promise { + options.signal?.throwIfAborted(); const env = options.env ?? process.env; const apiUrl = options.apiUrl || defaultApiUrl(env); const force = options.force === true; const interactive = options.interactive !== false; const refreshTimeoutMs = options.refreshTimeoutMs; const stored = !force ? await readStoredAuth(env) : null; + options.signal?.throwIfAborted(); // Stored auth is authoritative on its own host. A host mismatch between // `apiUrl` (typically defaultApiUrl()) and `stored.apiUrl` is NOT a reason @@ -807,6 +809,7 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro try { const auth = await refreshStoredAuth(stored, { + signal: options.signal, refreshTimeoutMs, validateApiUrl: options.validateApiUrl, }); @@ -815,6 +818,7 @@ export async function ensureCloudSession(options: CloudSessionOptions = {}): Pro validateApiUrl: options.validateApiUrl, }); } catch (error) { + options.signal?.throwIfAborted(); if (isEnvBackedAuth(stored)) { throw toEnvAuthRefreshError(error); } diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index a47b61a92..872fc30ba 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -99,7 +99,7 @@ describe('Cloud fleet sandbox client', () => { const result = await attachCloudFleetSandboxRelayfile({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID, - paths: ['/linear/**'], + paths: ['/linear'], }); expect(result.relayfileMountPath).toBe('/home/node/workspace'); expect(mocks.authorizedApiFetch).toHaveBeenCalledExactlyOnceWith( @@ -107,7 +107,7 @@ describe('Cloud fleet sandbox client', () => { `/api/v1/workspaces/${CLOUD_WORKSPACE_ID}/sandboxes/${SANDBOX_ID}/relayfile-mount`, expect.objectContaining({ method: 'POST', - body: JSON.stringify({ paths: ['/linear/**'] }), + body: JSON.stringify({ paths: ['/linear'] }), signal: expect.any(AbortSignal), }), { interactive: false } @@ -131,6 +131,94 @@ describe('Cloud fleet sandbox client', () => { ).rejects.toThrow('did not confirm'); }); + it('includes stalled Cloud authentication in the attachment timeout and never sends a late POST', async () => { + let finishAuth!: (value: { auth: typeof auth; client: object }) => void; + mocks.ensureCloudSession.mockImplementation( + () => + new Promise((resolve) => { + finishAuth = resolve; + }) + ); + await expect( + attachCloudFleetSandboxRelayfile( + { cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }, + { timeoutMs: 20 } + ) + ).rejects.toMatchObject({ name: 'TimeoutError' }); + const signal = mocks.ensureCloudSession.mock.calls[0][0].signal as AbortSignal; + expect(signal.aborted).toBe(true); + finishAuth({ auth, client: {} }); + await Promise.resolve(); + expect(mocks.authorizedApiFetch).not.toHaveBeenCalled(); + }); + + it('polls confirmed detached preparation on the same sandbox and authentication deadline', async () => { + mocks.authorizedApiFetch + .mockResolvedValueOnce({ + auth: refreshedAuth, + response: Response.json( + { + sandboxId: SANDBOX_ID, + relayfileMounted: false, + state: 'pending', + phase: 'relayfile_mount_bootstrap', + }, + { status: 202, headers: { 'Retry-After': '0' } } + ), + }) + .mockResolvedValueOnce({ + auth: refreshedAuth, + response: Response.json({ + sandboxId: SANDBOX_ID, + relayWorkspaceId: 'rw_abc', + relayfileMounted: true, + relayfileMountPath: '/home/node/workspace', + }), + }); + await expect( + attachCloudFleetSandboxRelayfile({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }) + ).resolves.toMatchObject({ relayfileMounted: true }); + const calls = mocks.authorizedApiFetch.mock.calls; + expect(calls).toHaveLength(2); + expect(calls[1][0]).toEqual(refreshedAuth); + expect(calls[1][1]).toEqual(calls[0][1]); + expect(calls[1][2].signal).toBe(calls[0][2].signal); + expect(mocks.ensureCloudSession.mock.calls[0][0].signal).toBe(calls[0][2].signal); + }); + + it('does not poll pending preparation for a different sandbox', async () => { + mocks.authorizedApiFetch.mockResolvedValue({ + auth, + response: Response.json( + { + sandboxId: 'sbx_wrong', + relayfileMounted: false, + state: 'pending', + phase: 'relayfile_mount_bootstrap', + }, + { status: 202 } + ), + }); + await expect( + attachCloudFleetSandboxRelayfile({ cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }) + ).rejects.toThrow('did not confirm pending'); + expect(mocks.authorizedApiFetch).toHaveBeenCalledTimes(1); + }); + + it.each(['/linear/**', '/linear/../secret', 'relative', '/'])( + 'rejects a path the Cloud attachment route cannot accept (%s)', + async (path) => { + await expect( + attachCloudFleetSandboxRelayfile({ + cloudWorkspaceId: CLOUD_WORKSPACE_ID, + sandboxId: SANDBOX_ID, + paths: [path], + }) + ).rejects.toThrow('absolute exact roots'); + expect(mocks.ensureCloudSession).not.toHaveBeenCalled(); + } + ); + it('does not retry a failed optional attachment or allocate a replacement sandbox', async () => { mocks.authorizedApiFetch.mockResolvedValue({ auth, diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index cdd310b1e..5efa41453 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -1751,7 +1751,7 @@ export async function deleteCloudFleetSandbox( export type AttachCloudFleetSandboxRelayfileInput = { cloudWorkspaceId: string; sandboxId: string; - /** Optional context subtrees; the server chooses a separate mount directory. */ + /** Optional exact context roots; the server chooses a separate mount directory. */ paths?: string[]; }; @@ -1762,6 +1762,19 @@ export type CloudFleetSandboxRelayfileAttachment = { relayfileMountPath: string; }; +/** Bound session acquisition too, including filesystem and lock waits. */ +function awaitAttachmentSession(signal: AbortSignal, task: () => ReturnType) { + signal.throwIfAborted(); + return new Promise>>((resolve, reject) => { + const aborted = () => reject(signal.reason ?? new Error('Relayfile attachment aborted')); + signal.addEventListener('abort', aborted, { once: true }); + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => signal.removeEventListener('abort', aborted)); + }); +} + /** Attach optional live context to an existing sandbox without allocating or dispatching. */ export async function attachCloudFleetSandboxRelayfile( input: AttachCloudFleetSandboxRelayfileInput, @@ -1772,29 +1785,76 @@ export async function attachCloudFleetSandboxRelayfile( if (!CLOUD_WORKSPACE_ID_PATTERN.test(cloudWorkspaceId) || !CLOUD_SANDBOX_ID_PATTERN.test(sandboxId)) { throw new Error('A Cloud workspace UUID and sbx_ sandbox ID are required.'); } + const paths = input.paths?.map((path) => + path + .trim() + .replace(/\/{2,}/g, '/') + .replace(/\/$/, '') + ); if ( - input.paths && - (input.paths.length === 0 || input.paths.some((path) => !path.startsWith('/') || !path.endsWith('/**'))) + paths && + (paths.length === 0 || + paths.length > 8 || + paths.some( + (path) => + path.length < 2 || + path.length > 512 || + !path.startsWith('/') || + /[\0\r\n*]/.test(path) || + path + .slice(1) + .split('/') + .some((part) => part === '.' || part === '..') + )) ) { - throw new Error('Relayfile context paths must be absolute subtrees ending in /**.'); + throw new Error('Relayfile context paths must be absolute exact roots without wildcards or traversal.'); } const signal = boundedSignal(options, DEFAULT_ENSURE_TIMEOUT_MS); - const session = await ensureCloudSession({ apiUrl: options.apiUrl || defaultApiUrl(), interactive: false }); - const { response } = await authorizedApiFetch( - session.auth, - `/api/v1/workspaces/${encodeURIComponent(cloudWorkspaceId)}/sandboxes/${encodeURIComponent(sandboxId)}/relayfile-mount`, - { method: 'POST', signal, body: JSON.stringify(input.paths ? { paths: input.paths } : {}) }, - { interactive: false } + const session = await awaitAttachmentSession(signal, () => + ensureCloudSession({ + apiUrl: options.apiUrl || defaultApiUrl(), + interactive: false, + signal, + }) ); - const payload = await readJson(response); - if (!response.ok) throw endpointError('attach Relayfile context', response, payload); - if (!isObject(payload) || payload.sandboxId !== sandboxId || payload.relayfileMounted !== true) { - throw new Error('Cloud did not confirm Relayfile attachment to the requested sandbox.'); + let activeAuth = session.auth; + for (;;) { + signal.throwIfAborted(); + const fetched = await authorizedApiFetch( + activeAuth, + `/api/v1/workspaces/${encodeURIComponent(cloudWorkspaceId)}/sandboxes/${encodeURIComponent(sandboxId)}/relayfile-mount`, + { method: 'POST', signal, body: JSON.stringify(paths ? { paths: [...new Set(paths)] } : {}) }, + { interactive: false } + ); + activeAuth = fetched.auth; + const response = fetched.response; + const payload = await readJson(response); + if (!response.ok) throw endpointError('attach Relayfile context', response, payload); + if (response.status === 202) { + if ( + !isObject(payload) || + payload.sandboxId !== sandboxId || + payload.relayfileMounted !== false || + payload.state !== 'pending' || + ![ + 'relayfile_mount_bootstrap', + 'relayfile_mount_readiness_incomplete', + 'relayfile_mount_readiness_timed_out', + ].includes(readString(payload, 'phase') ?? '') + ) { + throw new Error('Cloud did not confirm pending attachment to the requested sandbox.'); + } + await waitForDelay(retryAfterDelayMs(response) ?? 5_000, signal); + continue; + } + if (!isObject(payload) || payload.sandboxId !== sandboxId || payload.relayfileMounted !== true) { + throw new Error('Cloud did not confirm Relayfile attachment to the requested sandbox.'); + } + return { + sandboxId, + relayfileMounted: true, + relayWorkspaceId: requiredString(payload, 'relayWorkspaceId', 'Relayfile attachment'), + relayfileMountPath: requiredString(payload, 'relayfileMountPath', 'Relayfile attachment'), + }; } - return { - sandboxId, - relayfileMounted: true, - relayWorkspaceId: requiredString(payload, 'relayWorkspaceId', 'Relayfile attachment'), - relayfileMountPath: requiredString(payload, 'relayfileMountPath', 'Relayfile attachment'), - }; } diff --git a/packages/cloud/src/types.ts b/packages/cloud/src/types.ts index d2932e4d3..1cf2b16af 100644 --- a/packages/cloud/src/types.ts +++ b/packages/cloud/src/types.ts @@ -34,6 +34,8 @@ export type CloudSession = { export type CloudSessionOptions = { apiUrl?: string; + /** Cancellation/deadline shared with the operation acquiring this session. */ + signal?: AbortSignal; force?: boolean; interactive?: boolean; /** From 6c17822ef943c4b890a5089c5abba89b002a60f2 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 13 Oct 2026 09:09:52 -0700 Subject: [PATCH 3/5] test(fleet): align main's sandbox tests and proof cases with checkout-first default Main's PTY-body-limit test, command-surface counts, and the 1656/1763 RelayFlow cases predate #1965: opt the Relayfile-context cases into --sandbox-relayfile, echo requested repoRevisions from the 1656 Cloud mock, and count the new `fleet relayfile attach` command. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/cli/bootstrap.test.ts | 1 + packages/cli/src/cli/commands/fleet.test.ts | 1 + tests/fixtures/verify-fleet-daytona.test.ts | 6 +++--- .../1656-long-running-agent37-sandbox/run.mjs | 17 +++++++++++------ .../cases/1763-zero-config-sandbox/run.mjs | 2 +- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/cli/bootstrap.test.ts b/packages/cli/src/cli/bootstrap.test.ts index cabbc4bcf..5c21d18bc 100644 --- a/packages/cli/src/cli/bootstrap.test.ts +++ b/packages/cli/src/cli/bootstrap.test.ts @@ -45,6 +45,7 @@ const expectedLeafCommands = [ // fleet (serve is a hidden error stub; config/enable/disable/inherit are hidden no-ops) 'fleet agent list', 'fleet nodes list', + 'fleet relayfile attach', 'fleet release', 'fleet spawn', 'fleet spawn-status', diff --git a/packages/cli/src/cli/commands/fleet.test.ts b/packages/cli/src/cli/commands/fleet.test.ts index c0f86b120..45a90e9e5 100644 --- a/packages/cli/src/cli/commands/fleet.test.ts +++ b/packages/cli/src/cli/commands/fleet.test.ts @@ -1450,6 +1450,7 @@ describe('fleet command support', () => { 'spawn', 'codex', '--sandbox', + '--sandbox-relayfile', '--sandbox-provider', 'agent37', '--name', diff --git a/tests/fixtures/verify-fleet-daytona.test.ts b/tests/fixtures/verify-fleet-daytona.test.ts index 004588934..437d942ec 100644 --- a/tests/fixtures/verify-fleet-daytona.test.ts +++ b/tests/fixtures/verify-fleet-daytona.test.ts @@ -2059,10 +2059,10 @@ describe('complete Daytona Fleet board', () => { const actual = await collectFleetCliInventory('packages/cli/dist/cli/index.js'); // The trusted verifier runs from current main while the candidate CLI is // hydrated separately. Keep this assertion pinned to main's known - // 31-executable-leaf/37-record surface; candidate inventory equality is checked in + // 32-executable-leaf/39-record surface; candidate inventory equality is checked in // the qualification job against the hydrated artifact. - expect(actual.commands).toHaveLength(37); - expect(actual.commands.filter(({ leaf }: { leaf: boolean }) => leaf)).toHaveLength(31); + expect(actual.commands).toHaveLength(39); + expect(actual.commands.filter(({ leaf }: { leaf: boolean }) => leaf)).toHaveLength(32); expect(inventorySha256(actual)).toMatch(/^[a-f0-9]{64}$/); for (const verb of ['config', 'enable', 'disable', 'inherit']) { expect(actual.commands.find(({ path }: { path: string }) => path === `fleet ${verb}`)).toMatchObject({ diff --git a/tests/relayflows/cases/1656-long-running-agent37-sandbox/run.mjs b/tests/relayflows/cases/1656-long-running-agent37-sandbox/run.mjs index 916cd2edb..a93867b56 100644 --- a/tests/relayflows/cases/1656-long-running-agent37-sandbox/run.mjs +++ b/tests/relayflows/cases/1656-long-running-agent37-sandbox/run.mjs @@ -239,12 +239,17 @@ test('fleet spawn --sandbox replays and retains an exact provider-attributed ide .mockResolvedValueOnce({ response: Response.json({ cloudWorkspaceId: CLOUD_WORKSPACE_ID }), auth }) // 2. provisioning. Cloud selects agent37 on its own; the command line below // never names a provider, which is the whole point of the feature. - .mockResolvedValueOnce({ - response: Response.json( - provisionedResponse, - { status: 201 } - ), - auth, + // Echo any requested repository revisions, as Cloud does for a + // checkout-first sandbox. + .mockImplementationOnce(async (_auth: unknown, _path: string, request?: { body?: string }) => { + const body = request?.body ? JSON.parse(request.body) : {}; + return { + response: Response.json( + { ...provisionedResponse, ...(body.repoRevisions ? { repoRevisions: body.repoRevisions } : {}) }, + { status: 201 } + ), + auth, + }; }) // 3. cleanup, triggered by the deliberate output-sink failure below .mockResolvedValueOnce({ diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index e35e6bf8c..e9e731b8e 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -75,7 +75,7 @@ test('fleet sandbox CLI materializes exact source through a scoped live Relayfil 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'); + await expect(program.parseAsync(['fleet', 'spawn', 'codex', '--name', 'proof-worker', '--task', 'proof', '--sandbox', ...(${JSON.stringify(arm)} === 'head' ? ['--sandbox-relayfile'] : []), '--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'); } From 96ceca1ba1a3449759ef773c08547dfa4e97c26a Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 13 Oct 2026 09:11:16 -0700 Subject: [PATCH 4/5] fix(fleet): refuse relayfile attach through a login bound to another Cloud origin An explicit --cloud-api-url no longer silently POSTs the attachment to the stored login's environment; point the caller at cloud login --force, as the integration and room clients do. Reword case 1763 for --sandbox-relayfile. Co-Authored-By: Claude Opus 5.5 --- packages/cloud/src/fleet-sandbox.test.ts | 26 +++++++++++++++++++ packages/cloud/src/fleet-sandbox.ts | 18 +++++++++++++ .../cases/1763-zero-config-sandbox/case.json | 2 +- .../cases/1763-zero-config-sandbox/run.mjs | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/cloud/src/fleet-sandbox.test.ts b/packages/cloud/src/fleet-sandbox.test.ts index 872fc30ba..302668f28 100644 --- a/packages/cloud/src/fleet-sandbox.test.ts +++ b/packages/cloud/src/fleet-sandbox.test.ts @@ -114,6 +114,32 @@ describe('Cloud fleet sandbox client', () => { ); }); + it('refuses to attach through a stored login bound to a different Cloud origin', async () => { + await expect( + attachCloudFleetSandboxRelayfile( + { cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }, + { apiUrl: DEV_CLOUD_API_URL } + ) + ).rejects.toThrow(`cloud login --api-url ${DEV_CLOUD_API_URL.replace(/\/+$/, '')} --force`); + expect(mocks.authorizedApiFetch).not.toHaveBeenCalled(); + + mocks.authorizedApiFetch.mockResolvedValue({ + auth, + response: Response.json({ + sandboxId: SANDBOX_ID, + relayWorkspaceId: 'rw_abc', + relayfileMounted: true, + relayfileMountPath: '/home/node/workspace', + }), + }); + await expect( + attachCloudFleetSandboxRelayfile( + { cloudWorkspaceId: CLOUD_WORKSPACE_ID, sandboxId: SANDBOX_ID }, + { apiUrl: `${auth.apiUrl}/` } + ) + ).resolves.toMatchObject({ relayfileMounted: true }); + }); + it.each([ { sandboxId: 'sbx_wrong', relayfileMounted: true }, { sandboxId: SANDBOX_ID, relayfileMounted: false }, diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 5efa41453..70f9920c8 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -1762,6 +1762,15 @@ export type CloudFleetSandboxRelayfileAttachment = { relayfileMountPath: string; }; +function comparableApiUrl(value: string): string { + try { + const url = new URL(value); + return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; + } catch { + return value.trim().replace(/\/+$/, ''); + } +} + /** Bound session acquisition too, including filesystem and lock waits. */ function awaitAttachmentSession(signal: AbortSignal, task: () => ReturnType) { signal.throwIfAborted(); @@ -1817,6 +1826,15 @@ export async function attachCloudFleetSandboxRelayfile( signal, }) ); + // A stored login stays authoritative on its own host; never attach to a + // different environment than the one the caller explicitly selected. + if (options.apiUrl && comparableApiUrl(session.auth.apiUrl) !== comparableApiUrl(options.apiUrl)) { + throw new Error( + `Cloud login is bound to ${comparableApiUrl(session.auth.apiUrl)}. Run \`agent-relay cloud login --api-url ${comparableApiUrl( + options.apiUrl + )} --force\` before attaching on this host.` + ); + } let activeAuth = session.auth; for (;;) { signal.throwIfAborted(); diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/case.json b/tests/relayflows/cases/1763-zero-config-sandbox/case.json index c5e07ca0a..7ea2e8c6a 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/case.json +++ b/tests/relayflows/cases/1763-zero-config-sandbox/case.json @@ -2,7 +2,7 @@ "version": 1, "id": "1763-zero-config-sandbox", "kind": "feature", - "title": "Materialize a live exact repository tree through Relayfile by default", + "title": "Materialize a live exact repository tree through Relayfile with --sandbox-relayfile", "runner": { "command": ["node", "tests/relayflows/cases/1763-zero-config-sandbox/run.mjs"] }, diff --git a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs index e9e731b8e..35163f1ca 100644 --- a/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs +++ b/tests/relayflows/cases/1763-zero-config-sandbox/run.mjs @@ -129,7 +129,7 @@ try { 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` + `${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 fleet spawn --sandbox --sandbox-relayfile command inferred the repository, materialized its exact revision through Relayfile, mounted source metadata and skills, mapped the caller-relative cwd, and sent no static clone request.' : 'The base plain fleet spawn command did not materialize or mount the inferred repository as a live decoded Relayfile working tree.' })}\n` ); } finally { await rm(probePath, { force: true }); From 4db1de0015e38015b4a0122f3f4fbfa6da4e7abf Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Tue, 13 Oct 2026 09:34:12 -0700 Subject: [PATCH 5/5] fix(cloud): trim attach API URL slashes without a regex Clears the CodeQL polynomial-ReDoS alert on the origin comparison. Co-Authored-By: Claude Opus 5.5 --- packages/cloud/src/fleet-sandbox.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cloud/src/fleet-sandbox.ts b/packages/cloud/src/fleet-sandbox.ts index 70f9920c8..b19dc7b72 100644 --- a/packages/cloud/src/fleet-sandbox.ts +++ b/packages/cloud/src/fleet-sandbox.ts @@ -1762,12 +1762,18 @@ export type CloudFleetSandboxRelayfileAttachment = { relayfileMountPath: string; }; +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === '/') end -= 1; + return value.slice(0, end); +} + function comparableApiUrl(value: string): string { try { const url = new URL(value); - return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; + return `${url.origin}${trimTrailingSlashes(url.pathname)}`; } catch { - return value.trim().replace(/\/+$/, ''); + return trimTrailingSlashes(value.trim()); } }