Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b6c3bb1
Expose fleet attach subpaths and forward sandbox readonly requests
Sep 18, 2026
e9e44b2
Record package validation and base-head readonly proof
Sep 18, 2026
b46ed1c
style: auto-format with Prettier
github-actions[bot] Sep 18, 2026
54bd2e5
Merge origin/main into relay#1787
khaliqgant Oct 8, 2026
d916282
feat(sdk): spawnFleetSandbox starts a running agent in a Cloud sandbox
khaliqgant Oct 8, 2026
8c980d4
ci(evals): build the SDK's cloud dependency before the SDK
khaliqgant Oct 8, 2026
31968ec
fix(sdk,cloud): address review on spawnFleetSandbox and attach
khaliqgant Oct 8, 2026
0cdd92b
fix(sdk): await async spawnFleetSandbox hooks inside the cleanup boun…
khaliqgant Oct 8, 2026
813a003
fix(sdk): require a dispatch receipt proving the sandbox node; redact…
khaliqgant Oct 8, 2026
c36c3c1
fix(cloud): keep /ws output history while a raw attach client is active
khaliqgant Oct 8, 2026
b8ca716
fix(sdk): await async persistRelaycastTarget inside the cleanup boundary
khaliqgant Oct 8, 2026
475c8c6
fix(sdk): destroy() retries only the teardown step that failed
khaliqgant Oct 8, 2026
8b3ad5b
Merge remote-tracking branch 'origin/main' into relayflow/relay-softw…
khaliqgant Oct 8, 2026
a20c066
chore: align with v13.2.0 — pin @agent-relay/cloud 13.2.0, keep new e…
khaliqgant Oct 8, 2026
734d30f
fix(cloud): end raw attachments on connection-fatal session errors
khaliqgant Oct 8, 2026
e3c17c7
docs(changelog): mark the pending release Minor (new SDK subpaths and…
khaliqgant Oct 8, 2026
1ce7c7c
fix(cloud,sdk): hold raw input across reconnects; pin attach transpor…
khaliqgant Oct 8, 2026
5c155d3
fix(sdk,cloud): pin the launcher gateway; refuse raw clients after a …
khaliqgant Oct 8, 2026
5e05ea5
fix(cloud): settle finished with 0 when the raw attach client detaches
khaliqgant Oct 8, 2026
06c3237
Merge remote-tracking branch 'origin/trunk' into relayflow/relay-soft…
khaliqgant Oct 8, 2026
5ec40ec
fix(cloud,sdk): keep handle.attach() on the spawn-pinned transport
khaliqgant Oct 8, 2026
70cfdc1
Merge main into relayflow/relay-software-garden-139d1a46 (keep both c…
khaliqgant Oct 10, 2026
8a7e568
test(relayflows): give sandbox spawn probe mocks a dispatch receipt
khaliqgant Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Trajectory: Publish fleet and attach subpaths and forward sandbox readonlyPaths

> **Status:** ❌ Abandoned
> **Started:** September 18, 2026 at 03:11 PM
> **Completed:** September 18, 2026 at 03:21 PM

---

## Key Decisions

### Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics
- **Chose:** Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics
- **Reasoning:** Reviewed plan identifies Cloud as the consumer import target, no remote exit-code protocol, and no server mount handler in this repository. D4 running-agent orchestration remains pending user scope clarification.

---

## Chapters

### 1. Work
*Agent: default*

- Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics: Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics
- Abandoned: Attach extraction and readonlyPaths client implementation ready; full spawnFleetSandbox orchestration awaits reviewed-plan.md D4 scope decision. Server chmod enforcement is outside this repository.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"id": "traj_qdtkdvki0xoh",
"version": 1,
"task": {
"title": "Publish fleet and attach subpaths and forward sandbox readonlyPaths"
},
"status": "abandoned",
"startedAt": "2026-09-18T15:11:05.520Z",
"completedAt": "2026-09-18T15:21:12.959Z",
"agents": [
{
"name": "default",
"role": "lead",
"joinedAt": "2026-09-18T15:20:29.731Z"
}
],
"chapters": [
{
"id": "chap_jnv5kvm9t42d",
"title": "Work",
"agentName": "default",
"startedAt": "2026-09-18T15:20:29.731Z",
"endedAt": "2026-09-18T15:21:12.959Z",
"events": [
{
"ts": 1789744829731,
"type": "decision",
"content": "Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics: Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics",
"raw": {
"question": "Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics",
"chosen": "Implement shared attach transport in Cloud with SDK re-exports; preserve ESM exports and inferred completion semantics",
"alternatives": [],
"reasoning": "Reviewed plan identifies Cloud as the consumer import target, no remote exit-code protocol, and no server mount handler in this repository. D4 running-agent orchestration remains pending user scope clarification."
},
"significance": "high"
},
{
"ts": 1789744872960,
"type": "note",
"content": "Abandoned: Attach extraction and readonlyPaths client implementation ready; full spawnFleetSandbox orchestration awaits reviewed-plan.md D4 scope decision. Server chmod enforcement is outside this repository.",
"significance": "high"
}
]
}
],
"commits": [],
"filesChanged": [],
"projectId": "AgentWorkforce/relay",
"tags": [],
"_trace": {
"startRef": "c97830275c0849f8ae1ed7fc1f347a5198124b11",
"endRef": "c97830275c0849f8ae1ed7fc1f347a5198124b11"
}
}
2 changes: 1 addition & 1 deletion .github/workflows/relay-evals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
run: npm ci

- name: Build SDK
run: npm run build:sdk
run: npm run build:session && npm run build:config && npm run build:cloud && npm run build:sdk

- name: Run offline evals
run: npm run evals:offline
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The `agent-relay mcp` server adds `upload_file` (local path or base64) and `download_file`, and `send_group_dm` accepts `attachments`.
- Agents spawned by the broker receive message attachments (up to 25 MiB each) as local files: the injected message ends with an `Attachments:` list giving each file's path under `.agent-relay/attachments/` in the agent's working directory, or an `agent-relay message file download <file_id>` command when a file could not be fetched. Messages that carry only attachments are now delivered too.
- `@agent-relay/sdk` adds `files.upload` / `files.get` / `files.download` on `AgentRelay` and `RelaycastMessagingClient`, plus `uploadRelayFile` / `downloadRelayFile` helpers.
- `@agent-relay/sdk/fleet` exports `spawnFleetSandbox`, which provisions a Cloud fleet sandbox, starts an agent harness on it, confirms the node the agent landed on, and returns a handle to the live agent that can be attached to immediately and torn down idempotently. `agent-relay fleet spawn --sandbox` uses the same path.
- `@agent-relay/cloud/attach` and `@agent-relay/sdk/attach` expose fleet terminal attachment with a private local stdio socket and completion promise.
- `@agent-relay/cloud/fleet` and `@agent-relay/sdk/fleet` expose the sandbox ensure and deletion primitives.
- Fleet sandbox ensure and `fleet spawn --sandbox-readonly-path` forward explicit read-only Relayfile subtree requests to Cloud for server-side enforcement.

### Fixed

Expand Down
9 changes: 6 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
"test:integration:broker": "npx tsc -p tests/integration/broker/tsconfig.json && cd tests/integration/broker && node --test dist/*.test.js",
"test:integration:broker:build": "npx tsc -p tests/integration/broker/tsconfig.json",
"test:integration:broker:run": "cd tests/integration/broker && node --test dist/*.test.js",
"eval:build": "npm run build:sdk && npm run build:harness-driver && npx tsc -p tests/integration/broker/evals/tsconfig.json",
"eval:build": "npm run build:session && npm run build:config && npm run build:cloud && npm run build:sdk && npm run build:harness-driver && npx tsc -p tests/integration/broker/evals/tsconfig.json",
"eval:unit": "vitest run tests/integration/broker/evals",
"eval:selftest": "npm run eval:build && node tests/integration/broker/dist/evals/selftest.js",
"eval:toolcheck": "npm run eval:build && node tests/integration/broker/dist/evals/toolcheck-cli.js",
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,19 @@ agent-relay fleet spawn claude \
--task 'Review the live draft under /workspace/live-review/run-123'
```

Use `--sandbox-readonly-path '/reference/**'` to request a read-only Relayfile
subtree. It requires `--sandbox` with Relayfile enabled and accepts explicit
`/path/**` subtrees (no traversal or other wildcards). This client forwards the
request; the deployed Cloud ensure handler must implement chmod enforcement.

The ESM `@agent-relay/cloud/attach` and `@agent-relay/sdk/attach` entries expose
`startFleetNodeAttachProxy({ nodeId, mode })`. An omitted agent is discovered
only when the node has exactly one agent. `socketPath` accepts one raw stdio
connection; `close()` removes it. `finished` reports an inferred status (0 for
terminal closure or detach, 1 for transport failure), because Relaycast does
not currently transmit the remote harness exit code. Existing CLI broker
transport fields remain available for compatibility.

`--session-ref` is a real CLI resume, not a logical collaboration label. Pass
the actual Claude session ID or Codex thread ID and target its origin node.
`--cwd` must name an absolute directory that exists on the selected node. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ describe('fleet CLI lifecycle routing', () => {
} as never;
});
const placement = {
spawn: vi.fn(async () => ({ invocationId: 'inv_lifecycle', node: { name: nodeName } })),
spawn: vi.fn(async () => ({
invocationId: 'inv_lifecycle',
dispatchedNodeId: 'node-1',
node: { name: nodeName },
})),
};
const ensureCloudFleetSandbox = vi.fn(async () => ({
outcome: 'provisioned' as const,
Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/cli/commands/fleet-sandbox-regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ function registerSandboxCommand(overrides: {
placement?: ReturnType<typeof vi.fn>;
}): Command {
const placement = overrides.placement ?? vi.fn(async () => ({ invocationId: 'inv_test' }));
// Like the engine, the dispatch receipt names the node the spawn ran on.
let ensuredNodeId: string | undefined;
const ensureCloudFleetSandbox = async (...args: unknown[]) => {
const result = await overrides.ensureCloudFleetSandbox(...args);
ensuredNodeId = (result as { nodeId?: string } | undefined)?.nodeId;
return result;
};
const spawn = async (input: unknown) => ({
dispatchedNodeId: ensuredNodeId,
...((await placement(input)) as Record<string, unknown>),
});
const register = vi.fn(async () => ({ token: 'at_live_launcher' }));
const release = vi.fn(async () => ({ released: true, deleted: true }));
const createWorkspaceRelay = vi.fn(() => ({
Expand Down Expand Up @@ -69,7 +80,7 @@ function registerSandboxCommand(overrides: {
...(overrides.materializeCloudRelayfileRepository
? { materializeCloudRelayfileRepository: overrides.materializeCloudRelayfileRepository as never }
: {}),
ensureCloudFleetSandbox: overrides.ensureCloudFleetSandbox as never,
ensureCloudFleetSandbox: ensureCloudFleetSandbox as never,
resolveWorkspaceSelection: () => ({
key: 'rk_live_workspace',
source: 'project',
Expand All @@ -79,7 +90,7 @@ function registerSandboxCommand(overrides: {
persistWorkspaceRelaycastTarget: () => true,
deleteCloudFleetSandbox: vi.fn(async () => undefined),
sdk: {
createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn: placement } } })) as never,
createAgentRelay: vi.fn(() => ({ messaging: { placement: { spawn } } })) as never,
createWorkspaceRelay: createWorkspaceRelay as never,
createWorkspace: vi.fn() as never,
log: vi.fn(),
Expand Down
134 changes: 133 additions & 1 deletion packages/cli/src/cli/commands/fleet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ describe('fleet command support', () => {
const placement = {
spawn: vi.fn(async () => ({
invocationId: 'inv_sandbox',
dispatchedNodeId: 'node-1',
node: { name: 'e2b-codex' },
})),
};
Expand Down Expand Up @@ -1178,6 +1179,8 @@ describe('fleet command support', () => {
REPLAY_SANDBOX_NAME,
'--sandbox-relayfile-path',
'/live-review/run-123/**',
'--sandbox-readonly-path',
'/live-review/run-123/reference/**',
'--name',
'sandbox-worker',
'--task',
Expand All @@ -1195,6 +1198,7 @@ describe('fleet command support', () => {
maxAgents: 1,
mountRelayfile: true,
relayfilePaths: ['/live-review/run-123/**'],
readonlyPaths: ['/live-review/run-123/reference/**'],
sandboxId: REPLAY_SANDBOX_ID,
forceProvision: true,
providerId: 'agent37',
Expand Down Expand Up @@ -1253,6 +1257,95 @@ describe('fleet command support', () => {
});
});

it('fleet spawn --sandbox rejects an agent dispatched to another node and tears the sandbox down', async () => {
vi.stubEnv('RELAY_AGENT_TOKEN', undefined);
const placement = {
spawn: vi.fn(async () => ({
invocationId: 'inv_sandbox',
node: { name: 'e2b-codex' },
dispatchedNodeId: 'node-2',
})),
};
const release = vi.fn(async () => ({ released: true, deleted: true }));
const createWorkspaceRelay = vi.fn(() => ({
workspace: {
info: vi.fn(async () => ({ id: 'rw_abc' })),
register: vi.fn(async () => ({ token: 'at_live_launcher' })),
release,
},
}));
const deleteCloudFleetSandbox = vi.fn(async () => undefined);
const errors: string[] = [];
const program = new Command();
program.exitOverride();
registerFleetCommands(program, {
resolveSandboxRepository: vi.fn(() => undefined),
sdk: {
createAgentRelay: vi.fn(() => ({ messaging: { placement } })) as never,
createWorkspaceRelay: createWorkspaceRelay as never,
createWorkspace: vi.fn() as never,
log: vi.fn(),
error: (message: unknown) => errors.push(String(message)),
exit: vi.fn(() => {
throw new Error('__exit__');
}) as never,
},
ensureCloudFleetSandbox: vi.fn(async () => ({
outcome: 'provisioned' as const,
providerId: 'e2b' as const,
cloudWorkspaceId: 'cloud-workspace',
nodeId: 'node-1',
nodeName: 'e2b-codex',
sandboxId: 'sandbox-1',
relayWorkspaceId: 'rw_abc',
relayfileMounted: true,
relayfileMountPath: '/workspace',
})),
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 expect(
program.parseAsync(
[
'fleet',
'spawn',
'codex',
'--sandbox',
'--sandbox-provider',
'e2b',
'--no-sandbox-relayfile',
'--name',
'sandbox-worker',
'--task',
'Wait for VERIFY',
'--workspace-key',
'rk_live_test',
],
{ from: 'user' }
)
).rejects.toThrow('__exit__');
expect(errors.join('\n')).toContain("landed on node 'node-2', not sandbox node 'e2b-codex'");
expect(release).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sandbox-worker', deleteAgent: true })
);
expect(deleteCloudFleetSandbox).toHaveBeenCalledWith({
cloudWorkspaceId: 'cloud-workspace',
sandboxId: 'sandbox-1',
providerId: 'e2b',
});
});

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';
Expand Down Expand Up @@ -2131,7 +2224,11 @@ describe('fleet command support', () => {
const selectionOptions: Record<string, unknown>[] = [];
const persistWorkspaceRelaycastTarget = vi.fn(() => true);
const placement = {
spawn: vi.fn(async () => ({ invocationId: 'inv_inferred', node: { name: 'cloud-node' } })),
spawn: vi.fn(async () => ({
invocationId: 'inv_inferred',
dispatchedNodeId: 'node-1',
node: { name: 'cloud-node' },
})),
};
const register = vi.fn(async () => ({ token: 'at_live_launcher' }));
const release = vi.fn(async () => ({ released: true, deleted: true }));
Expand Down Expand Up @@ -5428,3 +5525,38 @@ describe('fleet command support', () => {
expect(errors.join('\n')).toMatch(/relay cloud enroll/);
});
});

describe('sandbox read-only path guards', () => {
it.each([[], ['--sandbox', '--no-sandbox-relayfile']])(
'rejects unavailable mounts (%j)',
async (...flags) => {
const ensureCloudFleetSandbox = vi.fn();
const error = vi.fn();
const program = new Command();
program.exitOverride();
registerFleetCommands(program, {
ensureCloudFleetSandbox,
sdk: { error, exit: vi.fn() as never },
});
await program.parseAsync(
[
'fleet',
'spawn',
'claude',
...flags,
'--sandbox-readonly-path',
'/reference/**',
'--name',
'worker',
'--task',
'review',
],
{ from: 'user' }
);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('--sandbox-readonly-path requires --sandbox')
);
expect(ensureCloudFleetSandbox).not.toHaveBeenCalled();
}
);
});
Loading
Loading