Skip to content

Commit f6e2170

Browse files
committed
The one-machine file bridge: @path in, outputFile out
Control plane stays embedded (D28); the chat's workbench sandbox becomes the agent's filesystem, bridged by the sim_cli handler in both directions. @-shaped argv tokens are pre-read from the session sandbox into the embed context, and the CLI's OWN argument resolver consults that map — so only genuinely file-accepting flags get file semantics (a literal --text @channel stays literal) and the server's filesystem stays unreadable from model argv (the earlier refusal remains as defense in depth, now worded as this machine's not-found). outputFile lands command stdout on the machine as a file and returns a short ack, so exports and traces never transit the model's context. Find-only by design: a cold machine degrades to inline with boot guidance. Live e2e of the full loop: trace pulled to file (ack only), run_code computed on it, @env.json fed a manual run byte-exact. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 861d493 commit f6e2170

6 files changed

Lines changed: 170 additions & 13 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
4+
5+
const logger = createLogger('SessionSandboxFiles')
6+
7+
/**
8+
* File I/O against an EXISTING session sandbox — the bridge that lets the
9+
* Mothership's embedded CLI treat the chat's workbench as its filesystem:
10+
* `@path` arguments read from it, `outputFile` writes command output into it,
11+
* while the CLI itself keeps executing in-process on the server. Find-only by
12+
* design: booting the machine belongs to the execution path (run_code), so a
13+
* missing session degrades to an actionable answer instead of paying a sandbox
14+
* spin-up inside a CLI call.
15+
*
16+
* Session sandboxes exist only on providers with session support (E2B), whose
17+
* mothership image runs the default user at this home directory — the pinned
18+
* cwd contract for relative paths.
19+
*/
20+
export const SESSION_SANDBOX_HOME = '/home/user'
21+
22+
const READ_LIMIT_BYTES = 4 * 1024 * 1024
23+
24+
export function resolveSessionPath(path: string): string {
25+
return path.startsWith('/') ? path : `${SESSION_SANDBOX_HOME}/${path}`
26+
}
27+
28+
export type SessionFileRead =
29+
| { outcome: 'read'; content: string }
30+
| { outcome: 'no-session' }
31+
| { outcome: 'no-file'; detail: string }
32+
33+
export async function readSessionSandboxFile(
34+
sessionKey: string,
35+
path: string
36+
): Promise<SessionFileRead> {
37+
const provider = resolveProvider()
38+
if (!provider.findSessionSandbox) return { outcome: 'no-session' }
39+
let sandbox: Awaited<ReturnType<NonNullable<typeof provider.findSessionSandbox>>>
40+
try {
41+
sandbox = await provider.findSessionSandbox(sessionKey, {})
42+
} catch (error) {
43+
logger.warn('Session sandbox lookup failed for file read', {
44+
sessionKey,
45+
error: getErrorMessage(error),
46+
})
47+
return { outcome: 'no-session' }
48+
}
49+
if (!sandbox) return { outcome: 'no-session' }
50+
try {
51+
const file = await sandbox.readFileWithLimit(resolveSessionPath(path), {
52+
maxBytes: READ_LIMIT_BYTES,
53+
encoding: 'utf8',
54+
})
55+
return { outcome: 'read', content: file.content }
56+
} catch (error) {
57+
return { outcome: 'no-file', detail: getErrorMessage(error) }
58+
}
59+
}
60+
61+
export type SessionFileWrite = { outcome: 'written'; path: string } | { outcome: 'no-session' }
62+
63+
export async function writeSessionSandboxFile(
64+
sessionKey: string,
65+
path: string,
66+
content: string
67+
): Promise<SessionFileWrite> {
68+
const provider = resolveProvider()
69+
if (!provider.findSessionSandbox) return { outcome: 'no-session' }
70+
let sandbox: Awaited<ReturnType<NonNullable<typeof provider.findSessionSandbox>>>
71+
try {
72+
sandbox = await provider.findSessionSandbox(sessionKey, {})
73+
} catch (error) {
74+
logger.warn('Session sandbox lookup failed for file write', {
75+
sessionKey,
76+
error: getErrorMessage(error),
77+
})
78+
return { outcome: 'no-session' }
79+
}
80+
if (!sandbox) return { outcome: 'no-session' }
81+
const resolved = resolveSessionPath(path)
82+
await sandbox.writeFile(resolved, content)
83+
return { outcome: 'written', path: resolved }
84+
}

apps/sim/lib/mothership/tools/handlers/sim-cli.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { createLogger } from '@sim/logger'
22
import { createEmbeddedClient, type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed'
33
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
4+
import {
5+
readSessionSandboxFile,
6+
writeSessionSandboxFile,
7+
} from '@/lib/execution/remote-sandbox/session-files'
48
import { mintDelegationToken } from '@/lib/mothership/chat/delegation'
59
import type {
610
ToolExecutionContext,
@@ -44,11 +48,31 @@ export async function executeSimCli(
4448
if (!context.workspaceId) {
4549
return { success: false, error: 'sim_cli requires a workspace-scoped execution context.' }
4650
}
47-
const { cliArgs: args, stages } = splitPipeline(rawArgs)
48-
if (args.length === 0) {
51+
const { cliArgs: rawCliArgs, stages } = splitPipeline(rawArgs)
52+
if (rawCliArgs.length === 0) {
4953
return { success: false, error: 'A pipe needs a sim CLI invocation before the first |.' }
5054
}
5155

56+
const args = rawCliArgs
57+
58+
// The chat's workbench sandbox is the agent's filesystem: every @-shaped token
59+
// is pre-read from it into a map the embedded CLI's OWN argument resolver
60+
// consults — so only genuinely file-aware flags get file semantics (a literal
61+
// `--text @channel` stays literal), and the server's filesystem is never
62+
// readable from model argv. A token that names no sandbox file is simply
63+
// absent from the map; the resolver's refusal then says so.
64+
const sessionKey = context.chatId ? `mothership-chat:${context.chatId}` : null
65+
const fileArguments: Record<string, string> = {}
66+
if (sessionKey) {
67+
for (const token of args) {
68+
if (!token.startsWith('@') || token.startsWith('@@') || token === '@-') continue
69+
const path = token.slice(1)
70+
if (fileArguments[path] !== undefined) continue
71+
const read = await readSessionSandboxFile(sessionKey, path)
72+
if (read.outcome === 'read') fileArguments[path] = read.content
73+
}
74+
}
75+
5276
// Stages are validated before the CLI runs: a mutating command must never
5377
// execute and then fail on a malformed pipe, or a model retry would repeat
5478
// the mutation.
@@ -77,7 +101,7 @@ export async function executeSimCli(
77101
workspaceId: context.workspaceId,
78102
userId: context.userId,
79103
})
80-
: await runEmbeddedCli(args, identity)
104+
: await runEmbeddedCli(args, identity, { fileArguments })
81105
if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) {
82106
result.stdout += agentCliHelpSection()
83107
}
@@ -86,6 +110,24 @@ export async function executeSimCli(
86110
if (piped.ok) result.stdout = piped.stdout
87111
}
88112

113+
// outputFile: land large stdout directly on the agent's machine instead of
114+
// returning it through the model window — the other half of the file bridge.
115+
const outputFile = typeof params.outputFile === 'string' ? params.outputFile.trim() : ''
116+
if (outputFile && result.exitCode === 0) {
117+
if (!sessionKey) {
118+
result.stdout +=
119+
'\n[outputFile not written: no chat-scoped machine — output returned inline instead]'
120+
} else {
121+
const written = await writeSessionSandboxFile(sessionKey, outputFile, result.stdout)
122+
if (written.outcome === 'written') {
123+
result.stdout = `[stdout written to ${outputFile} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${outputFile}.]`
124+
} else {
125+
result.stdout +=
126+
'\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]'
127+
}
128+
}
129+
}
130+
89131
logger.info('CLI invocation finished', {
90132
exitCode: result.exitCode,
91133
argv0: args[0],

packages/sim-cli/src/embed-context.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface EmbedContext {
1717
identity: EmbeddedCliIdentity
1818
stdout: string[]
1919
stderr: string[]
20+
/**
21+
* Pre-read contents for `@path` file arguments, keyed by the path as written
22+
* (without the `@`). The host resolves these from the caller's own file
23+
* surface before the run; the in-process CLI never touches the server's
24+
* filesystem.
25+
*/
26+
fileArguments?: Record<string, string>
2027
}
2128

2229
export const embedStore = new AsyncLocalStorage<EmbedContext>()

packages/sim-cli/src/embed.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,16 @@ export function createEmbeddedClient(identity: EmbeddedCliIdentity): SimClient {
6868
*/
6969
export async function runEmbeddedCli(
7070
argv: string[],
71-
identity: EmbeddedCliIdentity
71+
identity: EmbeddedCliIdentity,
72+
options?: { fileArguments?: Record<string, string> }
7273
): Promise<EmbeddedCliResult> {
7374
installEmbedSinks()
74-
const ctx: EmbedContext = { identity, stdout: [], stderr: [] }
75+
const ctx: EmbedContext = {
76+
identity,
77+
stdout: [],
78+
stderr: [],
79+
...(options?.fileArguments ? { fileArguments: options.fileArguments } : {}),
80+
}
7581
return embedStore.run(ctx, async () => {
7682
let exitCode = 0
7783
try {

packages/sim-cli/src/runtime/embedded-file-args.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,34 @@ import { embedStore } from '../embed-context'
66
import { readArgumentSource } from './request'
77

88
describe('file arguments in embedded runs', () => {
9-
it('refuses @path reads in-process, with inline guidance', () => {
9+
it('refuses @path reads in-process when the host preloaded nothing', () => {
1010
const ctx = {
1111
identity: { endpoint: 'http://x', apiKey: 'k' },
1212
stdout: [] as string[],
1313
stderr: [] as string[],
1414
}
1515
embedStore.run(ctx, () => {
1616
expect(() => readArgumentSource('@/etc/hostname', 'input')).toThrow(
17-
/not available in embedded runs.*inline/
17+
/no file "\/etc\/hostname" on this machine/
1818
)
1919
})
2020
})
2121

22+
it('serves @path from host-preloaded file arguments, never local disk', () => {
23+
const ctx = {
24+
identity: { endpoint: 'http://x', apiKey: 'k' },
25+
stdout: [] as string[],
26+
stderr: [] as string[],
27+
fileArguments: { 'env.json': '{"thread":"t1"}' },
28+
}
29+
embedStore.run(ctx, () => {
30+
const resolved = readArgumentSource('@env.json', 'input')
31+
expect(resolved.text).toBe('{"thread":"t1"}')
32+
expect(resolved.from).toContain('your machine')
33+
expect(() => readArgumentSource('@other.json', 'input')).toThrow(/no file "other.json"/)
34+
})
35+
})
36+
2237
it('keeps @@ literal escape and inline values working embedded', () => {
2338
const ctx = {
2439
identity: { endpoint: 'http://x', apiKey: 'k' },

packages/sim-cli/src/runtime/request.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,16 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin
203203
if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' }
204204
if (!raw.startsWith('@')) return { text: raw, from: '' }
205205

206-
// An embedded run executes in-process on the hosting server, so a file path
207-
// here would read the SERVER's filesystem with argv the model controls.
208-
// There is no local file a caller could legitimately mean; the value must
209-
// arrive inline (or as @@-escaped literal text).
210-
if (embedStore.getStore()) {
206+
// An embedded run executes in-process on the hosting server, so a raw file
207+
// path here would read the SERVER's filesystem with argv the model controls.
208+
// The host may pre-resolve @paths from the caller's own file surface into the
209+
// embed context; anything else is refused — never read from local disk.
210+
const embedded = embedStore.getStore()
211+
if (embedded) {
212+
const preloaded = embedded.fileArguments?.[raw.slice(1)]
213+
if (preloaded !== undefined) return { text: preloaded, from: ' (read from your machine)' }
211214
throw new SimApiError(
212-
`--${flagName} file arguments (@path) are not available in embedded runs — pass the JSON inline as a single argument`,
215+
`--${flagName}: no file "${raw.slice(1)}" on this machine — write it first (run_code), or pass the value inline`,
213216
0
214217
)
215218
}

0 commit comments

Comments
 (0)