Skip to content

Commit 6fddc12

Browse files
committed
mothership agent-cli: fixes from the exploration run
- docs search: the engine builds the secret-trace registry (prepareCopilotEnvironmentContext) — without it the docs tool refused every query as 'could not be processed safely'. - universal grep: workspaceId on the list and detail requests that require it (the export route takes none); the A2 tests had mocked the client. - run-cli: strip ANSI escapes from CLI stdout/stderr (chalk keys off the hosting server's TTY). - pipeline: the jq/outline non-JSON error names | grep instead of a flag the caller already passed; the to-sandbox notice names /home/user/<name>. - sim-cli embed path only (public CLI unchanged): positional file arguments (tables import, files upload, knowledge documents upload) read from the host's pre-read @path map and refuse anything else; files get --output-file writes through EmbedContext.writeFile (the chat sandbox) instead of the server's disk, which is where it had been landing. embedded-files.test.ts. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8
1 parent d4fed76 commit 6fddc12

13 files changed

Lines changed: 187 additions & 15 deletions

File tree

apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ const { execute } = vi.hoisted(() => ({
88
.fn()
99
.mockResolvedValue({ results: [{ path: 'docs/integrations/slack.mdx' }], query: 'q' }),
1010
}))
11+
vi.mock('@/lib/mothership/environment-context', () => ({
12+
prepareCopilotEnvironmentContext: vi.fn(async () => ({
13+
resolvedSecretTraceRegistry: { registry: 'stub' },
14+
})),
15+
}))
16+
1117
vi.mock('@/lib/mothership/tools/server/docs/search-docs', () => ({
1218
searchDocsServerTool: { execute },
1319
}))
@@ -30,7 +36,7 @@ describe('docs search engine', () => {
3036
expect(result.exitCode).toBe(0)
3137
expect(execute).toHaveBeenCalledWith(
3238
{ query: 'slack streaming', topK: 3, path: 'docs/integrations' },
33-
{ userId: 'user-1', workspaceId: 'ws-1' }
39+
{ userId: 'user-1', workspaceId: 'ws-1', resolvedSecretTraceRegistry: { registry: 'stub' } }
3440
)
3541
expect(JSON.parse(result.stdout).results[0].path).toBe('docs/integrations/slack.mdx')
3642
})

apps/sim/lib/mothership/agent-cli/engines/docs-search.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
agentCliFail,
55
agentCliOk,
66
} from '@/lib/mothership/agent-cli/types'
7+
import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context'
78
import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs'
89

910
const DEFAULT_TOP = 6
@@ -29,9 +30,13 @@ export const docsSearchCommand: AgentCliEngine = {
2930
const top = topFrom(flags)
3031
if (typeof top === 'string') return agentCliFail(top)
3132
const path = typeof flags.path === 'string' ? flags.path : undefined
33+
const { resolvedSecretTraceRegistry } = await prepareCopilotEnvironmentContext(
34+
runtime.userId,
35+
runtime.workspaceId
36+
)
3237
const output = await searchDocsServerTool.execute(
3338
{ query, topK: top, ...(path ? { path } : {}) },
34-
{ userId: runtime.userId, workspaceId: runtime.workspaceId }
39+
{ userId: runtime.userId, workspaceId: runtime.workspaceId, resolvedSecretTraceRegistry }
3540
)
3641
return agentCliOk(JSON.stringify(output, null, 2))
3742
},

apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async function listAll(runtime: AgentCliRuntime, path: string): Promise<Record<s
5959
let cursor: string | undefined
6060
for (let pages = 0; pages < 50; pages++) {
6161
const page = await runtime.client.request<Page>(path, {
62-
query: { limit: '100', ...(cursor ? { cursor } : {}) },
62+
query: { workspaceId: runtime.workspaceId, limit: '100', ...(cursor ? { cursor } : {}) },
6363
})
6464
out.push(...page.data)
6565
if (!page.nextCursor) break
@@ -96,6 +96,8 @@ const MATERIALIZERS: Record<Scope, (runtime: AgentCliRuntime) => Promise<Materia
9696
const list = await listAll(runtime, '/api/v2/workflows')
9797
return mapConcurrent(list, FETCH_CONCURRENCY, async (w) => {
9898
const id = str(w.id) ?? ''
99+
// The export route scopes by workflow id alone (`query: noInputSchema`); a
100+
// workspaceId here is an "Unrecognized key" — the other detail routes require it.
99101
const exported = await runtime.client.request<{ data: { state: unknown } }>(
100102
`/api/v2/workflows/${id}/export`
101103
)
@@ -109,7 +111,9 @@ const MATERIALIZERS: Record<Scope, (runtime: AgentCliRuntime) => Promise<Materia
109111
const list = await listAll(runtime, '/api/v2/blocks')
110112
const materialized = await mapConcurrent(list, FETCH_CONCURRENCY, async (b) => {
111113
const id = str(b.id) ?? ''
112-
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`)
114+
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`, {
115+
query: { workspaceId: runtime.workspaceId },
116+
})
113117
return render('blocks', id, id, detail.data)
114118
})
115119
catalogCache.set(key, materialized)
@@ -123,23 +127,29 @@ const MATERIALIZERS: Record<Scope, (runtime: AgentCliRuntime) => Promise<Materia
123127
const list = await listAll(runtime, '/api/v2/tables')
124128
return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => {
125129
const id = str(t.id) ?? ''
126-
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`)
130+
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`, {
131+
query: { workspaceId: runtime.workspaceId },
132+
})
127133
return render('tables', id, str(t.name) ?? id, detail.data)
128134
})
129135
},
130136
skills: async (runtime) => {
131137
const list = await listAll(runtime, '/api/v2/skills')
132138
return mapConcurrent(list, FETCH_CONCURRENCY, async (s) => {
133139
const id = str(s.id) ?? ''
134-
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`)
140+
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`, {
141+
query: { workspaceId: runtime.workspaceId },
142+
})
135143
return render('skills', id, str(s.name) ?? id, detail.data)
136144
})
137145
},
138146
'custom-tools': async (runtime) => {
139147
const list = await listAll(runtime, '/api/v2/custom-tools')
140148
return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => {
141149
const id = str(t.id) ?? ''
142-
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`)
150+
const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`, {
151+
query: { workspaceId: runtime.workspaceId },
152+
})
143153
return render('custom-tools', id, str(t.title) ?? str(t.name) ?? id, detail.data)
144154
})
145155
},

apps/sim/lib/mothership/agent-cli/pipeline.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe('jq and outline over JSON stdout', () => {
113113

114114
it('fails the invocation with the reason when stdout is not JSON or the program is bad', async () => {
115115
expect(await applyPipeline('plain text', [{ kind: 'jq', expression: '.' }])).toContain(
116-
'stdout is not JSON'
116+
'output is text, not JSON'
117117
)
118118
expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data |' }])).toContain('jq:')
119119
})

apps/sim/lib/mothership/agent-cli/pipeline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function parseJsonStdout(stdout: string, stage: string): JsonValue {
6363
return parsed
6464
} catch {
6565
throw new PipeStageError(
66-
`${stage}: stdout is not JSON. Run the command with --output json before piping into ${stage}.`
66+
`${stage}: this command's output is text, not JSON, so ${stage} cannot apply. Filter it with | grep instead, or use outputs get <id> --grep on a stored result.`
6767
)
6868
}
6969
}

apps/sim/lib/mothership/agent-cli/run-cli.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed'
2-
import { readSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files'
2+
import {
3+
readSessionSandboxFile,
4+
writeSessionSandboxFile,
5+
} from '@/lib/execution/remote-sandbox/session-files'
36
import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli'
47

58
/**
@@ -27,5 +30,27 @@ export async function runCli(
2730
if (read.outcome === 'read') fileArguments[path] = read.content
2831
}
2932
}
30-
return runEmbeddedCli(argv, identity, { fileArguments })
33+
// Downloads land on the same machine `@path` reads from; without a sandbox session
34+
// the CLI refuses rather than writing to the server's disk.
35+
const writeFile = sessionKey
36+
? async (path: string, content: Uint8Array) =>
37+
(await writeSessionSandboxFile(sessionKey, path, Buffer.from(content).toString('utf8')))
38+
.outcome === 'written'
39+
: undefined
40+
const result = await runEmbeddedCli(argv, identity, {
41+
fileArguments,
42+
...(writeFile ? { writeFile } : {}),
43+
})
44+
return { ...result, stdout: stripAnsi(result.stdout), stderr: stripAnsi(result.stderr) }
45+
}
46+
47+
const ANSI_SEQUENCE = /\[[0-9;?]*[ -/]*[@-~]/g
48+
49+
/**
50+
* The CLI colours its notes with chalk, which keys off the HOSTING server's TTY — so an
51+
* embedded run on a dev server hands the model `…` around every
52+
* truncation notice. The model reads text, never a terminal: strip escapes on the way out.
53+
*/
54+
function stripAnsi(text: string): string {
55+
return text.replace(ANSI_SEQUENCE, '')
3156
}

apps/sim/lib/mothership/agent-cli/sink.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { writeSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files'
1+
import {
2+
resolveSessionPath,
3+
writeSessionSandboxFile,
4+
} from '@/lib/execution/remote-sandbox/session-files'
25
import type { AgentCliRawResult, AgentCliSink } from '@/lib/mothership/generated/agent-cli'
36

47
/**
@@ -22,7 +25,7 @@ export async function applySink(
2225
if (written.outcome === 'written') {
2326
return {
2427
...result,
25-
stdout: `[stdout written to ${sink.path} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${sink.path}.]`,
28+
stdout: `[stdout written to ${resolveSessionPath(sink.path)} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${sink.path}.]`,
2629
}
2730
}
2831
if (written.outcome === 'no-session') {

packages/sim-cli/src/commands/protocol/files-get.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Readable, type Writable } from 'node:stream'
66
import { pipeline } from 'node:stream/promises'
77
import type { Command } from 'commander'
88
import { clientFrom } from '../../context'
9+
import { embedStore } from '../../embed-context'
910
import { V2_OPERATIONS } from '../../generated/v2-api'
1011
import { isRequestTimeout, RAISE_TIMEOUT_HINT, resolvePath, SimApiError } from '../../http/client'
1112
import { printProtocolResult } from './result'
@@ -202,6 +203,26 @@ export async function saveToFile(
202203
target: string,
203204
force: boolean
204205
): Promise<void> {
206+
const embedded = embedStore.getStore()
207+
if (embedded) {
208+
// In-process on the hosting server: the only legitimate destination is the
209+
// caller's own machine, and the host decides how to reach it.
210+
if (!embedded.writeFile) {
211+
throw new SimApiError(
212+
`--output-file cannot save ${target} here: this surface has no machine to write to. Read the file instead, or pipe a text command with | to-sandbox <name>.`,
213+
0
214+
)
215+
}
216+
const bytes = new Uint8Array(await new Response(body).arrayBuffer())
217+
const written = await embedded.writeFile(target, bytes)
218+
if (!written) {
219+
throw new SimApiError(
220+
`Could not write ${target} on your machine — the workbench is not running yet; run any run_code call first, then retry.`,
221+
0
222+
)
223+
}
224+
return
225+
}
205226
return saveStagedFile(body, target, force)
206227
}
207228

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface EmbedContext {
3131
* clear another's failure.
3232
*/
3333
softExitCode?: number
34+
/**
35+
* Where a download lands when embedded: the host writes to the caller's own machine
36+
* (the chat's sandbox), never to the server's disk. Resolves true when written, false
37+
* when the caller has no machine to write to right now.
38+
*/
39+
writeFile?: (path: string, content: Uint8Array) => Promise<boolean>
3440
}
3541

3642
/** The embedded-vs-standalone seam for soft-fail codes: context when embedded, global otherwise. */

packages/sim-cli/src/embed.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,15 @@ export function createEmbeddedClient(identity: EmbeddedCliIdentity): SimClient {
6969
export async function runEmbeddedCli(
7070
argv: string[],
7171
identity: EmbeddedCliIdentity,
72-
options?: { fileArguments?: Record<string, string> }
72+
options?: { fileArguments?: Record<string, string>; writeFile?: EmbedContext['writeFile'] }
7373
): Promise<EmbeddedCliResult> {
7474
installEmbedSinks()
7575
const ctx: EmbedContext = {
7676
identity,
7777
stdout: [],
7878
stderr: [],
7979
...(options?.fileArguments ? { fileArguments: options.fileArguments } : {}),
80+
...(options?.writeFile ? { writeFile: options.writeFile } : {}),
8081
}
8182
return embedStore.run(ctx, async () => {
8283
let exitCode = 0

0 commit comments

Comments
 (0)