From 8fbe7a10ded831ef2a515761464bfb481dabbd99 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 12 Jun 2026 11:21:58 -0300 Subject: [PATCH 1/2] feat(console): friendly chat views for shell::* function calls Add a shell renderer module to the console chat, covering all 16 shell worker functions (exec, exec_bg, status, kill, list, config-status and the 10 fs::* operations) with terminal-style visualizations matching the existing sandbox/coder/web modules, instead of the raw REQUEST/RESPONSE JSON fallback. - zod schemas mirror the shell worker wire types exactly (serde-faithful optionality: target optional-not-nullable, kill reason skip-serialized, transparent fs::stat, chmod updated alias, stream-only fs::read) - parseShellErrorDisplay recovers S-codes both from flat error bodies and from harness gate-denial envelopes whose reason embeds the code as text (the path real shell errors take today) - approval previews for shell::exec, exec_bg and kill - reuse sandbox primitives; extract FsEntriesTable, GrepMatchList, SedResultsTable and export collectErrorCandidates from the sandbox module (verbatim moves, additive only) - 86 tests pinning wire-shape decisions and error paths Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/chat/FunctionCallMessage.tsx | 10 +- .../components/chat/sandbox/FsGrepView.tsx | 79 +- .../src/components/chat/sandbox/FsLsView.tsx | 71 +- .../src/components/chat/sandbox/FsSedView.tsx | 121 +-- .../src/components/chat/sandbox/parsers.ts | 7 +- .../chat/shell/ConfigStatusView.tsx | 65 ++ .../src/components/chat/shell/ExecBgView.tsx | 91 +++ .../src/components/chat/shell/ExecView.tsx | 126 +++ .../src/components/chat/shell/FsChmodView.tsx | 44 ++ .../src/components/chat/shell/FsGrepView.tsx | 61 ++ .../src/components/chat/shell/FsLsView.tsx | 38 + .../src/components/chat/shell/FsMkdirView.tsx | 48 ++ .../src/components/chat/shell/FsMvView.tsx | 49 ++ .../src/components/chat/shell/FsReadView.tsx | 58 ++ .../src/components/chat/shell/FsRmView.tsx | 56 ++ .../src/components/chat/shell/FsSedView.tsx | 66 ++ .../src/components/chat/shell/FsStatView.tsx | 44 ++ .../src/components/chat/shell/FsWriteView.tsx | 129 ++++ .../src/components/chat/shell/KillView.tsx | 77 ++ .../src/components/chat/shell/ListView.tsx | 107 +++ .../src/components/chat/shell/StatusView.tsx | 110 +++ .../chat/shell/__tests__/format.test.ts | 95 +++ .../chat/shell/__tests__/parsers.test.ts | 715 ++++++++++++++++++ .../web/src/components/chat/shell/format.ts | 59 ++ .../web/src/components/chat/shell/index.tsx | 126 +++ .../web/src/components/chat/shell/parsers.ts | 587 ++++++++++++++ .../web/src/components/chat/shell/shared.tsx | 43 ++ 27 files changed, 2967 insertions(+), 115 deletions(-) create mode 100644 console/web/src/components/chat/shell/ConfigStatusView.tsx create mode 100644 console/web/src/components/chat/shell/ExecBgView.tsx create mode 100644 console/web/src/components/chat/shell/ExecView.tsx create mode 100644 console/web/src/components/chat/shell/FsChmodView.tsx create mode 100644 console/web/src/components/chat/shell/FsGrepView.tsx create mode 100644 console/web/src/components/chat/shell/FsLsView.tsx create mode 100644 console/web/src/components/chat/shell/FsMkdirView.tsx create mode 100644 console/web/src/components/chat/shell/FsMvView.tsx create mode 100644 console/web/src/components/chat/shell/FsReadView.tsx create mode 100644 console/web/src/components/chat/shell/FsRmView.tsx create mode 100644 console/web/src/components/chat/shell/FsSedView.tsx create mode 100644 console/web/src/components/chat/shell/FsStatView.tsx create mode 100644 console/web/src/components/chat/shell/FsWriteView.tsx create mode 100644 console/web/src/components/chat/shell/KillView.tsx create mode 100644 console/web/src/components/chat/shell/ListView.tsx create mode 100644 console/web/src/components/chat/shell/StatusView.tsx create mode 100644 console/web/src/components/chat/shell/__tests__/format.test.ts create mode 100644 console/web/src/components/chat/shell/__tests__/parsers.test.ts create mode 100644 console/web/src/components/chat/shell/format.ts create mode 100644 console/web/src/components/chat/shell/index.tsx create mode 100644 console/web/src/components/chat/shell/parsers.ts create mode 100644 console/web/src/components/chat/shell/shared.tsx diff --git a/console/web/src/components/chat/FunctionCallMessage.tsx b/console/web/src/components/chat/FunctionCallMessage.tsx index 5d4747c1..44fc1f5f 100644 --- a/console/web/src/components/chat/FunctionCallMessage.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.tsx @@ -9,6 +9,7 @@ import { SandboxFunctionIdLabel, SandboxToolView, } from '@/components/chat/sandbox' +import { ShellFunctionIdLabel, ShellToolView } from '@/components/chat/shell' import { WebFunctionIdLabel, WebToolView } from '@/components/chat/web' import { WorkerFunctionIdLabel, WorkerToolView } from '@/components/chat/worker' import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton' @@ -118,6 +119,9 @@ function FunctionIdLabel({ functionId }: { functionId: string }) { if (SandboxToolView.isSandboxFunction(functionId)) { return } + if (ShellToolView.isShellFunction(functionId)) { + return + } return {functionId} } @@ -144,14 +148,16 @@ export function FunctionCallMessage({ DirectoryToolView.tryRenderPreview(message) ?? WorkerToolView.tryRenderPreview(message) ?? WebToolView.tryRenderPreview(message) ?? - CoderToolView.tryRenderPreview(message) + CoderToolView.tryRenderPreview(message) ?? + ShellToolView.tryRenderPreview(message) const customTerminal = !pending ? (SandboxToolView.tryRender(message) ?? EngineToolView.tryRender(message) ?? DirectoryToolView.tryRender(message) ?? WorkerToolView.tryRender(message) ?? WebToolView.tryRender(message) ?? - CoderToolView.tryRender(message)) + CoderToolView.tryRender(message) ?? + ShellToolView.tryRender(message)) : null const hasCustomTerminal = customTerminal != null const showRequestPaneAbove = diff --git a/console/web/src/components/chat/sandbox/FsGrepView.tsx b/console/web/src/components/chat/sandbox/FsGrepView.tsx index 8fda9759..bb640b57 100644 --- a/console/web/src/components/chat/sandbox/FsGrepView.tsx +++ b/console/web/src/components/chat/sandbox/FsGrepView.tsx @@ -1,5 +1,6 @@ import { renderWithHighlight } from './highlight' import { + type FsMatch, fsGrepRequestSchema, fsGrepResponseSchema, safeParseResponse, @@ -35,35 +36,57 @@ export function FsGrepView({ input, output }: FsGrepViewProps) { · no matches ) : ( -
- {matches.map((m) => ( -
-
- {m.path} - : - {m.line} -
-
-                
-                  {/* sandbox grep patterns are always regexes on the wire */}
-                  {renderWithHighlight(m.content, req.data.pattern, {
-                    isRegex: true,
-                    ignoreCase: !!req.data.ignore_case,
-                  })}
-                
-              
-
- ))} -
+ )} ) } + +interface GrepMatchListProps { + matches: FsMatch[] + pattern: string + ignoreCase: boolean +} + +/** Highlighted match list. Shared with the shell module's `fs::grep` + renderer — both wires speak `FsMatch` and regex-by-default patterns. */ +export function GrepMatchList({ + matches, + pattern, + ignoreCase, +}: GrepMatchListProps) { + return ( +
+ {matches.map((m) => ( +
+
+ {m.path} + : + {m.line} +
+
+            
+              {/* sandbox grep patterns are always regexes on the wire */}
+              {renderWithHighlight(m.content, pattern, {
+                isRegex: true,
+                ignoreCase,
+              })}
+            
+          
+
+ ))} +
+ ) +} diff --git a/console/web/src/components/chat/sandbox/FsLsView.tsx b/console/web/src/components/chat/sandbox/FsLsView.tsx index b85b3b0e..a3e36438 100644 --- a/console/web/src/components/chat/sandbox/FsLsView.tsx +++ b/console/web/src/components/chat/sandbox/FsLsView.tsx @@ -1,6 +1,7 @@ import { File, FileText, Folder, Link as LinkIcon } from 'lucide-react' import { formatBytes, formatMode, formatMtime } from './format' import { + type FsEntry, fsLsRequestSchema, fsLsResponseSchema, safeParseResponse, @@ -30,42 +31,50 @@ export function FsLsView({ input, output }: FsLsViewProps) { · directory is empty ) : ( - - - {entries.map((e) => { - const Icon = e.is_symlink - ? LinkIcon - : e.is_dir - ? Folder - : iconForFile(e.name) - return ( - - - - - - - - ) - })} - -
- - {e.name} - {e.is_dir ? '—' : formatBytes(e.size)} - - {`${e.is_dir ? 'd' : '-'}${formatMode(e.mode)}`} - - {formatMtime(e.mtime)} -
+ )} ) } +/** Directory-listing table. Shared with the shell module's `fs::ls` + renderer — both wires speak `FsEntry`. */ +export function FsEntriesTable({ entries }: { entries: FsEntry[] }) { + return ( + + + {entries.map((e) => { + const Icon = e.is_symlink + ? LinkIcon + : e.is_dir + ? Folder + : iconForFile(e.name) + return ( + + + + + + + + ) + })} + +
+ + {e.name} + {e.is_dir ? '—' : formatBytes(e.size)} + + {`${e.is_dir ? 'd' : '-'}${formatMode(e.mode)}`} + + {formatMtime(e.mtime)} +
+ ) +} + function iconForFile(name: string) { const lower = name.toLowerCase() if (/\.(md|txt|json|yml|yaml|toml|csv|log)$/.test(lower)) return FileText diff --git a/console/web/src/components/chat/sandbox/FsSedView.tsx b/console/web/src/components/chat/sandbox/FsSedView.tsx index 182b5285..26c3b5ec 100644 --- a/console/web/src/components/chat/sandbox/FsSedView.tsx +++ b/console/web/src/components/chat/sandbox/FsSedView.tsx @@ -5,6 +5,7 @@ import { TooltipTrigger, } from '@/components/ui/Tooltip' import { + type FsSedFileResult, fsSedRequestSchema, fsSedResponseSchema, safeParseResponse, @@ -42,60 +43,74 @@ export function FsSedView({ input, output }: FsSedViewProps) { · no files touched ) : ( - - - - - - - - - - {results.map((r) => ( - - - - - - ))} - - - - - -
path - replacements - status
{r.path} - {r.replacements} - - {r.success ? ( - ok - ) : r.error ? ( - - - - - err - - - {r.error} - - ) : ( - err - )} -
- total - - 0 ? 'accent' : 'default'} - > - {`${total_replacements} replacements`} - -
+ )} ) } + +interface SedResultsTableProps { + results: FsSedFileResult[] + totalReplacements: number +} + +/** Per-file replacement table with the total pill. Shared with the shell + module's `fs::sed` renderer — both wires speak `FsSedFileResult`. */ +export function SedResultsTable({ + results, + totalReplacements, +}: SedResultsTableProps) { + return ( + + + + + + + + + + {results.map((r) => ( + + + + + + ))} + + + + + +
path + replacements + status
{r.path} + {r.replacements} + + {r.success ? ( + ok + ) : r.error ? ( + + + + + err + + + {r.error} + + ) : ( + err + )} +
+ total + + 0 ? 'accent' : 'default'}> + {`${totalReplacements} replacements`} + +
+ ) +} diff --git a/console/web/src/components/chat/sandbox/parsers.ts b/console/web/src/components/chat/sandbox/parsers.ts index efc75f3e..9458270e 100644 --- a/console/web/src/components/chat/sandbox/parsers.ts +++ b/console/web/src/components/chat/sandbox/parsers.ts @@ -511,7 +511,12 @@ function invocationFromFunctionError( } } -function collectErrorCandidates(value: unknown): unknown[] { +/** Gather every sub-value that might carry an error payload: the value + itself, its unwrapped envelope, `value.error`, `error.details`, + `error.message`, and `content[]` text blocks. Shared with the shell + module's `parseShellErrorDisplay` — same harness wrapping, same + traversal. */ +export function collectErrorCandidates(value: unknown): unknown[] { const seen = new Set() const out: unknown[] = [] const push = (candidate: unknown) => { diff --git a/console/web/src/components/chat/shell/ConfigStatusView.tsx b/console/web/src/components/chat/shell/ConfigStatusView.tsx new file mode 100644 index 00000000..e38bf750 --- /dev/null +++ b/console/web/src/components/chat/shell/ConfigStatusView.tsx @@ -0,0 +1,65 @@ +import { StatusPill } from '@/components/chat/sandbox/shared' +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { cn } from '@/lib/utils' +import { safeParseResponse, shellConfigStatusResponseSchema } from './parsers' + +interface ShellConfigStatusViewProps { + output: unknown + running?: boolean +} + +/** + * `shell::config-status` — the worker's config-reload health. A slab + * with the last reload outcome, a rejected-reload counter (warn pill + * when non-zero), and the last rejection error verbatim. The request + * payload is ignored server-side, so nothing request-derived renders. + */ +export function ShellConfigStatusView({ + output, + running, +}: ShellConfigStatusViewProps) { + const resp = + output != null + ? safeParseResponse(shellConfigStatusResponseSchema, output) + : null + + if (!resp) { + if (!running) return null + return ( +
+ checking config… +
+ ) + } + + const applied = resp.last_outcome === 'applied' + return ( +
+
+
+ + {resp.rejected_reloads > 0 ? ( + + {`rejected reloads ${resp.rejected_reloads}`} + + ) : ( + {resp.rejected_reloads} + )} +
+ {resp.last_error != null ? ( +
+            {resp.last_error}
+          
+ ) : null} +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/ExecBgView.tsx b/console/web/src/components/chat/shell/ExecBgView.tsx new file mode 100644 index 00000000..de2e26bd --- /dev/null +++ b/console/web/src/components/chat/shell/ExecBgView.tsx @@ -0,0 +1,91 @@ +import { + FooterPill, + Terminal, +} from '@/components/chat/sandbox/terminal/Terminal' +import { ShellExecChips, ShellExecPreviewRow } from './ExecView' +import { formatArgv, formatShellCommand } from './format' +import { + type ShellExecBgRequest, + type ShellExecBgResponse, + safeParseResponse, + shellExecBgRequestSchema, + shellExecBgResponseSchema, +} from './parsers' +import { isSandboxTarget } from './shared' + +interface ShellExecBgViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function ShellExecBgView({ + input, + output, + running, +}: ShellExecBgViewProps) { + const req = shellExecBgRequestSchema.safeParse(input) + if (!req.success) return null + const respData = + output != null ? safeParseResponse(shellExecBgResponseSchema, output) : null + return ( + } + footer={respData ? : null} + > + {respData ? : null} + + ) +} + +/** Compact `$ cmd args` preview used in the pending-approval state — + the shared exec row plus the `bg` marker chip. */ +export function ShellExecBgPreview({ input }: { input: unknown }) { + return +} + +/** Success body — there is no stdout for a background spawn. The job_id + is the copy-handle for `shell::status`/`shell::kill`, so it renders + full and untruncated. The server-resolved argv appears as a faint + second line only when shell-words tokenization changed the spelling + of what was typed (that's when it's informative). */ +function BgStartedBody({ + req, + resp, +}: { + req: ShellExecBgRequest + resp: ShellExecBgResponse +}) { + const resolved = formatArgv(resp.argv) + return ( +
+
+ + started + + {resp.job_id} + +
+ {resolved !== formatShellCommand(req) ? ( +
+ {`$ ${resolved}`} +
+ ) : null} +
+ ) +} + +function ShellExecBgFooter({ req }: { req: ShellExecBgRequest }) { + return ( + <> + job started + {/* Host-targeted background jobs IGNORE timeout_ms (wire semantic); + surface the silently-dropped knob. */} + {!isSandboxTarget(req.target) && typeof req.timeout_ms === 'number' ? ( + timeout_ms ignored (host bg) + ) : null} + + ) +} diff --git a/console/web/src/components/chat/shell/ExecView.tsx b/console/web/src/components/chat/shell/ExecView.tsx new file mode 100644 index 00000000..e9099265 --- /dev/null +++ b/console/web/src/components/chat/shell/ExecView.tsx @@ -0,0 +1,126 @@ +import { + formatBytes, + pillForExit, + truncateMiddle, +} from '@/components/chat/sandbox/format' +import { AnsiOutput } from '@/components/chat/sandbox/terminal/AnsiOutput' +import { + Chip, + FooterPill, + Terminal, +} from '@/components/chat/sandbox/terminal/Terminal' +import { Prompt } from '@/components/ui/Prompt' +import { formatShellCommand } from './format' +import { + type ShellExecRequest, + type ShellExecResponse, + safeParseResponse, + shellExecRequestSchema, + shellExecResponseSchema, +} from './parsers' +import { TargetChip } from './shared' + +interface ShellExecViewProps { + input: unknown + output: unknown + running?: boolean +} + +export function ShellExecView({ input, output, running }: ShellExecViewProps) { + const req = shellExecRequestSchema.safeParse(input) + if (!req.success) return null + const respData = + output != null ? safeParseResponse(shellExecResponseSchema, output) : null + return ( + } + footer={respData ? : null} + > + + + ) +} + +/** Compact `$ cmd args` preview used in the pending-approval state. */ +export function ShellExecPreview({ input }: { input: unknown }) { + return +} + +/** Shared approval-surface row for exec and exec_bg previews (exec_bg + adds the `bg` marker chip). The chips carry the approval-relevant + facts: target (host is the privileged one), cwd, env keys, stdin + size. */ +export function ShellExecPreviewRow({ + input, + bg, +}: { + input: unknown + bg?: boolean +}) { + const req = shellExecRequestSchema.safeParse(input) + if (!req.success) return null + return ( +
+ + + {formatShellCommand(req.data)} + + + + +
+ ) +} + +/* `stdin.length` undercounts multibyte input — the server caps and pipes + bytes, so report the UTF-8 size. */ +const utf8 = new TextEncoder() + +/** Header chips shared by exec/exec_bg cards and both previews. `env` + surfaces sorted KEYS only — values can be secrets (the raw-json tab + still has them). */ +export function ShellExecChips({ + req, + bg, +}: { + req: ShellExecRequest + bg?: boolean +}) { + const envKeys = req.env ? Object.keys(req.env).sort() : [] + return ( + <> + {bg ? bg : null} + + {req.cwd ? {truncateMiddle(req.cwd, 28)} : null} + {typeof req.timeout_ms === 'number' ? ( + {`${req.timeout_ms}ms`} + ) : null} + {envKeys.length > 0 ? ( + {truncateMiddle(envKeys.join(', '), 24)} + ) : null} + {req.stdin != null ? ( + {formatBytes(utf8.encode(req.stdin).length)} + ) : null} + + ) +} + +function ShellExecFooter({ resp }: { resp: ShellExecResponse }) { + const exit = pillForExit(resp.exit_code) + return ( + <> + {exit.label} + {`${resp.duration_ms}ms`} + {resp.timed_out ? timed out : null} + {/* Explicit wire flags — no length heuristic; the server says so. */} + {resp.stdout_truncated ? ( + stdout truncated + ) : null} + {resp.stderr_truncated ? ( + stderr truncated + ) : null} + + ) +} diff --git a/console/web/src/components/chat/shell/FsChmodView.tsx b/console/web/src/components/chat/shell/FsChmodView.tsx new file mode 100644 index 00000000..28b6d02d --- /dev/null +++ b/console/web/src/components/chat/shell/FsChmodView.tsx @@ -0,0 +1,44 @@ +import { formatMode } from '@/components/chat/sandbox/format' +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsChmodRequestSchema, + fsChmodResponseSchema, + safeParseResponse, +} from './parsers' +import { displayPath, TargetChip } from './shared' + +interface FsChmodViewProps { + input: unknown + output: unknown +} + +export function FsChmodView({ input, output }: FsChmodViewProps) { + const req = fsChmodRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsChmodResponseSchema, output) + if (!resp) return null + const ownership = + typeof req.data.uid === 'number' || typeof req.data.gid === 'number' + ? `${req.data.uid ?? '_'}:${req.data.gid ?? '_'}` + : null + + return ( +
+
+
+ chmod + {displayPath(req.data.path, resp.path)} + + {req.data.mode} + ({formatMode(req.data.mode)}) +
+
+ {ownership ? {ownership} : null} + {req.data.recursive ? true : null} + {resp.entries_changed} + +
+
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsGrepView.tsx b/console/web/src/components/chat/shell/FsGrepView.tsx new file mode 100644 index 00000000..4783fcd6 --- /dev/null +++ b/console/web/src/components/chat/shell/FsGrepView.tsx @@ -0,0 +1,61 @@ +import { GrepMatchList } from '@/components/chat/sandbox/FsGrepView' +import { truncateMiddle } from '@/components/chat/sandbox/format' +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsGrepRequestSchema, + fsGrepResponseSchema, + safeParseResponse, +} from './parsers' +import { TargetChip } from './shared' + +interface FsGrepViewProps { + input: unknown + output: unknown +} + +export function FsGrepView({ input, output }: FsGrepViewProps) { + const req = fsGrepRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsGrepResponseSchema, output) + if (!resp) return null + const { matches, truncated } = resp + + return ( +
+
+ {req.data.path} + {req.data.pattern} + + {req.data.ignore_case ? case-insensitive : null} + {/* `recursive` defaults TRUE on the wire — chip the deviation only */} + {req.data.recursive === false ? non-recursive : null} + {req.data.include_glob?.length ? ( + + {truncateMiddle(req.data.include_glob.join(' '), 40)} + + ) : null} + {req.data.exclude_glob?.length ? ( + + {truncateMiddle(req.data.exclude_glob.join(' '), 40)} + + ) : null} + 0 ? 'default' : 'warn'}> + {`${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`} + + {truncated ? truncated : null} +
+ + {matches.length === 0 ? ( +
+ · no matches +
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/shell/FsLsView.tsx b/console/web/src/components/chat/shell/FsLsView.tsx new file mode 100644 index 00000000..2e3e37ee --- /dev/null +++ b/console/web/src/components/chat/shell/FsLsView.tsx @@ -0,0 +1,38 @@ +import { FsEntriesTable } from '@/components/chat/sandbox/FsLsView' +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsLsRequestSchema, + fsLsResponseSchema, + safeParseResponse, +} from './parsers' +import { TargetChip } from './shared' + +interface FsLsViewProps { + input: unknown + output: unknown +} + +export function FsLsView({ input, output }: FsLsViewProps) { + const req = fsLsRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsLsResponseSchema, output) + if (!resp) return null + const entries = resp.entries + + return ( +
+
+ {req.data.path} + + {entries.length} +
+ {entries.length === 0 ? ( +
+ · directory is empty +
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/shell/FsMkdirView.tsx b/console/web/src/components/chat/shell/FsMkdirView.tsx new file mode 100644 index 00000000..7dd65c5a --- /dev/null +++ b/console/web/src/components/chat/shell/FsMkdirView.tsx @@ -0,0 +1,48 @@ +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsMkdirRequestSchema, + fsMkdirResponseSchema, + safeParseResponse, +} from './parsers' +import { displayPath, isSandboxTarget, TargetChip } from './shared' + +interface FsMkdirViewProps { + input: unknown + output: unknown +} + +export function FsMkdirView({ input, output }: FsMkdirViewProps) { + const req = fsMkdirRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsMkdirResponseSchema, output) + if (!resp) return null + const created = resp.created + const sandboxed = isSandboxTarget(req.data.target) + // Sandbox-target responses default `already_existed` (serde fill-in, + // not a signal) — collapse to the plain created/exists wording there. + const verb = created + ? '+ created ' + : sandboxed + ? '· exists ' + : resp.already_existed + ? '· already exists ' + : '· not created ' + + return ( +
+
+
+ + {verb} + + {displayPath(req.data.path, resp.path)} +
+
+ {req.data.mode ?? '0755'} + {req.data.parents ? true : null} + +
+
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsMvView.tsx b/console/web/src/components/chat/shell/FsMvView.tsx new file mode 100644 index 00000000..b053b824 --- /dev/null +++ b/console/web/src/components/chat/shell/FsMvView.tsx @@ -0,0 +1,49 @@ +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsMvRequestSchema, + fsMvResponseSchema, + safeParseResponse, +} from './parsers' +import { displayPath, isSandboxTarget, TargetChip } from './shared' + +interface FsMvViewProps { + input: unknown + output: unknown +} + +export function FsMvView({ input, output }: FsMvViewProps) { + const req = fsMvRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsMvResponseSchema, output) + if (!resp) return null + const moved = resp.moved + /* Sandbox-target responses default `overwrote` — a serde fill-in, not + a signal; only surface the warn pill for host moves. */ + const showOverwrote = resp.overwrote && !isSandboxTarget(req.data.target) + const hasChips = + isSandboxTarget(req.data.target) || !!req.data.overwrite || showOverwrote + + return ( +
+
+
+ + {moved ? 'mv' : '·'} + + {displayPath(req.data.src, resp.src)} + + {displayPath(req.data.dst, resp.dst)} +
+ {hasChips ? ( +
+ + {req.data.overwrite ? true : null} + {showOverwrote ? ( + overwrote existing + ) : null} +
+ ) : null} +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsReadView.tsx b/console/web/src/components/chat/shell/FsReadView.tsx new file mode 100644 index 00000000..ef947cd4 --- /dev/null +++ b/console/web/src/components/chat/shell/FsReadView.tsx @@ -0,0 +1,58 @@ +import { + formatBytes, + formatMode, + formatMtime, + truncateMiddle, +} from '@/components/chat/sandbox/format' +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsReadRequestSchema, + fsReadResponseSchema, + safeParseResponse, +} from './parsers' +import { TargetChip } from './shared' + +interface FsReadViewProps { + input: unknown + output: unknown +} + +/** shell::fs::read never inlines content — the response `content` is + always a channel ref, so the body is the stream row promoted to the + only branch (no CodeHighlight, no empty state). The console cannot + dereference the channel, so there is no "view content" affordance. */ +export function FsReadView({ input, output }: FsReadViewProps) { + const req = fsReadRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsReadResponseSchema, output) + if (!resp) return null + + return ( +
+
+ + file + + {req.data.path} + +
+ +
+ content streamed via channel + + {truncateMiddle(resp.content.channel_id, 18)} + + + ({resp.content.direction ?? 'read'}) + +
+ +
+ {formatBytes(resp.size)} + {formatMode(resp.mode)} + {formatMtime(resp.mtime)} + streamed +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsRmView.tsx b/console/web/src/components/chat/shell/FsRmView.tsx new file mode 100644 index 00000000..acc29a85 --- /dev/null +++ b/console/web/src/components/chat/shell/FsRmView.tsx @@ -0,0 +1,56 @@ +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsRmRequestSchema, + fsRmResponseSchema, + safeParseResponse, +} from './parsers' +import { displayPath, isSandboxTarget, TargetChip } from './shared' + +interface FsRmViewProps { + input: unknown + output: unknown +} + +export function FsRmView({ input, output }: FsRmViewProps) { + const req = fsRmRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsRmResponseSchema, output) + if (!resp) return null + const removed = resp.removed + const sandboxed = isSandboxTarget(req.data.target) + // `was_present` is a host-only signal; sandbox-target responses default + // it (serde fill-in) — collapse to removed/not-removed wording there. + const wasAbsent = !sandboxed && !removed && !resp.was_present + const verb = removed + ? '− removed ' + : wasAbsent + ? '· was not present ' + : '· not removed ' + const tone = removed + ? 'text-warn' + : wasAbsent + ? 'text-ink-ghost' + : 'text-ink-faint' + const recursive = req.data.recursive === true + + return ( +
+
+
+ {verb} + {displayPath(req.data.path, resp.path)} +
+ {recursive || sandboxed ? ( +
+ {recursive ? ( + + true + + ) : null} + +
+ ) : null} +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsSedView.tsx b/console/web/src/components/chat/shell/FsSedView.tsx new file mode 100644 index 00000000..34477511 --- /dev/null +++ b/console/web/src/components/chat/shell/FsSedView.tsx @@ -0,0 +1,66 @@ +import { SedResultsTable } from '@/components/chat/sandbox/FsSedView' +import { truncateMiddle } from '@/components/chat/sandbox/format' +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsSedRequestSchema, + fsSedResponseSchema, + safeParseResponse, +} from './parsers' +import { TargetChip } from './shared' + +interface FsSedViewProps { + input: unknown + output: unknown +} + +export function FsSedView({ input, output }: FsSedViewProps) { + const req = fsSedRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsSedResponseSchema, output) + if (!resp) return null + const { results, total_replacements } = resp + const pathMode = req.data.path != null + const target = + req.data.path ?? + (req.data.files?.length ? `${req.data.files.length} files` : '—') + + return ( +
+
+ {target} + {req.data.pattern} + {req.data.replacement || "''"} + {/* `regex`/`recursive` default TRUE on the wire — chip the + deviations ("literal", "non-recursive") only */} + {req.data.regex === false ? literal : null} + {req.data.first_only ? first-only : null} + {req.data.ignore_case ? case-insensitive : null} + {pathMode && req.data.recursive === false ? ( + non-recursive + ) : null} + {req.data.include_glob?.length ? ( + + {truncateMiddle(req.data.include_glob.join(' '), 40)} + + ) : null} + {req.data.exclude_glob?.length ? ( + + {truncateMiddle(req.data.exclude_glob.join(' '), 40)} + + ) : null} + +
+ + {results.length === 0 ? ( +
+ · no files touched +
+ ) : ( + + )} +
+ ) +} diff --git a/console/web/src/components/chat/shell/FsStatView.tsx b/console/web/src/components/chat/shell/FsStatView.tsx new file mode 100644 index 00000000..31083a9b --- /dev/null +++ b/console/web/src/components/chat/shell/FsStatView.tsx @@ -0,0 +1,44 @@ +import { + formatBytes, + formatMode, + formatMtime, +} from '@/components/chat/sandbox/format' +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + fsStatRequestSchema, + fsStatResponseSchema, + safeParseResponse, +} from './parsers' +import { TargetChip } from './shared' + +interface FsStatViewProps { + input: unknown + output: unknown +} + +/** Response is the bare FsEntry (`StatResponse` is serde-transparent). */ +export function FsStatView({ input, output }: FsStatViewProps) { + const req = fsStatRequestSchema.safeParse(input) + if (!req.success) return null + const e = safeParseResponse(fsStatResponseSchema, output) + if (!e) return null + + return ( +
+
+
+ stat + {req.data.path} +
+
+ {e.is_dir ? '—' : formatBytes(e.size)} + {`${e.is_dir ? 'd' : '-'}${formatMode(e.mode)}`} + {formatMtime(e.mtime)} + + {e.is_dir ? dir : null} + {e.is_symlink ? symlink : null} +
+
+
+ ) +} diff --git a/console/web/src/components/chat/shell/FsWriteView.tsx b/console/web/src/components/chat/shell/FsWriteView.tsx new file mode 100644 index 00000000..7d0b5cd9 --- /dev/null +++ b/console/web/src/components/chat/shell/FsWriteView.tsx @@ -0,0 +1,129 @@ +import { formatBytes } from '@/components/chat/sandbox/format' +import { Chip, FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { + contentRefSchema, + type FsWriteRequest, + type FsWriteResponse, + fsWriteRequestSchema, + fsWriteResponseSchema, + safeParseResponse, +} from './parsers' +import { displayPath, TargetChip } from './shared' + +interface FsWriteViewProps { + input: unknown + output: unknown +} + +export function FsWriteView({ input, output }: FsWriteViewProps) { + const req = fsWriteRequestSchema.safeParse(input) + if (!req.success) return null + const resp = safeParseResponse(fsWriteResponseSchema, output) + if (!resp) return null + + /* Request-side discriminator: batch when `files` is a non-empty array. + (The response-side `path === ''` heuristic is unreliable — + sandbox-target single writes blank it too.) */ + if (req.data.files?.length) { + return + } + + const streamed = + req.data.content != null && + contentRefSchema.safeParse(req.data.content).success + + return ( +
+
+
+ + wrote{' '} + + {formatBytes(resp.bytes_written)} + {' '} + to{' '} + {displayPath(req.data.path ?? '', resp.path)} +
+
+ {req.data.mode ?? '0644'} + {req.data.parents ? true : null} + {streamed ? ( + uploaded via channel + ) : null} + +
+
+
+ ) +} + +interface BatchWriteProps { + req: FsWriteRequest + resp: FsWriteResponse +} + +/** Batch layout — one row per written file. `resp.files` is authoritative + (worker preserves order); each row joins back to its request spec by + path (index fallback) for mode/content provenance. */ +function BatchWrite({ req, resp }: BatchWriteProps) { + const specs = req.files ?? [] + const specByPath = new Map(specs.map((s) => [s.path, s] as const)) + + return ( +
+
+ {resp.files.length} + +
+ + + + + + + + + + + + {resp.files.map((r, i) => { + const spec = specByPath.get(r.path) ?? specs[i] + return ( + + + + + + + ) + })} + + + + + +
pathbytesmodecontent
{r.path} + {formatBytes(r.bytes_written)} + + {spec ? (spec.mode ?? '0644') : '—'} + {spec?.parents ? ( + +parents + ) : null} + + {spec + ? typeof spec.content === 'string' + ? 'inline' + : 'channel' + : '—'} +
+ total + + + {`${formatBytes(resp.bytes_written)} · ${resp.files.length} ${resp.files.length === 1 ? 'file' : 'files'}`} + +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/KillView.tsx b/console/web/src/components/chat/shell/KillView.tsx new file mode 100644 index 00000000..9731d48a --- /dev/null +++ b/console/web/src/components/chat/shell/KillView.tsx @@ -0,0 +1,77 @@ +import { truncateMiddle } from '@/components/chat/sandbox/format' +import { ActionLine, StatusPill } from '@/components/chat/sandbox/shared' +import { FooterPill } from '@/components/chat/sandbox/terminal/Terminal' +import { jobStatusPill } from './format' +import { + safeParseResponse, + shellKillRequestSchema, + shellKillResponseSchema, +} from './parsers' + +interface ShellKillViewProps { + input: unknown + output: unknown + running?: boolean +} + +/** + * `shell::kill` — warn slab in the sandbox StopView pattern. The + * `killed: false` case always arrives with `reason: "not running"` + * plus the job's terminal status; both render side by side so the + * outcome is unambiguous. + */ +export function ShellKillView({ input, output, running }: ShellKillViewProps) { + const req = shellKillRequestSchema.safeParse(input) + if (!req.success) return null + const resp = + output != null ? safeParseResponse(shellKillResponseSchema, output) : null + const status = resp ? jobStatusPill(resp.status) : null + + return ( +
+
+
+ × + {running ? 'killing job…' : 'killed job'} + + {truncateMiddle(resp?.job_id ?? req.data.job_id, 24)} + +
+ {/* `reason` is long prose (e.g. the sandbox no-cancel-hook + message) — full-width faint line, not a pill. */} + {resp?.reason ? ( +
+ {resp.reason} +
+ ) : null} + {resp && status ? ( +
+ + + {resp.killed ? 'killed' : 'not running'} + +
+ ) : null} +
+
+ ) +} + +/** Pending-approval preview — kill is destructive, so the approver + sees the exact job id on a single warn action line. */ +export function ShellKillPreview({ input }: { input: unknown }) { + const req = shellKillRequestSchema.safeParse(input) + if (!req.success) return null + return ( +
+ + + kill job{' '} + + {req.data.job_id} + + + +
+ ) +} diff --git a/console/web/src/components/chat/shell/ListView.tsx b/console/web/src/components/chat/shell/ListView.tsx new file mode 100644 index 00000000..4ac887b4 --- /dev/null +++ b/console/web/src/components/chat/shell/ListView.tsx @@ -0,0 +1,107 @@ +import { Inbox } from 'lucide-react' +import { formatAgeSecs, truncateMiddle } from '@/components/chat/sandbox/format' +import { StatusPill } from '@/components/chat/sandbox/shared' +import { EmptyState } from '@/components/ui/EmptyState' +import { StatusDot } from '@/components/ui/StatusDot' +import { formatEpochMs, jobDurationMs, jobStatusPill } from './format' +import { safeParseResponse, shellListResponseSchema } from './parsers' + +interface ShellListViewProps { + output: unknown +} + +/** `1234` → `"1234ms"`; ≥ 10 s humanize via `formatAgeSecs` so + long-lived jobs read as `2m`/`3h` instead of raw millis. */ +function formatDurationMs(ms: number): string { + return ms < 10_000 ? `${ms}ms` : formatAgeSecs(Math.floor(ms / 1000)) +} + +/** + * `shell::list` — background-job summary table. `JobSummary` + * deliberately omits argv/stdout/stderr (cross-caller secrecy), so + * there is no command column; the footer line points at + * `shell::status` for the full record. `count` is not rendered — it + * always equals `jobs.length` (the schema keeps it required as a + * contract canary). Request renders nothing (ignored server-side). + */ +export function ShellListView({ output }: ShellListViewProps) { + const parsed = safeParseResponse(shellListResponseSchema, output) + if (!parsed) return null + const jobs = parsed.jobs + + if (jobs.length === 0) { + return ( +
+ +
+ ) + } + + return ( +
+ + + + + + + + + + + + + {jobs.map((j) => { + const status = jobStatusPill(j.status) + const duration = jobDurationMs(j) + const truncated = j.stdout_truncated || j.stderr_truncated + return ( + + + + + + + + + ) + })} + +
jobstatusstarteddurationexitoutput
+ {truncateMiddle(j.id, 18)} + + + {j.status === 'running' ? ( + + ) : null} + + + + {formatEpochMs(j.started_at_ms)} + + {duration != null ? formatDurationMs(duration) : '—'} + + {j.exit_code == null ? ( + + ) : ( + + {j.exit_code} + + )} + + {truncated ? '✂ truncated' : '—'} +
+
+ summaries only — full output via shell::status +
+
+ ) +} diff --git a/console/web/src/components/chat/shell/StatusView.tsx b/console/web/src/components/chat/shell/StatusView.tsx new file mode 100644 index 00000000..1b9c3d7a --- /dev/null +++ b/console/web/src/components/chat/shell/StatusView.tsx @@ -0,0 +1,110 @@ +import { + formatAgeSecs, + pillForExit, + truncateMiddle, +} from '@/components/chat/sandbox/format' +import { AnsiOutput } from '@/components/chat/sandbox/terminal/AnsiOutput' +import { + Chip, + FooterPill, + Terminal, +} from '@/components/chat/sandbox/terminal/Terminal' +import { + formatArgv, + formatEpochMs, + jobDurationMs, + jobStatusPill, +} from './format' +import { + type JobRecord, + safeParseResponse, + shellStatusRequestSchema, + shellStatusResponseSchema, +} from './parsers' + +interface ShellStatusViewProps { + input: unknown + output: unknown + running?: boolean +} + +/** `1234` → `"1234ms"`; ≥ 10 s humanize via `formatAgeSecs` so + long-lived jobs read as `2m`/`3h` instead of raw millis. */ +function formatDurationMs(ms: number): string { + return ms < 10_000 ? `${ms}ms` : formatAgeSecs(Math.floor(ms / 1000)) +} + +/** + * `shell::status` — the full JobRecord as real terminal chrome (the + * record carries argv + buffered stdout/stderr, unlike list's + * summaries). The frequent `S211 no such job` failure never reaches + * this view — the dispatch error pre-pass renders it as an error card. + */ +export function ShellStatusView({ + input, + output, + running, +}: ShellStatusViewProps) { + const req = shellStatusRequestSchema.safeParse(input) + if (!req.success) return null + const resp = + output != null ? safeParseResponse(shellStatusResponseSchema, output) : null + + if (!resp) { + // The status call itself is in flight (or output is missing): + // chips-only header, `running` drives the executing shimmer. + return ( + {truncateMiddle(req.data.job_id, 18)}} + /> + ) + } + + const job = resp.job + return ( + + {truncateMiddle(job.id, 18)} + {formatEpochMs(job.started_at_ms)} + {job.finished_at_ms != null ? ( + {formatEpochMs(job.finished_at_ms)} + ) : null} + + } + footer={} + > + + + ) +} + +function StatusFooter({ job }: { job: JobRecord }) { + const status = jobStatusPill(job.status) + const exit = pillForExit(job.exit_code) + const duration = jobDurationMs(job) + return ( + <> + {status.label} + {/* exit_code is null by definition while running — "no exit"/warn + would be misleading there, so the pill is terminal-only. */} + {job.status !== 'running' ? ( + {exit.label} + ) : null} + {/* No live-ticking duration for running jobs (no re-render + source); the `started` chip's relative time covers it. */} + {duration != null ? ( + {formatDurationMs(duration)} + ) : null} + {job.stdout_truncated ? ( + stdout truncated + ) : null} + {job.stderr_truncated ? ( + stderr truncated + ) : null} + + ) +} diff --git a/console/web/src/components/chat/shell/__tests__/format.test.ts b/console/web/src/components/chat/shell/__tests__/format.test.ts new file mode 100644 index 00000000..e27f8625 --- /dev/null +++ b/console/web/src/components/chat/shell/__tests__/format.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { + formatArgv, + formatEpochMs, + formatShellCommand, + jobDurationMs, + jobStatusPill, +} from '../format' + +describe('formatShellCommand', () => { + it('renders the shell line verbatim when args are absent', () => { + expect(formatShellCommand({ command: 'echo "hi there" | wc -l' })).toBe( + 'echo "hi there" | wc -l', + ) + }) + + it('treats null args the same as absent', () => { + expect(formatShellCommand({ command: 'ls -la /tmp', args: null })).toBe( + 'ls -la /tmp', + ) + }) + + it('quote-joins the argv when args are present', () => { + expect( + formatShellCommand({ command: 'echo', args: ['hi there', '$HOME'] }), + ).toBe("echo 'hi there' '$HOME'") + }) + + it('keeps the []-vs-absent wire distinction visible', () => { + // Absent ⇒ shell line, displayed verbatim. + expect(formatShellCommand({ command: 'my program' })).toBe('my program') + // Present (even empty) ⇒ verbatim argv: the program slot gets quoted. + expect(formatShellCommand({ command: 'my program', args: [] })).toBe( + "'my program'", + ) + }) +}) + +describe('formatArgv', () => { + it('quote-joins a server-resolved argv into a paste-able line', () => { + expect(formatArgv(['node', '--eval', 'console.log(1)'])).toBe( + "node --eval 'console.log(1)'", + ) + }) + + it('renders an empty argv as an empty string', () => { + expect(formatArgv([])).toBe('') + }) +}) + +describe('jobStatusPill', () => { + it('maps every JobStatus to its tone', () => { + expect(jobStatusPill('running')).toEqual({ + label: 'running', + tone: 'accent', + }) + expect(jobStatusPill('finished')).toEqual({ + label: 'finished', + tone: 'default', + }) + expect(jobStatusPill('killed')).toEqual({ label: 'killed', tone: 'warn' }) + expect(jobStatusPill('failed')).toEqual({ label: 'failed', tone: 'alert' }) + }) +}) + +describe('jobDurationMs', () => { + it('returns the wall-clock delta for finished jobs', () => { + expect(jobDurationMs({ started_at_ms: 1_000, finished_at_ms: 4_500 })).toBe( + 3_500, + ) + }) + + it('returns null while the job is still running', () => { + expect( + jobDurationMs({ started_at_ms: 1_000, finished_at_ms: null }), + ).toBeNull() + }) + + it('clamps negative clock skew to 0', () => { + expect(jobDurationMs({ started_at_ms: 2_000, finished_at_ms: 1_000 })).toBe( + 0, + ) + }) +}) + +describe('formatEpochMs', () => { + it('treats epoch 0 as unknown', () => { + expect(formatEpochMs(0)).toBe('—') + }) + + it('floors millis to seconds before delegating to formatMtime', () => { + expect(formatEpochMs(Date.now())).toBe('just now') + expect(formatEpochMs(Date.now() - 120_000)).toBe('2m ago') + }) +}) diff --git a/console/web/src/components/chat/shell/__tests__/parsers.test.ts b/console/web/src/components/chat/shell/__tests__/parsers.test.ts new file mode 100644 index 00000000..57e325b2 --- /dev/null +++ b/console/web/src/components/chat/shell/__tests__/parsers.test.ts @@ -0,0 +1,715 @@ +import { describe, expect, it } from 'vitest' +import { + extractEmbeddedSCode, + fsChmodRequestSchema, + fsChmodResponseSchema, + fsGrepRequestSchema, + fsGrepResponseSchema, + fsLsRequestSchema, + fsLsResponseSchema, + fsMkdirResponseSchema, + fsMvResponseSchema, + fsPathRequestSchema, + fsReadResponseSchema, + fsRmResponseSchema, + fsSedResponseSchema, + fsStatResponseSchema, + fsWriteRequestSchema, + fsWriteResponseSchema, + isShellFunction, + jobRecordSchema, + parseShellErrorDisplay, + SHELL_FUNCTION_IDS, + shellConfigStatusResponseSchema, + shellErrorWireSchema, + shellExecBgRequestSchema, + shellExecBgResponseSchema, + shellExecRequestSchema, + shellExecResponseSchema, + shellKillResponseSchema, + shellListRequestSchema, + shellListResponseSchema, + shellStatusRequestSchema, + shellStatusResponseSchema, + stripHandlerPrefix, + targetSchema, +} from '../parsers' + +const SB = '00000000-0000-0000-0000-000000000000' + +/** Build a `{ content, details, terminate }` envelope identical to + what `harness/src/turn-orchestrator/agent-trigger.ts` produces. */ +function wrap(details: T) { + return { + content: [{ type: 'text', text: JSON.stringify(details) }], + details, + terminate: false, + } +} + +/** Canonical finished JobRecord — all 10 keys present. */ +const FINISHED_JOB = { + id: 'job-1', + argv: ['sleep', '5'], + started_at_ms: 1_700_000_000_000, + finished_at_ms: 1_700_000_005_000, + status: 'finished', + exit_code: 0, + stdout: 'done\n', + stderr: '', + stdout_truncated: false, + stderr_truncated: false, +} + +/** Running record — `finished_at_ms`/`exit_code` are explicit nulls. */ +const RUNNING_JOB = { + ...FINISHED_JOB, + id: 'job-2', + finished_at_ms: null, + status: 'running', + exit_code: null, + stdout: '', +} + +describe('isShellFunction', () => { + it('accepts all 16 shell ids', () => { + expect(SHELL_FUNCTION_IDS).toHaveLength(16) + for (const id of SHELL_FUNCTION_IDS) { + expect(isShellFunction(id)).toBe(true) + } + }) + + it('rejects near-misses from other families and misspellings', () => { + expect(isShellFunction('sandbox::exec')).toBe(false) + // The real id is dash-cased `shell::config-status`. + expect(isShellFunction('shell::config_status')).toBe(false) + }) +}) + +describe('target schema', () => { + it('accepts host and sandbox variants', () => { + expect(targetSchema.safeParse({ kind: 'host' }).success).toBe(true) + expect( + targetSchema.safeParse({ kind: 'sandbox', sandbox_id: SB }).success, + ).toBe(true) + }) + + it('rejects unknown kinds and sandbox without an id', () => { + expect(targetSchema.safeParse({ kind: 'moon' }).success).toBe(false) + expect(targetSchema.safeParse({ kind: 'sandbox' }).success).toBe(false) + }) + + it('is optional on requests but NOT nullable (serde default on non-Option)', () => { + // Omitted ⇒ host default — fine. + expect(shellExecRequestSchema.safeParse({ command: 'ls' }).success).toBe( + true, + ) + expect(fsPathRequestSchema.safeParse({ path: '/' }).success).toBe(true) + // Explicit null is a server-side deserialize error — pin the rejection. + expect( + shellExecRequestSchema.safeParse({ command: 'ls', target: null }).success, + ).toBe(false) + expect( + fsPathRequestSchema.safeParse({ path: '/', target: null }).success, + ).toBe(false) + }) +}) + +describe('shell::exec request', () => { + it('accepts the canonical fully-populated example', () => { + const ok = shellExecRequestSchema.safeParse({ + command: 'ls', + args: ['-la', '/tmp'], + timeout_ms: 30_000, + cwd: '/tmp', + env: { PATH: '/bin' }, + stdin: 'hi', + target: { kind: 'sandbox', sandbox_id: SB }, + }) + expect(ok.success).toBe(true) + }) + + it('keeps `args: []` distinguishable from absent (shell-line vs verbatim argv)', () => { + const absent = shellExecRequestSchema.safeParse({ command: 'ls -la' }) + expect(absent.success).toBe(true) + if (absent.success) expect(absent.data.args).toBeUndefined() + + const empty = shellExecRequestSchema.safeParse({ command: 'ls', args: [] }) + expect(empty.success).toBe(true) + if (empty.success) expect(empty.data.args).toEqual([]) + + const nul = shellExecRequestSchema.safeParse({ command: 'ls', args: null }) + expect(nul.success).toBe(true) + if (nul.success) expect(nul.data.args).toBeNull() + }) + + it('swallows junk timeout_ms via .catch (server silently ignores non-u64)', () => { + for (const junk of ['soon', -5, 1.5]) { + const parsed = shellExecRequestSchema.safeParse({ + command: 'ls', + timeout_ms: junk, + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.timeout_ms).toBeUndefined() + } + const valid = shellExecRequestSchema.safeParse({ + command: 'ls', + timeout_ms: 1500, + }) + expect(valid.success).toBe(true) + if (valid.success) expect(valid.data.timeout_ms).toBe(1500) + }) + + it('accepts explicit nulls for cwd/env/stdin (Option + serde default)', () => { + const ok = shellExecRequestSchema.safeParse({ + command: 'ls', + cwd: null, + env: null, + stdin: null, + }) + expect(ok.success).toBe(true) + }) + + it('rejects an array command (string-only on the server)', () => { + expect( + shellExecRequestSchema.safeParse({ command: ['ls', '-la'] }).success, + ).toBe(false) + }) + + it('exec_bg request is the same schema object (alias, not copy)', () => { + expect(shellExecBgRequestSchema).toBe(shellExecRequestSchema) + }) +}) + +describe('shell::exec / exec_bg responses', () => { + it('accepts the canonical 7-key ExecResponse', () => { + const ok = shellExecResponseSchema.safeParse({ + exit_code: 0, + stdout: 'hi\n', + stderr: '', + duration_ms: 12, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }) + expect(ok.success).toBe(true) + }) + + it('accepts null exit_code on timeout', () => { + const ok = shellExecResponseSchema.safeParse({ + exit_code: null, + stdout: '', + stderr: '', + duration_ms: 1500, + timed_out: true, + stdout_truncated: false, + stderr_truncated: true, + }) + expect(ok.success).toBe(true) + }) + + it('rejects a payload missing a truncation key (contract canary)', () => { + expect( + shellExecResponseSchema.safeParse({ + exit_code: 0, + stdout: '', + stderr: '', + duration_ms: 1, + timed_out: false, + stderr_truncated: false, + }).success, + ).toBe(false) + }) + + it('ExecBgResponse round-trips through the harness envelope', () => { + const resp = { job_id: 'job-abc', argv: ['sleep', '5'] } + const parsed = shellExecBgResponseSchema.safeParse(wrap(resp).details) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.job_id).toBe('job-abc') + }) +}) + +describe('shell::status / shell::kill', () => { + it('status request + full and running job records', () => { + expect( + shellStatusRequestSchema.safeParse({ job_id: 'job-1' }).success, + ).toBe(true) + expect( + shellStatusResponseSchema.safeParse({ job: FINISHED_JOB }).success, + ).toBe(true) + expect( + shellStatusResponseSchema.safeParse({ job: RUNNING_JOB }).success, + ).toBe(true) + }) + + it('rejects wrong-case status enums (serde lowercase)', () => { + expect( + jobRecordSchema.safeParse({ ...FINISHED_JOB, status: 'Finished' }) + .success, + ).toBe(false) + }) + + it('kill: not-running combo carries a reason', () => { + const ok = shellKillResponseSchema.safeParse({ + job_id: 'job-1', + killed: false, + status: 'finished', + reason: 'not running', + }) + expect(ok.success).toBe(true) + }) + + it('kill: host kill omits the reason key entirely', () => { + const parsed = shellKillResponseSchema.safeParse({ + job_id: 'job-1', + killed: true, + status: 'killed', + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.reason).toBeUndefined() + }) + + it('kill: sandbox kill carries the no-cancel-hook reason', () => { + const ok = shellKillResponseSchema.safeParse({ + job_id: 'job-1', + killed: true, + status: 'killed', + reason: + 'sandbox::exec has no cancel hook; the in-VM process will run until its timeout_ms expires', + }) + expect(ok.success).toBe(true) + }) + + it('kill: reason is skip_serializing_if — null is rejected', () => { + expect( + shellKillResponseSchema.safeParse({ + job_id: 'job-1', + killed: true, + status: 'killed', + reason: null, + }).success, + ).toBe(false) + }) +}) + +describe('shell::list / shell::config-status', () => { + it('list request tolerates the engine-injected _caller_worker_id', () => { + expect( + shellListRequestSchema.safeParse({ _caller_worker_id: 'w1' }).success, + ).toBe(true) + }) + + it('list response accepts empty and populated job tables', () => { + expect( + shellListResponseSchema.safeParse({ jobs: [], count: 0 }).success, + ).toBe(true) + expect( + shellListResponseSchema.safeParse({ + jobs: [ + { + id: 'job-2', + status: 'running', + started_at_ms: 1_700_000_000_000, + finished_at_ms: null, + exit_code: null, + stdout_truncated: false, + stderr_truncated: false, + }, + ], + count: 1, + }).success, + ).toBe(true) + }) + + it('config-status accepts both outcomes; last_error is always present', () => { + expect( + shellConfigStatusResponseSchema.safeParse({ + last_outcome: 'applied', + last_error: null, + rejected_reloads: 0, + }).success, + ).toBe(true) + expect( + shellConfigStatusResponseSchema.safeParse({ + last_outcome: 'rejected', + last_error: 'invalid allowlist pattern', + rejected_reloads: 2, + }).success, + ).toBe(true) + expect( + shellConfigStatusResponseSchema.safeParse({ + last_outcome: 'applied', + rejected_reloads: 0, + }).success, + ).toBe(false) + }) +}) + +describe('fs schemas', () => { + const ENTRY = { + name: 'a', + is_dir: false, + size: 1, + mode: '0644', + mtime: 0, + is_symlink: false, + } + + it('fs::ls request + response', () => { + expect( + fsLsRequestSchema.safeParse({ + path: '/', + target: { kind: 'sandbox', sandbox_id: SB }, + }).success, + ).toBe(true) + expect(fsLsResponseSchema.safeParse({ entries: [ENTRY] }).success).toBe( + true, + ) + }) + + it('fs::stat response is serde(transparent) — bare entry, no wrapper', () => { + expect(fsStatResponseSchema.safeParse(ENTRY).success).toBe(true) + expect(fsStatResponseSchema.safeParse({ entry: ENTRY }).success).toBe(false) + }) + + it('fs::mkdir minimal response fills serde defaults', () => { + const parsed = fsMkdirResponseSchema.safeParse({ created: true }) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.path).toBe('') + expect(parsed.data.already_existed).toBe(false) + } + }) + + it('fs::rm minimal response fills serde defaults', () => { + const parsed = fsRmResponseSchema.safeParse({ removed: true }) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.path).toBe('') + expect(parsed.data.was_present).toBe(false) + } + }) + + it('fs::mv minimal response fills serde defaults', () => { + const parsed = fsMvResponseSchema.safeParse({ moved: true }) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.src).toBe('') + expect(parsed.data.dst).toBe('') + expect(parsed.data.overwrote).toBe(false) + } + }) + + it('fs::chmod request requires mode; uid/gid accept null', () => { + expect( + fsChmodRequestSchema.safeParse({ + path: '/a', + mode: '0755', + uid: null, + gid: null, + }).success, + ).toBe(true) + expect(fsChmodRequestSchema.safeParse({ path: '/a' }).success).toBe(false) + }) + + it('fs::chmod response accepts the legacy `updated` alias', () => { + const parsed = fsChmodResponseSchema.safeParse({ updated: 3 }) + expect(parsed.success).toBe(true) + if (parsed.success) { + expect(parsed.data.entries_changed).toBe(3) + expect(parsed.data.path).toBe('') + expect(parsed.data.recursive).toBe(false) + } + }) + + it('fs::chmod response: entries_changed wins over updated; neither rejects', () => { + const parsed = fsChmodResponseSchema.safeParse({ + entries_changed: 5, + updated: 3, + path: '/a', + recursive: true, + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.entries_changed).toBe(5) + expect(fsChmodResponseSchema.safeParse({ path: '/a' }).success).toBe(false) + }) + + it('fs::grep request + response normalise the legacy `file` key', () => { + expect( + fsGrepRequestSchema.safeParse({ path: '/src', pattern: 'TODO' }).success, + ).toBe(true) + const parsed = fsGrepResponseSchema.safeParse({ + matches: [{ file: '/legacy.ts', line: 7, content: 'TODO' }], + truncated: false, + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.matches[0].path).toBe('/legacy.ts') + }) + + it('fs::sed response defaults absent per-file error to null', () => { + const parsed = fsSedResponseSchema.safeParse({ + results: [{ path: '/a.ts', replacements: 2, success: true }], + total_replacements: 2, + }) + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.results[0].error).toBeNull() + }) + + it('fs::write accepts single inline, channel-ref, and batch request forms', () => { + expect( + fsWriteRequestSchema.safeParse({ path: '/a.txt', content: 'hello\n' }) + .success, + ).toBe(true) + expect( + fsWriteRequestSchema.safeParse({ + path: '/big.bin', + content: { channel_id: 'ch1', access_key: 'k', direction: 'write' }, + }).success, + ).toBe(true) + expect( + fsWriteRequestSchema.safeParse({ + files: [ + { path: '/a', content: 'x' }, + { + path: '/b', + content: { channel_id: 'ch2', access_key: 'k' }, + mode: '0600', + }, + ], + }).success, + ).toBe(true) + }) + + it('fs::write response defaults `files` to [] on single-file writes', () => { + const single = fsWriteResponseSchema.safeParse({ + bytes_written: 6, + path: '/a.txt', + }) + expect(single.success).toBe(true) + if (single.success) expect(single.data.files).toEqual([]) + + const batch = fsWriteResponseSchema.safeParse({ + bytes_written: 10, + path: '', + files: [{ path: '/a', bytes_written: 4 }], + }) + expect(batch.success).toBe(true) + if (batch.success) expect(batch.data.files).toHaveLength(1) + }) + + it('fs::read content is ALWAYS a channel ref — inline strings rejected', () => { + expect( + fsReadResponseSchema.safeParse({ + content: 'hello', + size: 5, + mode: '0644', + mtime: 0, + }).success, + ).toBe(false) + expect( + fsReadResponseSchema.safeParse({ + content: { channel_id: 'ch1', access_key: 'k', direction: 'read' }, + size: 100, + mode: '0644', + mtime: 1_700_000_000, + }).success, + ).toBe(true) + }) +}) + +describe('shellErrorWireSchema', () => { + it('accepts the SDK ErrorBody with an S-code', () => { + expect( + shellErrorWireSchema.safeParse({ + code: 'S211', + message: 'no such job: job-7', + stacktrace: 'backtrace…', + }).success, + ).toBe(true) + }) + + it('rejects non-S codes (no false positives on {code,message} objects)', () => { + expect( + shellErrorWireSchema.safeParse({ + code: 'invocation_failed', + message: 'handler error: argv: missing closing quote', + }).success, + ).toBe(false) + }) +}) + +describe('parseShellErrorDisplay', () => { + it('lifts a flat S211 ErrorBody into the wire variant with its label', () => { + const out = parseShellErrorDisplay({ + code: 'S211', + message: 'no such job: job-7', + stacktrace: 'backtrace…', + }) + expect(out).toEqual({ + variant: 'wire', + error: { type: 'not found', code: 'S211', message: 'no such job: job-7' }, + }) + }) + + it('finds the ErrorBody inside the harness envelope', () => { + const out = parseShellErrorDisplay( + wrap({ code: 'S210', message: 'cwd must be absolute' }), + ) + expect(out?.variant).toBe('wire') + if (out?.variant === 'wire') { + expect(out.error.code).toBe('S210') + expect(out.error.type).toBe('bad request') + } + }) + + it('falls back to a generic label for undocumented S-codes', () => { + const out = parseShellErrorDisplay({ code: 'S999', message: 'mystery' }) + expect(out?.variant).toBe('wire') + if (out?.variant === 'wire') expect(out.error.type).toBe('shell error') + }) + + it('text-scans the real-world denial envelope reason (the screenshot path)', () => { + const out = parseShellErrorDisplay({ + error: { + kind: 'function_error', + message: 'trigger_failed: IIIInvocationError: invocation_failed', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'gate_unavailable', + function_id: 'shell::status', + reason: + 'trigger_failed: IIIInvocationError: S211: no such job: job-x', + }, + content: [ + { + type: 'text', + text: 'trigger_failed: IIIInvocationError: S211: no such job: job-x', + }, + ], + }, + }) + expect(out).toEqual({ + variant: 'wire', + error: { type: 'not found', code: 'S211', message: 'no such job: job-x' }, + }) + }) + + it('synthesizes S215 from an invocation_failed handler-error message (exec_bg path)', () => { + const out = parseShellErrorDisplay( + wrap({ + code: 'invocation_failed', + message: 'handler error: S215: cwd escapes jail: /etc/passwd', + stacktrace: 'backtrace…', + }), + ) + expect(out).toEqual({ + variant: 'wire', + error: { + type: 'permission denied', + code: 'S215', + message: 'cwd escapes jail: /etc/passwd', + }, + }) + }) + + it('delegates S-code-free denials to the sandbox invocation variant', () => { + const out = parseShellErrorDisplay({ + schema_version: 1, + status: 'denied', + denied_by: 'user', + function_id: 'shell::exec', + reason: 'denied from the console approval prompt', + }) + expect(out?.variant).toBe('invocation') + if (out?.variant === 'invocation') { + expect(out.error.title).toBe('Denied by user') + expect(out.error.deniedBy).toBe('user') + expect(out.error.message).toBe('denied from the console approval prompt') + } + }) + + it('returns null on success payloads', () => { + // ExecResponse — flat. + expect( + parseShellErrorDisplay({ + exit_code: 0, + stdout: 'ok\n', + stderr: '', + duration_ms: 12, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }), + ).toBeNull() + // KillResponse — its prose `reason` must not trip the text scan. + expect( + parseShellErrorDisplay({ + job_id: 'job-1', + killed: false, + status: 'finished', + reason: 'not running', + }), + ).toBeNull() + // ExecBgResponse — wrapped in the harness envelope. + expect( + parseShellErrorDisplay(wrap({ job_id: 'job-abc', argv: ['sleep', '5'] })), + ).toBeNull() + // LsResponse. + expect( + parseShellErrorDisplay({ + entries: [ + { + name: 'a', + is_dir: false, + size: 1, + mode: '0644', + mtime: 0, + is_symlink: false, + }, + ], + }), + ).toBeNull() + }) +}) + +describe('extractEmbeddedSCode', () => { + it('pulls the first S-code out of free text', () => { + expect( + extractEmbeddedSCode( + 'trigger_failed: IIIInvocationError: S211: no such job: job-x', + ), + ).toBe('S211') + expect(extractEmbeddedSCode('handler error: S215: cwd escapes jail')).toBe( + 'S215', + ) + }) + + it('returns null when no word-boundary S-code is present', () => { + expect(extractEmbeddedSCode('not running')).toBeNull() + expect(extractEmbeddedSCode('XS211: glued prefix')).toBeNull() + }) +}) + +describe('stripHandlerPrefix', () => { + it('strips the SDK Display prefixes', () => { + expect(stripHandlerPrefix('handler error: S215: cwd escapes jail')).toBe( + 'S215: cwd escapes jail', + ) + expect(stripHandlerPrefix('serialization error: invalid type')).toBe( + 'invalid type', + ) + }) + + it('strips the harness trigger_failed class prefix', () => { + expect( + stripHandlerPrefix( + 'trigger_failed: IIIInvocationError: S211: no such job', + ), + ).toBe('S211: no such job') + }) + + it('passes prefix-free messages through unchanged', () => { + expect(stripHandlerPrefix('plain message')).toBe('plain message') + }) +}) diff --git a/console/web/src/components/chat/shell/format.ts b/console/web/src/components/chat/shell/format.ts new file mode 100644 index 00000000..cee54028 --- /dev/null +++ b/console/web/src/components/chat/shell/format.ts @@ -0,0 +1,59 @@ +/* Pure formatting helpers shared by the shell renderers. No React, no + DOM access — deterministic transforms over the parsed shell payloads. + Generic byte/mode/time/quoting helpers come from sandbox/format. */ + +import { formatMtime, quoteShellArg } from '@/components/chat/sandbox/format' +import type { JobStatus } from './parsers' + +/** Render an ExecRequest command line for the terminal prompt. + `args` absent/null ⇒ `command` is a shell line the server shell-words + tokenizes — display verbatim. `args` present (even `[]`) ⇒ verbatim + argv — quote every slot including the program. Preserves the wire + semantic distinction. */ +export function formatShellCommand(req: { + command: string + args?: string[] | null +}): string { + if (req.args == null) return req.command + return [req.command, ...req.args].map(quoteShellArg).join(' ') +} + +/** Quote-join a server-resolved argv (`JobRecord.argv`, + `ExecBgResponse.argv`) into a paste-able shell line. */ +export function formatArgv(argv: string[]): string { + return argv.map(quoteShellArg).join(' ') +} + +/** Epoch-millis (`started_at_ms`/`finished_at_ms`) → short relative + time, via sandbox `formatMtime` (which takes unix seconds). */ +export function formatEpochMs(ms: number): string { + return formatMtime(Math.floor(ms / 1000)) +} + +/** Job wall-clock duration. Null while running (`finished_at_ms` null); + clamps negative clock skew to 0. */ +export function jobDurationMs(rec: { + started_at_ms: number + finished_at_ms: number | null +}): number | null { + return rec.finished_at_ms == null + ? null + : Math.max(0, rec.finished_at_ms - rec.started_at_ms) +} + +/** Status pill mapping — single source of truth for status/kill/list. */ +export function jobStatusPill(status: JobStatus): { + label: string + tone: 'accent' | 'warn' | 'alert' | 'default' +} { + switch (status) { + case 'running': + return { label: 'running', tone: 'accent' } + case 'finished': + return { label: 'finished', tone: 'default' } + case 'killed': + return { label: 'killed', tone: 'warn' } + case 'failed': + return { label: 'failed', tone: 'alert' } + } +} diff --git a/console/web/src/components/chat/shell/index.tsx b/console/web/src/components/chat/shell/index.tsx new file mode 100644 index 00000000..6b31a923 --- /dev/null +++ b/console/web/src/components/chat/shell/index.tsx @@ -0,0 +1,126 @@ +import { SandboxErrorView } from '@/components/chat/sandbox/ErrorView' +import type { FunctionCallMessage } from '@/types/chat' +import { ShellConfigStatusView } from './ConfigStatusView' +import { ShellExecBgPreview, ShellExecBgView } from './ExecBgView' +import { ShellExecPreview, ShellExecView } from './ExecView' +import { FsChmodView } from './FsChmodView' +import { FsGrepView } from './FsGrepView' +import { FsLsView } from './FsLsView' +import { FsMkdirView } from './FsMkdirView' +import { FsMvView } from './FsMvView' +import { FsReadView } from './FsReadView' +import { FsRmView } from './FsRmView' +import { FsSedView } from './FsSedView' +import { FsStatView } from './FsStatView' +import { FsWriteView } from './FsWriteView' +import { ShellKillPreview, ShellKillView } from './KillView' +import { ShellListView } from './ListView' +import { + isShellFunction, + parseShellErrorDisplay, + unwrapEnvelope, +} from './parsers' +import { ShellStatusView } from './StatusView' + +/* The known shell::* set lives in parsers.ts (SHELL_FUNCTION_IDS) so + the dispatcher and the schemas share one source of truth. Re-exported + here for FCM symmetry with the sandbox module. */ +export { isShellFunction, SHELL_FUNCTION_IDS } from './parsers' + +/* Public surface mirrors the sandbox module exactly. Both helpers + return `null` for unknown function ids or unparseable payloads so + the caller can silently fall back to the existing JSON view. */ +export function ShellFunctionIdLabel({ functionId }: { functionId: string }) { + if (!functionId.startsWith('shell::')) { + return {functionId} + } + const tail = functionId.slice('shell::'.length) + return ( + <> + shell:: + {tail} + + ) +} + +function tryRender(message: FunctionCallMessage): React.ReactNode | null { + if (!isShellFunction(message.functionId)) return null + if (message.pendingApproval) return null + + // The done-state view is what tryRender owns; the pending preview + // lives in tryRenderPreview. Running-state cards are rendered by + // the per-tool view with `running=true` so the shell chrome stays + // identical and only the body swaps to the executing-shimmer. + const input = unwrapEnvelope(message.input) + const rawOutput = message.output + const output = rawOutput != null ? unwrapEnvelope(rawOutput) : undefined + const running = !!message.running + + const errorDisplay = + !running && rawOutput != null ? parseShellErrorDisplay(rawOutput) : null + if (errorDisplay) { + return + } + + switch (message.functionId) { + case 'shell::exec': + return + case 'shell::exec_bg': + return + case 'shell::status': + return + case 'shell::kill': + return + case 'shell::list': + return + case 'shell::config-status': + return + case 'shell::fs::ls': + return + case 'shell::fs::stat': + return + case 'shell::fs::read': + return + case 'shell::fs::write': + return + case 'shell::fs::mkdir': + return + case 'shell::fs::rm': + return + case 'shell::fs::mv': + return + case 'shell::fs::chmod': + return + case 'shell::fs::grep': + return + case 'shell::fs::sed': + return + default: + return null + } +} + +function tryRenderPreview( + message: FunctionCallMessage, +): React.ReactNode | null { + if (!isShellFunction(message.functionId)) return null + const input = unwrapEnvelope(message.input) + switch (message.functionId) { + case 'shell::exec': + return + case 'shell::exec_bg': + return + case 'shell::kill': + return + default: + return null + } +} + +export const ShellToolView = { + isShellFunction, + tryRender, + /** Alias kept for FCM symmetry; running state lives inside `tryRender`. */ + tryRenderRunning: tryRender, + tryRenderPreview, +} diff --git a/console/web/src/components/chat/shell/parsers.ts b/console/web/src/components/chat/shell/parsers.ts new file mode 100644 index 00000000..504729ce --- /dev/null +++ b/console/web/src/components/chat/shell/parsers.ts @@ -0,0 +1,587 @@ +import { z } from 'zod' +import { + collectErrorCandidates, + extractFirstJsonObject, + fsEntrySchema, + fsMatchSchema, + fsSedFileResultSchema, + parseSandboxErrorDisplay, + type SandboxErrorDisplay, + safeParseRequest, + safeParseResponse, + unwrapEnvelope, +} from '@/components/chat/sandbox/parsers' + +/* Zod schemas mirroring the shell::* request/response shapes from + `workers/shell/src/{functions/types,jobs,target,configuration}.rs` and + `src/fs/{mod,wire,error}.rs`. + + The Rust handlers deserialize plain JSON via serde, so wire payloads + always match these shapes. Every schema is intentionally non-strict + (no `.strict()`); forward-compat is preserved for unknown future + fields (notably the engine-injected `_caller_worker_id`) and the + renderers only read what they need. */ + +// Re-exports so the shell views import everything from one place. +export type { + FsEntry, + FsMatch, + FsSedFileResult, + SandboxErrorDisplay, +} from '@/components/chat/sandbox/parsers' +export { + extractFirstJsonObject, + fsEntrySchema, + fsMatchSchema, + fsSedFileResultSchema, + safeParseRequest, + safeParseResponse, + unwrapEnvelope, +} + +// --------------------------------------------------------------------------- +// Function ids +// --------------------------------------------------------------------------- + +export const SHELL_FUNCTION_IDS = [ + 'shell::exec', + 'shell::exec_bg', + 'shell::status', + 'shell::kill', + 'shell::list', + 'shell::config-status', + 'shell::fs::ls', + 'shell::fs::stat', + 'shell::fs::mkdir', + 'shell::fs::write', + 'shell::fs::read', + 'shell::fs::rm', + 'shell::fs::chmod', + 'shell::fs::mv', + 'shell::fs::grep', + 'shell::fs::sed', +] as const + +export type ShellFunctionId = (typeof SHELL_FUNCTION_IDS)[number] + +const SHELL_FUNCTION_ID_SET: ReadonlySet = new Set( + SHELL_FUNCTION_IDS, +) + +export function isShellFunction(id: string): id is ShellFunctionId { + return SHELL_FUNCTION_ID_SET.has(id) +} + +// --------------------------------------------------------------------------- +// Shared building blocks +// --------------------------------------------------------------------------- + +/** `src/target.rs::Target` — internally tagged on `kind`, snake_case. + Requests carry `#[serde(default)]` on a non-Option field: an omitted + key means host, but an explicit `null` is a server-side deserialize + error — so the field is `.optional()`, NOT `.nullable()`. `sandbox_id` + is a UUID on the server; kept a lenient string here so a malformed id + still renders. Unknown `kind` values fail parse (raw-JSON fallback), + matching the server's deserialize error. */ +export const targetSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('host') }), + z.object({ kind: z.literal('sandbox'), sandbox_id: z.string() }), +]) +export type ShellTarget = z.infer + +/** `src/jobs.rs::JobStatus` — `rename_all = "lowercase"`. */ +export const jobStatusSchema = z.enum([ + 'running', + 'finished', + 'killed', + 'failed', +]) +export type JobStatus = z.infer + +/** `src/jobs.rs::JobRecord` — no renames, no skip_serializing_if: all 10 + keys always present; `finished_at_ms`/`exit_code` are explicit JSON + null when unset ⇒ `.nullable()`, NOT `.optional()`. */ +export const jobRecordSchema = z.object({ + id: z.string(), + argv: z.array(z.string()), + started_at_ms: z.number(), + finished_at_ms: z.number().nullable(), + status: jobStatusSchema, + exit_code: z.number().nullable(), + stdout: z.string(), + stderr: z.string(), + stdout_truncated: z.boolean(), + stderr_truncated: z.boolean(), +}) +export type JobRecord = z.infer + +/** `src/fs/mod.rs::ContentRef`. `direction` is optional on requests + (serde default "read") and always present on fs::read responses — + one schema covers both sides. */ +export const contentRefSchema = z.object({ + channel_id: z.string(), + access_key: z.string(), + direction: z.enum(['read', 'write']).optional(), +}) +export type ContentRef = z.infer + +/** `WriteContentWire` — `#[serde(untagged)]`: inline UTF-8 string + (host-only) or a streaming channel ref. */ +export const writeContentSchema = z.union([z.string(), contentRefSchema]) +export type WriteContent = z.infer + +// --------------------------------------------------------------------------- +// shell::exec / shell::exec_bg +// --------------------------------------------------------------------------- + +/** `ExecRequest`/`ExecBgRequest` (`src/functions/types.rs`). + - `command` is string-only on the server (array → recovery-hint + error), so `z.string()`; a request that errored server-side simply + won't render as a terminal card (raw-JSON fallback is correct there). + - `args`: absent/null ⇒ shell-words tokenization of `command`; present + (even `[]`) ⇒ verbatim argv. The null/[] distinction is semantic — + keep `.nullable().optional()` and DO NOT default to []. + - `timeout_ms` is permissive server-side (any non-u64 silently ⇒ + default timeout, never an error) — mirror with `.catch(undefined)` + so junk values don't kill the whole card. + - `cwd`/`env`/`stdin` are `Option` + `serde(default)` ⇒ both null + and absent are legal on the wire: `.nullable().optional()`. + - `env` is a BTreeMap, not sandbox's EnvShape union: plain record. */ +export const shellExecRequestSchema = z.object({ + command: z.string(), + args: z.array(z.string()).nullable().optional(), + timeout_ms: z.number().int().nonnegative().optional().catch(undefined), + cwd: z.string().nullable().optional(), + env: z.record(z.string(), z.string()).nullable().optional(), + stdin: z.string().nullable().optional(), + target: targetSchema.optional(), +}) +export type ShellExecRequest = z.infer + +/** Same struct shape on the wire (`ExecBgRequest`). Alias, not copy. */ +export const shellExecBgRequestSchema = shellExecRequestSchema +export type ShellExecBgRequest = ShellExecRequest + +/** `ExecResponse` — all 7 keys always present; `exit_code` null when + killed by signal/timeout. */ +export const shellExecResponseSchema = z.object({ + exit_code: z.number().nullable(), + stdout: z.string(), + stderr: z.string(), + duration_ms: z.number(), + timed_out: z.boolean(), + stdout_truncated: z.boolean(), + stderr_truncated: z.boolean(), +}) +export type ShellExecResponse = z.infer + +/** `ExecBgResponse` — `job_id` is `"job-"`. */ +export const shellExecBgResponseSchema = z.object({ + job_id: z.string(), + argv: z.array(z.string()), +}) +export type ShellExecBgResponse = z.infer + +// --------------------------------------------------------------------------- +// shell::status / shell::kill +// --------------------------------------------------------------------------- + +export const shellStatusRequestSchema = z.object({ job_id: z.string() }) +export type ShellStatusRequest = z.infer + +export const shellStatusResponseSchema = z.object({ job: jobRecordSchema }) +export type ShellStatusResponse = z.infer + +export const shellKillRequestSchema = z.object({ job_id: z.string() }) +export type ShellKillRequest = z.infer + +/** `KillResponse` — `reason` is the ONLY skip_serializing_if field in + the family: key ABSENT when none ⇒ `.optional()`, NOT `.nullable()`. */ +export const shellKillResponseSchema = z.object({ + job_id: z.string(), + killed: z.boolean(), + status: jobStatusSchema, + reason: z.string().optional(), +}) +export type ShellKillResponse = z.infer + +// --------------------------------------------------------------------------- +// shell::list / shell::config-status +// --------------------------------------------------------------------------- + +/** Handler ignores the payload entirely; engine injects + `_caller_worker_id`. */ +export const shellListRequestSchema = z.object({}).passthrough() +export type ShellListRequest = z.infer + +/** `JobSummary` — deliberately NO argv/stdout/stderr (cross-caller + secrecy; full record only via shell::status). */ +export const jobSummarySchema = z.object({ + id: z.string(), + status: jobStatusSchema, + started_at_ms: z.number(), + finished_at_ms: z.number().nullable(), + exit_code: z.number().nullable(), + stdout_truncated: z.boolean(), + stderr_truncated: z.boolean(), +}) +export type JobSummary = z.infer + +export const shellListResponseSchema = z.object({ + jobs: z.array(jobSummarySchema), + count: z.number(), +}) +export type ShellListResponse = z.infer + +export const shellConfigStatusRequestSchema = z.object({}).passthrough() +export type ShellConfigStatusRequest = z.infer< + typeof shellConfigStatusRequestSchema +> + +/** `configuration.rs::ReloadStatus` — `last_error` always present, + explicit null. */ +export const shellConfigStatusResponseSchema = z.object({ + last_outcome: z.enum(['applied', 'rejected']), + last_error: z.string().nullable(), + rejected_reloads: z.number(), +}) +export type ShellConfigStatusResponse = z.infer< + typeof shellConfigStatusResponseSchema +> + +// --------------------------------------------------------------------------- +// Filesystem — shell::fs::* (`src/fs/mod.rs`). FsEntry/FsMatch/ +// FsSedFileResult are reused from sandbox/parsers — they already encode +// the `file`→`path` alias and absent-`error`→null transforms. +// --------------------------------------------------------------------------- + +/** ls / stat / read share one request shape. */ +export const fsPathRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string(), +}) +export type FsPathRequest = z.infer + +export const fsLsRequestSchema = fsPathRequestSchema +export const fsStatRequestSchema = fsPathRequestSchema +export const fsReadRequestSchema = fsPathRequestSchema + +export const fsLsResponseSchema = z.object({ + entries: z.array(fsEntrySchema), +}) +export type FsLsResponse = z.infer + +/** `StatResponse` is `#[serde(transparent)]` — a bare FsEntry at the top + level, no wrapper key. */ +export const fsStatResponseSchema = fsEntrySchema +export type FsStatResponse = z.infer + +export const fsMkdirRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string(), + mode: z.string().optional(), // worker default "0755" + parents: z.boolean().optional(), // default false +}) +export type FsMkdirRequest = z.infer + +/** `path`/`already_existed` carry `#[serde(default)]` on the worker for + sandbox-engine round-trips; be equally lenient here. Sandbox-target + responses blank `path` and default the boolean — fill-ins, not + signals (views suppress the pills via `isSandboxTarget`). */ +export const fsMkdirResponseSchema = z.object({ + created: z.boolean(), + path: z.string().optional().default(''), + already_existed: z.boolean().optional().default(false), +}) +export type FsMkdirResponse = z.infer + +export const fsRmRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string(), + recursive: z.boolean().optional(), // default false +}) +export type FsRmRequest = z.infer + +export const fsRmResponseSchema = z.object({ + removed: z.boolean(), + path: z.string().optional().default(''), + was_present: z.boolean().optional().default(false), // host-only signal +}) +export type FsRmResponse = z.infer + +export const fsChmodRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string(), + mode: z.string(), // REQUIRED, octal e.g. "0755" + uid: z.number().nullable().optional(), + gid: z.number().nullable().optional(), + recursive: z.boolean().optional(), // default false +}) +export type FsChmodRequest = z.infer + +/** Callers receive `entries_changed`; `updated` is the legacy engine + alias — accepted defensively (mirrors the worker's + `#[serde(alias = "updated")]`), `entries_changed` wins. */ +export const fsChmodResponseSchema = z + .object({ + entries_changed: z.number().optional(), + updated: z.number().optional(), + path: z.string().optional().default(''), + recursive: z.boolean().optional().default(false), + }) + .refine((r) => r.entries_changed != null || r.updated != null) + .transform((r) => ({ + entries_changed: r.entries_changed ?? r.updated ?? 0, + path: r.path, + recursive: r.recursive, + })) +export type FsChmodResponse = z.infer + +export const fsMvRequestSchema = z.object({ + target: targetSchema.optional(), + src: z.string(), + dst: z.string(), + overwrite: z.boolean().optional(), // default false +}) +export type FsMvRequest = z.infer + +export const fsMvResponseSchema = z.object({ + moved: z.boolean(), + src: z.string().optional().default(''), + dst: z.string().optional().default(''), + overwrote: z.boolean().optional().default(false), // host best-effort +}) +export type FsMvResponse = z.infer + +/** `recursive` defaults TRUE on the wire (unlike rm/chmod) — chips show + the deviation ("non-recursive"). */ +export const fsGrepRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string(), + pattern: z.string(), + recursive: z.boolean().optional(), // default TRUE on the wire + ignore_case: z.boolean().optional(), + include_glob: z.array(z.string()).optional(), + exclude_glob: z.array(z.string()).optional(), + max_matches: z.number().optional(), // default 10000 + max_line_bytes: z.number().optional(), // default 4096 +}) +export type FsGrepRequest = z.infer + +export const fsGrepResponseSchema = z.object({ + matches: z.array(fsMatchSchema), + truncated: z.boolean(), +}) +export type FsGrepResponse = z.infer + +/** `recursive` and `regex` both default TRUE on the wire — chips show + the deviations ("non-recursive", "literal"). */ +export const fsSedRequestSchema = z.object({ + target: targetSchema.optional(), + files: z.array(z.string()).optional(), + path: z.string().nullable().optional(), + recursive: z.boolean().optional(), // default TRUE + include_glob: z.array(z.string()).optional(), + exclude_glob: z.array(z.string()).optional(), + pattern: z.string(), + replacement: z.string(), + regex: z.boolean().optional(), // default TRUE + first_only: z.boolean().optional(), + ignore_case: z.boolean().optional(), +}) +export type FsSedRequest = z.infer + +export const fsSedResponseSchema = z.object({ + results: z.array(fsSedFileResultSchema), + total_replacements: z.number(), +}) +export type FsSedResponse = z.infer + +export const writeFileSpecSchema = z.object({ + path: z.string(), + content: writeContentSchema, + mode: z.string().optional(), // default "0644" + parents: z.boolean().optional(), +}) +export type WriteFileSpec = z.infer + +/** Single-file and batch forms share one lenient schema; the view + branches on `files` being a non-empty array. (Mutual-exclusion is + enforced server-side via S210, not by serde.) */ +export const fsWriteRequestSchema = z.object({ + target: targetSchema.optional(), + path: z.string().nullable().optional(), + content: writeContentSchema.nullable().optional(), + mode: z.string().nullable().optional(), + parents: z.boolean().nullable().optional(), + files: z.array(writeFileSpecSchema).nullable().optional(), +}) +export type FsWriteRequest = z.infer + +export const writeFileResultSchema = z.object({ + path: z.string(), + bytes_written: z.number(), +}) +export type WriteFileResult = z.infer + +export const fsWriteResponseSchema = z.object({ + bytes_written: z.number(), + path: z.string(), + files: z.array(writeFileResultSchema).optional().default([]), +}) +export type FsWriteResponse = z.infer + +/** `ReadResponseWire` — `content` is ALWAYS a channel ref, never inline + (the handler converts before serializing). */ +export const fsReadResponseSchema = z.object({ + content: contentRefSchema, + size: z.number(), + mode: z.string(), + mtime: z.number(), +}) +export type FsReadResponse = z.infer + +// --------------------------------------------------------------------------- +// Errors — flat SDK ErrorBody `{ code, message, stacktrace? }` plus the +// harness re-wrap that embeds the S-code into a denial-reason string. +// --------------------------------------------------------------------------- + +/** The SDK `ErrorBody` carrying a typed shell S-code (`IIIError::Remote`). + No `type` field — sandbox's `sandboxErrorWireSchema` requires one, so + shell errors need this dedicated schema. The regex keeps it from + false-positiving on arbitrary `{ code, message }` objects. */ +export const shellErrorWireSchema = z.object({ + code: z.string().regex(/^S\d{3}$/), + message: z.string(), + stacktrace: z.string().optional(), +}) +export type ShellErrorWire = z.infer + +/** Human labels for the S-codes documented in `shell/src/main.rs`. */ +export const S_CODE_LABEL: Record = { + S200: 'in-VM failure', + S210: 'bad request', + S211: 'not found', + S212: 'wrong file type', + S213: 'already exists', + S214: 'directory not empty', + S215: 'permission denied', + S216: 'i/o error', + S217: 'bad regex', + S218: 'size cap exceeded', + S300: 'vm boot failed', +} + +/** Lift a flat shell error into the existing `SandboxErrorDisplay` wire + variant so `SandboxErrorView` renders it unchanged (S-code Badge + + label + message). */ +function shellWire(e: { code: string; message: string }): SandboxErrorDisplay { + return { + variant: 'wire', + error: { + type: S_CODE_LABEL[e.code] ?? 'shell error', + code: e.code, + message: e.message, + }, + } +} + +/** exec_bg stringifies its S-code into the message ("S215: …"), and the + SDK Display impl prefixes "handler error: " / "serialization error: ". + Pull the embedded S-code out of free text. */ +export function extractEmbeddedSCode(message: string): string | null { + return message.match(/\b(S\d{3}):/)?.[1] ?? null +} + +/** Strip the harness/SDK Display prefixes ("trigger_failed: : ", + "handler error: ", "serialization error: ") for the headline — the + raw text stays available in the raw-json tab. */ +export function stripHandlerPrefix(message: string): string { + return message + .replace(/^trigger_failed:.*?:\s*/, '') + .replace(/^(?:handler error|serialization error):\s*/, '') +} + +/** Synthesize a wire display from an S-code embedded in free text — + `"trigger_failed: IIIInvocationError: S211: no such job: job-x"` → + `{ code: 'S211', message: 'no such job: job-x' }`. */ +function wireFromText(text: string): SandboxErrorDisplay | null { + const match = text.match(/\b(S\d{3}):\s*([\s\S]+)/) + if (!match) return null + return shellWire({ + code: match[1], + message: stripHandlerPrefix(match[2].trim()), + }) +} + +/** Every string the harness might hide an S-code in: string candidates + from the shared traversal, plus `reason`/`message` fields of object + candidates (the gate-denial envelope keeps its prose in `reason`, + which the generic traversal surfaces only as an object). */ +function collectStringCandidates(value: unknown): string[] { + const seen = new Set() + const out: string[] = [] + const push = (candidate: unknown) => { + if (typeof candidate !== 'string' || candidate.length === 0) return + if (seen.has(candidate)) return + seen.add(candidate) + out.push(candidate) + } + + for (const candidate of collectErrorCandidates(value)) { + for (const layer of [candidate, unwrapEnvelope(candidate)]) { + push(layer) + if (layer && typeof layer === 'object' && !Array.isArray(layer)) { + const obj = layer as Record + push(obj.reason) + push(obj.message) + } + } + } + + return out +} + +/** + * Normalise every failure shape the console may receive for shell calls, + * in priority order: + * + * 1. Flat SDK `ErrorBody` with a typed S-code — direct objects anywhere + * in the candidate traversal, or JSON embedded in strings. + * 2. S-codes stringified into prose (the load-bearing real-world path): + * the harness re-wraps shell's plain-text errors into a gate-denial + * envelope whose `reason` reads e.g. `"trigger_failed: + * IIIInvocationError: S211: no such job: job-x"` — scan every string + * candidate and synthesize the wire display from text. + * 3. Delegate to `parseSandboxErrorDisplay` for denials without S-codes, + * generic function_error envelopes, and harness wrappers (identical + * across families). + * + * Returns the existing `SandboxErrorDisplay` union, rendered by + * `SandboxErrorView` unchanged. + */ +export function parseShellErrorDisplay( + value: unknown, +): SandboxErrorDisplay | null { + const candidates = collectErrorCandidates(value) + + for (const candidate of candidates) { + const direct = shellErrorWireSchema.safeParse(candidate) + if (direct.success) return shellWire(direct.data) + + if (typeof candidate === 'string') { + const embedded = extractFirstJsonObject(candidate) + if (embedded != null) { + const fromJson = shellErrorWireSchema.safeParse(embedded) + if (fromJson.success) return shellWire(fromJson.data) + } + } + } + + for (const text of collectStringCandidates(value)) { + const synthesized = wireFromText(text) + if (synthesized) return synthesized + } + + return parseSandboxErrorDisplay(value) +} diff --git a/console/web/src/components/chat/shell/shared.tsx b/console/web/src/components/chat/shell/shared.tsx new file mode 100644 index 00000000..ea6e54db --- /dev/null +++ b/console/web/src/components/chat/shell/shared.tsx @@ -0,0 +1,43 @@ +import { truncateMiddle } from '@/components/chat/sandbox/format' +import { Chip } from '@/components/chat/sandbox/terminal/Terminal' +import type { ShellTarget } from './parsers' + +/** Narrow a request `target` to the sandbox variant. Also the views' + "suppress serde fill-ins" signal — sandbox-target responses blank + `path`/`src`/`dst` and default `already_existed`/`was_present`/ + `overwrote`, so those pills are meaningless there. */ +export function isSandboxTarget( + target: ShellTarget | undefined, +): target is Extract { + return target?.kind === 'sandbox' +} + +interface TargetChipProps { + target?: ShellTarget + /** exec/exec_bg (and their previews) set this: host is the privileged + execution surface approval decisions hinge on, so the default gets + an explicit chip. fs views omit it — host stays implicit and only + the sandbox deviation gets a chip. */ + explicitHost?: boolean +} + +/** Where the call runs. Label `on` (avoids colliding with sed's `target` + chip); neutral tone for both kinds — most calls are host, and a + permanently warn header reads as a perpetual alarm. `target` omitted + on the wire means host (serde default); normalised here so views + don't repeat the rule. */ +export function TargetChip({ target, explicitHost }: TargetChipProps) { + if (isSandboxTarget(target)) { + return ( + {`sandbox ${truncateMiddle(target.sandbox_id, 12)}`} + ) + } + if (!explicitHost) return null + return host +} + +/** Sandbox-target responses fill `path`/`src`/`dst` with `""` — prefer + the canonicalised response path when present, else the request path. */ +export function displayPath(reqPath: string, respPath?: string): string { + return respPath && respPath.length > 0 ? respPath : reqPath +} From c2831858ef7cc3d2c6bb61395a0490a37977c939 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Fri, 12 Jun 2026 11:57:14 -0300 Subject: [PATCH 2/2] feat(console): storybook coverage for shell::* chat views Add shell-fixtures.ts with 28 wire-accurate fixtures covering all 16 shell worker functions and a ShellFamily gallery story alongside the existing sandbox/coder/web families. - mirrors the serde wire shapes pinned by the shell parsers: transparent fs::stat, channel-ref-only fs::read, batch + streamed fs::write, kill reason skip-serialized, sandbox target variant - state variants: running, approval previews for exec/exec_bg/kill - both error paths: flat S215 ErrorBody and the harness gate-denial envelope with S211 embedded in the reason text Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/FunctionCallMessage.stories.tsx | 6 + .../src/stories/fixtures/shell-fixtures.ts | 478 ++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 console/web/src/stories/fixtures/shell-fixtures.ts diff --git a/console/web/src/components/chat/FunctionCallMessage.stories.tsx b/console/web/src/components/chat/FunctionCallMessage.stories.tsx index 6901078e..de061b70 100644 --- a/console/web/src/components/chat/FunctionCallMessage.stories.tsx +++ b/console/web/src/components/chat/FunctionCallMessage.stories.tsx @@ -3,6 +3,7 @@ import { coderFixtures } from '@/stories/fixtures/coder-fixtures' import { directoryFixtures } from '@/stories/fixtures/directory-fixtures' import { engineFixtures } from '@/stories/fixtures/engine-fixtures' import { sandboxFixtures } from '@/stories/fixtures/sandbox-fixtures' +import { shellFixtures } from '@/stories/fixtures/shell-fixtures' import { webFixtures } from '@/stories/fixtures/web-fixtures' import { workerFixtures } from '@/stories/fixtures/worker-fixtures' import type { FunctionCallMessage as FCallType } from '@/types/chat' @@ -125,6 +126,11 @@ export const SandboxFamily: Story = { render: () => , } +export const ShellFamily: Story = { + name: 'shell family', + render: () => , +} + export const DirectoryFamily: Story = { name: 'directory family', render: () => , diff --git a/console/web/src/stories/fixtures/shell-fixtures.ts b/console/web/src/stories/fixtures/shell-fixtures.ts new file mode 100644 index 00000000..c662edd5 --- /dev/null +++ b/console/web/src/stories/fixtures/shell-fixtures.ts @@ -0,0 +1,478 @@ +import type { FunctionCallMessage } from '@/types/chat' +import { wrapHarness } from './sandbox-fixtures' + +const now = Date.now() +const JOB = 'job-9f8e7d6c-5b4a-4310-aedc-ba9876543210' +const SANDBOX = 'a1b2c3d4-5e6f-4a8b-9c0d-e1f2a3b4c5d6' + +function base( + id: string, + functionId: string, + input: unknown, + output?: unknown, + extra?: Partial, +): FunctionCallMessage { + return { + id, + role: 'function-call', + functionId, + input, + output, + durationMs: 240, + createdAt: now, + ...extra, + } +} + +export const shellExecDone = base( + 'sh-exec', + 'shell::exec', + { + command: 'ls -la', + cwd: '/srv/app', + timeout_ms: 30_000, + }, + wrapHarness({ + exit_code: 0, + stdout: + 'total 8\ndrwxr-xr-x 2 app app 4096 May 26 10:00 .\ndrwxr-xr-x 1 app app 4096 May 26 09:58 ..\n-rw-r--r-- 1 app app 12 May 26 10:00 README.md\n', + stderr: '', + duration_ms: 142, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }), +) + +export const shellExecArgvDone = base( + 'sh-exec-argv', + 'shell::exec', + { + command: 'git', + args: ['log', '--oneline', '-3'], + cwd: '/srv/app', + env: { GIT_PAGER: 'cat' }, + }, + { + exit_code: 0, + stdout: + '8fbe7a1 feat(console): friendly chat views\nab75f28 chore(coder): bump to v0.5.1\n82e1c5d chore(shell): bump to v0.5.0\n', + stderr: '', + duration_ms: 58, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }, +) + +export const shellExecSandboxDone = base( + 'sh-exec-sandbox', + 'shell::exec', + { + command: 'uname -a', + target: { kind: 'sandbox', sandbox_id: SANDBOX }, + }, + wrapHarness({ + exit_code: 0, + stdout: 'Linux sandbox 6.1.0 #1 SMP x86_64 GNU/Linux\n', + stderr: '', + duration_ms: 35, + timed_out: false, + stdout_truncated: false, + stderr_truncated: false, + }), +) + +export const shellExecRunning = base( + 'sh-exec-running', + 'shell::exec', + { command: 'npm test', cwd: '/srv/app' }, + undefined, + { running: true }, +) + +export const shellExecPending = base( + 'sh-exec-pending', + 'shell::exec', + { command: 'rm -rf /tmp/scratch' }, + undefined, + { pendingApproval: true }, +) + +export const shellExecBgDone = base( + 'sh-exec-bg', + 'shell::exec_bg', + { command: 'npm run build', cwd: '/srv/app' }, + wrapHarness({ job_id: JOB, argv: ['npm', 'run', 'build'] }), +) + +export const shellExecBgPending = base( + 'sh-exec-bg-pending', + 'shell::exec_bg', + { command: 'cargo build --release', cwd: '/srv/app' }, + undefined, + { pendingApproval: true }, +) + +export const shellStatusFinishedDone = base( + 'sh-status-finished', + 'shell::status', + { job_id: JOB }, + { + job: { + id: JOB, + argv: ['npm', 'run', 'build'], + started_at_ms: now - 9_500, + finished_at_ms: now - 1_200, + status: 'finished', + exit_code: 0, + stdout: '> build\n> vite build\n\ndist/index.html 0.46 kB\n', + stderr: '', + stdout_truncated: false, + stderr_truncated: false, + }, + }, +) + +export const shellStatusRunningDone = base( + 'sh-status-running', + 'shell::status', + { job_id: JOB }, + wrapHarness({ + job: { + id: JOB, + argv: ['npm', 'run', 'build'], + started_at_ms: now - 3_000, + finished_at_ms: null, + status: 'running', + exit_code: null, + stdout: '> build\n> vite build\n', + stderr: '', + stdout_truncated: false, + stderr_truncated: false, + }, + }), +) + +export const shellKillDone = base( + 'sh-kill', + 'shell::kill', + { job_id: JOB }, + wrapHarness({ job_id: JOB, killed: true, status: 'killed' }), +) + +export const shellKillAlreadyFinished = base( + 'sh-kill-finished', + 'shell::kill', + { job_id: JOB }, + { + job_id: JOB, + killed: false, + status: 'finished', + reason: 'job already finished', + }, +) + +export const shellKillPending = base( + 'sh-kill-pending', + 'shell::kill', + { job_id: JOB }, + undefined, + { pendingApproval: true }, +) + +export const shellListDone = base( + 'sh-list', + 'shell::list', + {}, + wrapHarness({ + jobs: [ + { + id: JOB, + status: 'running', + started_at_ms: now - 3_000, + finished_at_ms: null, + exit_code: null, + stdout_truncated: false, + stderr_truncated: false, + }, + { + id: 'job-1a2b3c4d-0001-4000-8000-000000000001', + status: 'finished', + started_at_ms: now - 60_000, + finished_at_ms: now - 58_000, + exit_code: 0, + stdout_truncated: false, + stderr_truncated: false, + }, + { + id: 'job-1a2b3c4d-0002-4000-8000-000000000002', + status: 'failed', + started_at_ms: now - 120_000, + finished_at_ms: now - 119_000, + exit_code: 1, + stdout_truncated: true, + stderr_truncated: false, + }, + ], + count: 3, + }), +) + +export const shellConfigStatusDone = base( + 'sh-config-status', + 'shell::config-status', + {}, + { last_outcome: 'applied', last_error: null, rejected_reloads: 0 }, +) + +export const shellConfigStatusRejected = base( + 'sh-config-status-rejected', + 'shell::config-status', + {}, + wrapHarness({ + last_outcome: 'rejected', + last_error: 'jail_dir must be an absolute path', + rejected_reloads: 2, + }), +) + +export const shellFsLsDone = base( + 'sh-ls', + 'shell::fs::ls', + { path: '/srv/app' }, + { + entries: [ + { + name: 'src', + is_dir: true, + size: 4096, + mode: '0755', + mtime: Math.floor(now / 1000) - 180, + is_symlink: false, + }, + { + name: 'package.json', + is_dir: false, + size: 412, + mode: '0644', + mtime: Math.floor(now / 1000) - 60, + is_symlink: false, + }, + ], + }, +) + +export const shellFsStatDone = base( + 'sh-stat', + 'shell::fs::stat', + { path: '/srv/app/package.json' }, + // StatResponse is #[serde(transparent)] — a bare FsEntry, no wrapper key. + wrapHarness({ + name: 'package.json', + is_dir: false, + size: 412, + mode: '0644', + mtime: Math.floor(now / 1000) - 60, + is_symlink: false, + }), +) + +export const shellFsReadDone = base( + 'sh-read', + 'shell::fs::read', + { path: '/srv/app/src/index.ts' }, + // ReadResponseWire: content is ALWAYS a streaming channel ref. + { + content: { + channel_id: 'chan-2f9c1b7e-aa00-4f00-9d00-7c6b5a493827', + access_key: 'ak_4f8e2d1c9b7a', + direction: 'read', + }, + size: 48, + mode: '0644', + mtime: Math.floor(now / 1000) - 30, + }, +) + +export const shellFsWriteDone = base( + 'sh-write', + 'shell::fs::write', + { path: '/srv/app/out.txt', content: 'hello\n', parents: true }, + wrapHarness({ bytes_written: 6, path: '/srv/app/out.txt', files: [] }), +) + +export const shellFsWriteBatchDone = base( + 'sh-write-batch', + 'shell::fs::write', + { + files: [ + { path: '/srv/app/a.txt', content: 'alpha\n' }, + { + path: '/srv/app/b.txt', + content: { + channel_id: 'chan-77aa2bcd-bb11-4e22-8f33-9d44ee55ff66', + access_key: 'ak_streamed01', + direction: 'write', + }, + mode: '0600', + }, + ], + }, + { + bytes_written: 26, + path: '', + files: [ + { path: '/srv/app/a.txt', bytes_written: 6 }, + { path: '/srv/app/b.txt', bytes_written: 20 }, + ], + }, +) + +export const shellFsMkdirDone = base( + 'sh-mkdir', + 'shell::fs::mkdir', + { path: '/srv/app/nested/dir', parents: true, mode: '0755' }, + { created: true, path: '/srv/app/nested/dir', already_existed: false }, +) + +export const shellFsRmDone = base( + 'sh-rm', + 'shell::fs::rm', + { path: '/srv/app/tmp', recursive: true }, + wrapHarness({ removed: true, path: '/srv/app/tmp', was_present: true }), +) + +export const shellFsMvDone = base( + 'sh-mv', + 'shell::fs::mv', + { src: '/srv/app/old.txt', dst: '/srv/app/new.txt', overwrite: true }, + { + moved: true, + src: '/srv/app/old.txt', + dst: '/srv/app/new.txt', + overwrote: true, + }, +) + +export const shellFsChmodDone = base( + 'sh-chmod', + 'shell::fs::chmod', + { path: '/srv/app/scripts', mode: '0755', recursive: true }, + { entries_changed: 3, path: '/srv/app/scripts', recursive: true }, +) + +export const shellFsGrepDone = base( + 'sh-grep', + 'shell::fs::grep', + { + path: '/srv/app', + pattern: 'TODO', + ignore_case: true, + include_glob: ['*.ts'], + }, + wrapHarness({ + matches: [ + { + path: '/srv/app/src/app.ts', + line: 14, + content: ' // TODO: wire shell UI', + }, + { + path: '/srv/app/src/jobs.ts', + line: 3, + content: '// TODO document job lifecycle', + }, + ], + truncated: false, + }), +) + +export const shellFsSedDone = base( + 'sh-sed', + 'shell::fs::sed', + { + files: ['/srv/app/a.txt', '/srv/app/b.txt'], + pattern: 'foo', + replacement: 'bar', + regex: false, + }, + { + results: [ + { path: '/srv/app/a.txt', replacements: 2, success: true }, + { + path: '/srv/app/b.txt', + replacements: 0, + success: false, + error: 'permission denied', + }, + ], + total_replacements: 2, + }, +) + +/** Flat SDK ErrorBody with a typed S-code (the direct error path). */ +export const shellExecJailError = base( + 'sh-exec-jail-err', + 'shell::exec', + { command: 'cat /etc/passwd', cwd: '/etc' }, + wrapHarness({ code: 'S215', message: 'cwd escapes jail: /etc' }), +) + +/** The real-world path: harness gate-denial envelope whose `reason` + embeds the S-code as text (`parseShellErrorDisplay` text-scan). */ +export const shellStatusGateError = base( + 'sh-status-gate-err', + 'shell::status', + { job_id: 'job-x' }, + { + error: { + kind: 'function_error', + message: 'trigger_failed: IIIInvocationError: invocation_failed', + details: { + schema_version: 1, + status: 'denied', + denied_by: 'gate_unavailable', + function_id: 'shell::status', + reason: 'trigger_failed: IIIInvocationError: S211: no such job: job-x', + }, + content: [ + { + type: 'text', + text: 'trigger_failed: IIIInvocationError: S211: no such job: job-x', + }, + ], + }, + }, +) + +export const shellFixtures = [ + shellExecDone, + shellExecArgvDone, + shellExecSandboxDone, + shellExecRunning, + shellExecPending, + shellExecBgDone, + shellExecBgPending, + shellStatusFinishedDone, + shellStatusRunningDone, + shellKillDone, + shellKillAlreadyFinished, + shellKillPending, + shellListDone, + shellConfigStatusDone, + shellConfigStatusRejected, + shellFsLsDone, + shellFsStatDone, + shellFsReadDone, + shellFsWriteDone, + shellFsWriteBatchDone, + shellFsMkdirDone, + shellFsRmDone, + shellFsMvDone, + shellFsChmodDone, + shellFsGrepDone, + shellFsSedDone, + shellExecJailError, + shellStatusGateError, +] as const