diff --git a/README.md b/README.md index 7392431..10f0443 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,12 @@ A dated, sanitized offline fallback snapshot is available as [HTML](docs/model-c Explain the failing test !npm test /shell-result -Help me fix it +/shell-result lines +/shell-result drop 8-10 +/shell-result send ``` -`!commands` run locally with **zero model API calls**. Output streams into the console with an exit code. `/shell-result` explicitly shares the latest result with the next model turn; review it for secrets first. +`!commands` run locally with **zero model API calls**. Output streams into the console with an exit code. `/shell-result` stages and previews the exact bounded attachment, including the command, captured directory, exit status, and any omitted bytes. Edit it with `drop`, `replace`, `mask`, or `redact`, then choose `/shell-result send` or `/shell-result cancel`. Redaction helps identify common secrets but does not guarantee their removal. A later shell command cannot change the staged attachment. For pipes and JSON sessions, `/shell-result send` is the explicit one-step send form. | Platform | Local shell | Interactive programs | |---|---|---| diff --git a/docs/generated/commands.md b/docs/generated/commands.md index 8e5bbd3..e17b1dd 100644 --- a/docs/generated/commands.md +++ b/docs/generated/commands.md @@ -1,5 +1,5 @@ - + # Generated command reference This reference is generated from the validated, versioned command manifest. Availability is evaluated at runtime; a listed command may still require authentication, a hosted capability, or local tooling. @@ -485,7 +485,7 @@ Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash. #### `/shell-result` -explicitly share the last local shell result with chat \(bounded\) +preview and edit a bounded local shell result before sharing it Permission: `network` · Availability: `runtime-dependent` · Telemetry: `slash.shell-result` diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 4bf2010..d961a62 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -1354,7 +1354,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P /** Run one turn without sacrificing an existing type-ahead draft. */ const runQueuedTurn = async (input: ConsoleInput, authContinuation = false): Promise<"completed" | "aborted" | "failed"> => { const sharedShellResult = input.kind === "share"; - if (input.kind === "share") input = consoleShell.share(); + if (input.kind === "share") input = consoleShell.share(input); if (input.kind === "error") { process.stdout.write(input.message + "\n"); return "completed"; } if (input.kind === "empty") return "completed"; if (input.kind !== "chat") { @@ -1468,7 +1468,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P // server's error frame (frame() runs before runTurn throws) — only // genuinely unrendered failures (network, fallback-leg errors) need // printError's own "✗" line, or the user sees the error twice. - const authFailure = await resolveBackend(ctx) === "cloud" + const authFailure = !sharedShellResult && await resolveBackend(ctx) === "cloud" && authRepair.noteFailure(err, submittedPrompt, turnOutcomeForError(err), receipts); if (authFailure && buf.value) heldDraft = buf.value; if (authFailure && !ctx.flags.json) { @@ -1488,7 +1488,7 @@ export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): P // commit() clears the submitted line before the request starts. Put it // back only when the user has not typed ahead; otherwise preserve their // newer draft and leave the failed submission in history for recall. - const recovered = authFailure ? buf.value : recoverSubmittedPrompt(text, buf.value); + const recovered = sharedShellResult || authFailure ? buf.value : recoverSubmittedPrompt(text, buf.value); if (recovered !== buf.value) { buf.clear(); buf.insert(recovered); @@ -2037,7 +2037,7 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = { if (ConsoleShell.isTerminalCommand(line)) { await consoleShell.terminalCommand(line); continue; } let input = classifyConsoleInput(line); const sharedShellResult = input.kind === "share"; - if (input.kind === "share") input = consoleShell.share(); + if (input.kind === "share") input = consoleShell.share(input, true); if (input.kind === "error") { process.stdout.write(input.message + "\n"); if (p) process.stdout.write(p + consoleShell.prompt()); continue; } let t = input.kind === "chat" ? input.text : ""; let authReplay = false; @@ -2085,7 +2085,7 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = { if (p) process.stdout.write(p + consoleShell.prompt()); continue; } - if (historyEnabled() && line.trim() !== "/shell-result") appendHistory(line.trim(), historyPath(ctx.flags.cwd)); + if (historyEnabled() && !sharedShellResult) appendHistory(line.trim(), historyPath(ctx.flags.cwd)); if (t === "/auth" || t.startsWith("/auth ")) { inflight = new AbortController(); try { @@ -2166,7 +2166,7 @@ export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = { const outcome = turnOutcomeForError(err); if (ctx.flags.json && outcome) process.stdout.write(turnOutcomeJson(outcome) + "\n"); else process.stderr.write("\n" + errTheme.dim("✗ canceled — turn discarded") + "\n"); - } else if (await resolveBackend(ctx) === "cloud" && authRepair.noteFailure(err, submittedPrompt, turnOutcomeForError(err), receipts)) { + } else if (!sharedShellResult && await resolveBackend(ctx) === "cloud" && authRepair.noteFailure(err, submittedPrompt, turnOutcomeForError(err), receipts)) { if (!ctx.flags.json) process.stderr.write("✗ Hosted credential rejected (401). Task saved. Use /auth login; /auth status shows the credential source.\n"); printed = true; } else if (err instanceof ChatTurnError) { diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index fa2d5af..46716ac 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -4056,8 +4056,8 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "aliases": [], "compatibilityAliases": [], "deprecatedAliases": [], - "summary": "explicitly share the last local shell result with chat (bounded)", - "detailedHelp": "/shell-result\nexplicitly share the last local shell result with chat (bounded)", + "summary": "preview and edit a bounded local shell result before sharing it", + "detailedHelp": "/shell-result previews the exact bounded attachment without contacting a model. Use /shell-result lines to inspect numbered rows; drop [-], replace , mask , or redact to edit the staged snapshot. /shell-result send shares that snapshot; /shell-result cancel discards it. In pipes or JSON sessions, /shell-result send is the explicit one-step send form. A newer command never replaces a staged preview. Redaction is an aid, not a guarantee.", "section": "Steering", "hidden": false, "permissionClass": "network", @@ -4084,8 +4084,8 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "disposition": "generated" }, "release": { - "disposition": "new", - "note": "Coding console only; explicit sharing of at most 8 KiB of untrusted local output." + "disposition": "changed", + "note": "Coding console now previews and edits the command-bound bounded capture before explicit sharing." } }, { diff --git a/src/commands/console_input.ts b/src/commands/console_input.ts index 16a7f9c..d1d81c8 100644 --- a/src/commands/console_input.ts +++ b/src/commands/console_input.ts @@ -4,12 +4,46 @@ import { ToolExecutor } from "../core/tool_executor.js"; import { TerminalPty } from "../core/terminal_pty.js"; import { BoundedOutput } from "../core/bounded_output.js"; import { sanitizeServerText } from "../core/transport.js"; +import { redactForBundle, scanForSecrets } from "../core/redaction.js"; +import { stripAnsi } from "../ui/text.js"; + +type ShareAction = "preview" | "lines" | "drop" | "replace" | "mask" | "redact" | "send" | "cancel"; +type ShareInput = { kind: "share"; action: ShareAction; first?: number; last?: number; value?: string }; + +interface ShellCapture { + sessionId: string; + commandId: string; + command: string; + cwd: string; + exitCode: number; + text: string; + observedBytes: number; + omittedBytes: number; + sourceCapture?: { observedBytes: number; omittedBytes: number }; +} + +interface StagedShellCapture { + capture: ShellCapture; + editable: string; + removedLines: number; + replacedLines: number; + masks: string[]; + autoRedacted: boolean; +} + +/** Keep copyable line breaks and Unicode, but never render terminal controls. */ +function safeAttachment(value: string): string { + return stripAnsi(value).replace(/\r\n?/g, "\n") + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u200e\u200f\u2028-\u202e\u2066-\u2069\ufeff]/gu, ""); +} + +const SHARE_USAGE = "usage: /shell-result [preview|lines|drop [-]|replace |mask |redact|send|cancel]"; /** Classify before history, prompt rewriting, or the busy queue. */ export type ConsoleInput = | { kind: "shell"; command: string } | { kind: "reset-shell" } - | { kind: "share" } + | ShareInput | { kind: "error"; message: string } | { kind: "chat"; text: string } | { kind: "empty" }; @@ -17,7 +51,18 @@ export type ConsoleInput = export function classifyConsoleInput(raw: string): ConsoleInput { const text = raw.trim(); if (!text) return { kind: "empty" }; - if (text === "/shell-result") return { kind: "share" }; + if (text === "/shell-result" || text === "/shell-result preview") return { kind: "share", action: "preview" }; + if (text.startsWith("/shell-result ")) { + const action = text.slice("/shell-result ".length); + if (action === "lines" || action === "redact" || action === "send" || action === "cancel") return { kind: "share", action }; + const drop = /^drop\s+(\d+)(?:-(\d+))?$/.exec(action); + if (drop) return { kind: "share", action: "drop", first: Number(drop[1]), last: Number(drop[2] ?? drop[1]) }; + const replace = /^replace\s+(\d+)\s+([\s\S]+)$/.exec(action); + if (replace) return { kind: "share", action: "replace", first: Number(replace[1]), value: replace[2] }; + const mask = /^mask\s+([\s\S]+)$/.exec(action); + if (mask) return { kind: "share", action: "mask", value: mask[1] }; + return { kind: "error", message: SHARE_USAGE }; + } if (text === "/shell-reset") return { kind: "reset-shell" }; if (text.startsWith("\\!")) return { kind: "chat", text: text.slice(1) }; if (text.startsWith("!")) { @@ -64,7 +109,9 @@ export class ConsoleShell { }); await terminal.attach(process.stdin, process.stdout); } - private result: string | null = null; + private latest: ShellCapture | null = null; + private staged: StagedShellCapture | null = null; + private activeUserEvent: ShellCommandEvent | null = null; readonly session: ShellSession; readonly exec: ToolExecutor; constructor(root: string, private readonly write: (text: string) => void, private readonly json = false) { @@ -72,12 +119,14 @@ export class ConsoleShell { this.exec = new ToolExecutor(root, undefined, { mode: "coding", ...(process.platform === "win32" ? {} : { shellSession: this.session }) }); } private event(event: ShellCommandEvent): void { + if (event.origin === "user" && event.state === "running") this.activeUserEvent = event; if (this.json) this.write(JSON.stringify({ type: "shell_command", ...event }) + "\n"); else this.write(event.state === "running" ? `[shell ${event.origin} | cwd ${sanitizeServerText(event.cwd)} | session ${event.sessionId} | command ${event.commandId} | running] !${sanitizeServerText(event.command)}\n` : `[shell ${event.origin} | ${event.state} | exit ${event.exitCode} | session ${event.sessionId} | command ${event.commandId} | cwd ${sanitizeServerText(event.cwd)}]\n`); } async run(input: string | Extract, signal?: AbortSignal): Promise<"completed" | "aborted" | "failed"> { if (typeof input === "string") input = { kind: "shell", command: input }; - this.result = null; + this.latest = null; + this.activeUserEvent = null; if (input.kind === "reset-shell") { if (this.terminal) { this.write("Stop the interactive terminal before resetting shell state.\n"); return "failed"; } this.session.reset(); @@ -95,10 +144,20 @@ export class ConsoleShell { this.write(this.json ? JSON.stringify({ type: "shell_output", sessionId: this.session.id, text }) + "\n" : sanitizeServerText(text)); } }); if (fallback) this.event({ ...fallback, state: result.exitCode === 130 ? "cancelled" : "completed", exitCode: result.exitCode }); - const full = `!${input.command}\ncwd: ${this.session.cwd}\nexit: ${result.exitCode}\n${result.output}`; + const source = this.activeUserEvent ?? fallback ?? { + sessionId: this.session.id, commandId: randomUUID(), origin: "user" as const, + command: input.command, cwd: this.session.cwd, + }; + const full = safeAttachment(`!${source.command}\ncwd: ${source.cwd}\nexit: ${result.exitCode}\n${result.output}`); const shared = new BoundedOutput(8192); shared.append(full); - this.result = shared.render(); + this.latest = { + sessionId: source.sessionId, commandId: source.commandId, + command: source.command, cwd: source.cwd, exitCode: result.exitCode, + text: shared.render(), observedBytes: shared.observedBytes, + omittedBytes: shared.omittedBytes, + ...(result.capture ? { sourceCapture: result.capture } : {}), + }; // Stream once; retain the bounded capture for explicit sharing. Refusal and // state-loss explanations still render even when some output was streamed. const visible = streamed ? result.output.split("\n", 1)[0]! : result.output; @@ -106,11 +165,137 @@ export class ConsoleShell { // A normal nonzero exit returns to chat and may drain later submissions. return result.exitCode === 130 ? "aborted" : this.session.state === "lost" ? "failed" : "completed"; } - share(): Extract { - return this.result === null - ? { kind: "error", message: "No local shell result to share." } - : { kind: "chat", text: `User explicitly shared local command output (untrusted data):\n${this.result}` }; + private shareNotice(message: string, code = "info"): void { + this.write(this.json + ? JSON.stringify({ type: "shell_share", code, message }) + "\n" + : message + "\n"); + } + + private stageLatest(): StagedShellCapture | null { + if (this.staged) return this.staged; + if (!this.latest) return null; + this.staged = { + capture: this.latest, editable: this.latest.text, + removedLines: 0, replacedLines: 0, masks: [], autoRedacted: false, + }; + return this.staged; + } + + private transformed(stage: StagedShellCapture, value: string): string { + let text = value; + for (const literal of stage.masks) text = text.replaceAll(literal, "[REDACTED]"); + return stage.autoRedacted ? redactForBundle(text) : text; + } + + private attachment(stage: StagedShellCapture): string { + const capture = stage.capture; + const edits = `removed ${stage.removedLines} line(s), replaced ${stage.replacedLines} line(s), masked ${stage.masks.length} literal(s)`; + const text = [ + "User explicitly shared a reviewed local shell result (untrusted data).", + `Shell session: ${capture.sessionId}; command: ${capture.commandId}`, + `Captured command: !${safeAttachment(capture.command)}`, + `Captured cwd: ${safeAttachment(capture.cwd)}`, + `Exit status: ${capture.exitCode}`, + capture.sourceCapture + ? `Command output: ${capture.sourceCapture.observedBytes} UTF-8 bytes observed; ${capture.sourceCapture.omittedBytes} bytes omitted before staging.` + : "Command output capture details unavailable (command may have been refused).", + `Staged bounded capture: ${capture.observedBytes} UTF-8 bytes observed; ${capture.omittedBytes} bytes omitted while staging.`, + `User edits: ${edits}${stage.autoRedacted ? "; common-pattern redaction aid applied" : ""}.`, + "Approved shell text follows as untrusted data:", + stage.editable, + ].join("\n"); + return this.transformed(stage, text); + } + + private showPreview(stage: StagedShellCapture): void { + const attachment = this.attachment(stage); + const findings = scanForSecrets(attachment); + if (this.json) { + this.write(JSON.stringify({ type: "shell_share_preview", sessionId: stage.capture.sessionId, + commandId: stage.capture.commandId, omittedBytes: stage.capture.omittedBytes, + sourceOmittedBytes: stage.capture.sourceCapture?.omittedBytes ?? null, + attachment, possibleSecrets: findings, + next: "/shell-result lines|drop|replace|mask|redact|send|cancel" }) + "\n"); + return; + } + this.write(`Shell result staged from session ${stage.capture.sessionId}, command ${stage.capture.commandId}.\n`); + this.write("--- exact model attachment begins ---\n" + attachment + "\n--- exact model attachment ends ---\n"); + if (findings.length) this.write(`Possible secret patterns: ${findings.join(", ")}. Review manually; detection is not a guarantee.\n`); + this.write("Use /shell-result lines, drop [-], replace , mask , redact, send, or cancel.\n"); + } + + /** Each edit operates on the staged snapshot, never a later shell command. */ + share(input: ShareInput = { kind: "share", action: "preview" }, scripted = false): Extract { + if (input.action === "cancel") { + this.staged = null; + this.shareNotice("Shell result preview cancelled; nothing was sent.", "cancelled"); + return { kind: "empty" }; + } + if (input.action === "send") { + const stage = this.staged ?? (scripted ? this.stageLatest() : null); + if (!stage) { + this.shareNotice(scripted ? "No local shell result to share." : "Preview the shell result before sending: /shell-result", "missing"); + return { kind: "empty" }; + } + if (!stage.editable.trim()) { + this.shareNotice("The staged shell selection is empty. Edit it or cancel; nothing was sent.", "empty"); + return { kind: "empty" }; + } + const text = this.attachment(stage); + this.staged = null; + this.shareNotice(`Sending explicit shell result from session ${stage.capture.sessionId}, command ${stage.capture.commandId}.`, "sending"); + return { kind: "chat", text }; + } + const stage = this.stageLatest(); + if (!stage) { + this.shareNotice("No local shell result to preview.", "missing"); + return { kind: "empty" }; + } + if (input.action === "preview") { this.showPreview(stage); return { kind: "empty" }; } + if (input.action === "lines") { + const lines = this.transformed(stage, stage.editable).split("\n"); + if (this.json) this.write(JSON.stringify({ type: "shell_share_lines", sessionId: stage.capture.sessionId, + commandId: stage.capture.commandId, lines: lines.map((value, index) => ({ line: index + 1, text: safeAttachment(value) })) }) + "\n"); + else this.write(lines.map((value, index) => `${String(index + 1).padStart(3)} | ${safeAttachment(value)}`).join("\n") + "\n"); + return { kind: "empty" }; + } + if (input.action === "drop") { + const lines = this.transformed(stage, stage.editable).split("\n"); + const first = input.first ?? 0, last = input.last ?? 0; + if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) || first < 1 || last < first || last > lines.length) { + this.shareNotice(`Invalid line range; choose 1-${lines.length}.`, "invalid"); return { kind: "empty" }; + } + lines.splice(first - 1, last - first + 1); + stage.editable = lines.join("\n"); + stage.removedLines += last - first + 1; + } else if (input.action === "replace") { + const lines = this.transformed(stage, stage.editable).split("\n"); + const first = input.first ?? 0; + if (/\r|\n/.test(input.value ?? "")) { + this.shareNotice("Replace accepts one line of text; use separate commands for multiple lines.", "invalid"); return { kind: "empty" }; + } + if (!Number.isSafeInteger(first) || first < 1 || first > lines.length) { + this.shareNotice(`Invalid line number; choose 1-${lines.length}.`, "invalid"); return { kind: "empty" }; + } + lines.splice(first - 1, 1, safeAttachment(input.value ?? "")); + stage.editable = lines.join("\n"); + stage.replacedLines++; + } else if (input.action === "mask") { + const literal = input.value ?? ""; + if (!literal || literal.length > 512 || /[\r\n\u0000-\u001f\u007f]/.test(literal)) { + this.shareNotice("Mask needs one literal of at most 512 characters and no controls.", "invalid"); return { kind: "empty" }; + } + if (!this.attachment(stage).includes(literal)) { + this.shareNotice("That literal is not present in the staged attachment.", "missing"); return { kind: "empty" }; + } + stage.masks.push(literal); + } else if (input.action === "redact") { + stage.autoRedacted = true; + this.shareNotice("Common secret patterns were redacted as an aid; review the exact attachment before sending."); + } + this.showPreview(stage); + return { kind: "empty" }; } prompt(): string { return `[${sanitizeServerText(this.session.cwd)}${this.session.state === "lost" ? "; shell lost" : ""}] `; } - close(): void { this.terminal?.stop(); this.session.close(); } + close(): void { this.latest = null; this.staged = null; this.terminal?.stop(); this.session.close(); } } diff --git a/src/core/bounded_output.ts b/src/core/bounded_output.ts index 3e1eefd..f7f2617 100644 --- a/src/core/bounded_output.ts +++ b/src/core/bounded_output.ts @@ -21,6 +21,8 @@ export class BoundedOutput { /** Fixed allocation, independent of the number/size of incoming chunks. */ get capacityBytes(): number { return this.head.length + this.tail.length; } get retainedBytes(): number { return this.headLength + this.tailLength; } + get observedBytes(): number { return this.totalBytes; } + get truncated(): boolean { return this.totalBytes > this.capacityBytes; } append(text: string): void { const bytes = Buffer.from(text, "utf8"); @@ -46,12 +48,12 @@ export class BoundedOutput { } } - render(): string { + private parts(): { head: Buffer; tail: Buffer; omittedBytes: number } { const tail = this.tailLength < this.tail.length ? this.tail.subarray(0, this.tailLength) : Buffer.concat([this.tail.subarray(this.tailNext), this.tail.subarray(0, this.tailNext)]); const head = this.head.subarray(0, this.headLength); - if (this.totalBytes <= this.capacityBytes) return Buffer.concat([head, tail]).toString("utf8"); + if (!this.truncated) return { head, tail, omittedBytes: 0 }; // Never manufacture replacement characters at either elision boundary. // Appended text contains complete code points; only our cuts can split one. @@ -65,9 +67,19 @@ export class BoundedOutput { } let tailStart = 0; while (tailStart < tail.length && (tail[tailStart]! & 0xc0) === 0x80) tailStart++; - const omitted = this.totalBytes - headEnd - (tail.length - tailStart); - return head.subarray(0, headEnd).toString("utf8") - + `\n…[${omitted} UTF-8 bytes elided]…\n` - + tail.subarray(tailStart).toString("utf8"); + return { + head: head.subarray(0, headEnd), tail: tail.subarray(tailStart), + omittedBytes: this.totalBytes - headEnd - (tail.length - tailStart), + }; + } + + get omittedBytes(): number { return this.parts().omittedBytes; } + + render(): string { + const { head, tail, omittedBytes } = this.parts(); + if (!omittedBytes) return Buffer.concat([head, tail]).toString("utf8"); + return head.toString("utf8") + + `\n…[${omittedBytes} UTF-8 bytes elided]…\n` + + tail.toString("utf8"); } } diff --git a/src/core/shell_session.ts b/src/core/shell_session.ts index d1b6b0f..7dd1649 100644 --- a/src/core/shell_session.ts +++ b/src/core/shell_session.ts @@ -199,6 +199,11 @@ export class ShellSession { options.signal?.removeEventListener("abort", abort); cleanupOut(); cleanupErr(); result.output += output.render(); + // Internal preview provenance must not change the public ToolResult + // shape consumed by model tools, JSON output, or existing callers. + Object.defineProperty(result, "capture", { + value: { observedBytes: output.observedBytes, omittedBytes: output.omittedBytes }, + }); fd.off("data", onControl); this.failActive = null; emit(state, result.exitCode); diff --git a/src/core/tool_executor.ts b/src/core/tool_executor.ts index 414bc82..4797c5c 100644 --- a/src/core/tool_executor.ts +++ b/src/core/tool_executor.ts @@ -68,6 +68,8 @@ export interface RunOptions { export interface ToolResult { output: string; exitCode: number; + /** Command pipe capture only; absent for refusals and non-shell tools. */ + capture?: { observedBytes: number; omittedBytes: number }; } /** Chosen by the local host when it creates an executor, never by a tool call. */ @@ -352,11 +354,12 @@ export class ToolExecutor { }; signal?.addEventListener("abort", onAbort, { once: true }); - const finish = (result: ToolResult): void => { + const finish = (result: ToolResult, capture?: ToolResult["capture"]): void => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", onAbort); + if (capture) Object.defineProperty(result, "capture", { value: capture }); resolve(result); }; @@ -369,16 +372,17 @@ export class ToolExecutor { // test summary arriving with the exit is not lost. child.on("close", (code, sig) => { const body = output.render(); + const capture = { observedBytes: output.observedBytes, omittedBytes: output.omittedBytes }; if (verdict === "timeout") { - finish({ output: `[timeout after ${Math.round(timeoutMs / 1000)}s]\n${body}`, exitCode: 124 }); + finish({ output: `[timeout after ${Math.round(timeoutMs / 1000)}s]\n${body}`, exitCode: 124 }, capture); return; } if (verdict === "aborted") { - finish({ output: `[aborted]\n${body}`, exitCode: 130 }); + finish({ output: `[aborted]\n${body}`, exitCode: 130 }, capture); return; } const exit = code ?? (sig ? 1 : 1); - finish({ output: `[exit ${exit}]\n${body}`, exitCode: exit }); + finish({ output: `[exit ${exit}]\n${body}`, exitCode: exit }, capture); }); }); } diff --git a/test/console_input.test.ts b/test/console_input.test.ts index 09b42eb..3706059 100644 --- a/test/console_input.test.ts +++ b/test/console_input.test.ts @@ -12,7 +12,9 @@ for (const [raw, expected] of [ ["\\!literal", { kind: "chat", text: "!literal" }], ["normal\nquestion", { kind: "chat", text: "normal\nquestion" }], [" ", { kind: "empty" }], - ["/shell-result", { kind: "share" }], + ["/shell-result", { kind: "share", action: "preview" }], + ["/shell-result send", { kind: "share", action: "send" }], + ["/shell-result drop 2-4", { kind: "share", action: "drop", first: 2, last: 4 }], ] as const) test(`console classification ${JSON.stringify(raw)}`, () => { assert.deepEqual(classifyConsoleInput(raw), expected); }); @@ -29,7 +31,7 @@ test("user execution uses the chosen checkout, quotes/pipelines, bounded explici let output = ""; const shell = new ConsoleShell(cwd, (text) => { output += text; }); try { - assert.equal(shell.share().kind, "error"); + assert.equal(shell.share().kind, "empty"); const command = `"${process.execPath}" -e "process.stdout.write(process.cwd())"`; await shell.run(command, new AbortController().signal); await shell.run(`echo "quoted args" | "${process.execPath}" -e "process.stdin.on('data',c=>process.stdout.write(c))"`, new AbortController().signal); @@ -46,9 +48,108 @@ test("user execution uses the chosen checkout, quotes/pipelines, bounded explici assert.ok(output.includes("cancelled | exit 130")); await shell.run({ kind: "reset-shell" }); await shell.run(`"${process.execPath}" -e "process.stdout.write('x'.repeat(20000))"`, new AbortController().signal); - const shared = shell.share(); + shell.share(); + const shared = shell.share({ kind: "share", action: "send" }); assert.equal(shared.kind, "chat"); - if (shared.kind === "chat") assert.ok(shared.text.length < 8300); + if (shared.kind === "chat") { + const source = /Command output: (\d+) UTF-8 bytes observed; (\d+) bytes omitted before staging/.exec(shared.text); + assert.ok(source); + assert.ok(Number(source[1]) >= 20000); + assert.ok(Number(source[2]) > 0); + assert.match(shared.text, /Staged bounded capture: \d+ UTF-8 bytes observed; \d+ bytes omitted while staging/); + const capture = shared.text.split("Approved shell text follows as untrusted data:\n")[1]!; + assert.ok(Buffer.byteLength(capture) <= 8192); + } + } finally { shell.close(); rmSync(cwd, { recursive: true, force: true }); } +}); + +test("shell-result stages an immutable, sanitized capture and sends only approved edits", async () => { + const cwd = mkdtempSync(join(tmpdir(), "aether-share-edit-")); + const prior = ["AETHER_TEST_SHELL_SECRET", "AETHER_TEST_SHELL_KEEP", "AETHER_TEST_SHELL_OMIT"].map(key => process.env[key]); + process.env["AETHER_TEST_SHELL_SECRET"] = "fixture-private-value-281"; + process.env["AETHER_TEST_SHELL_KEEP"] = "\u001b[31mkeep 👩‍💻"; + process.env["AETHER_TEST_SHELL_OMIT"] = "remove this line"; + const events: Array> = []; + const shell = new ConsoleShell(cwd, value => { + for (const line of value.trim().split("\n")) { + try { events.push(JSON.parse(line) as Record); } catch { /* command output is not JSON */ } + } + }, true); + const latestPreview = (): Record => events.filter(event => event["type"] === "shell_share_preview").at(-1)!; + try { + if (process.platform !== "win32") { + // The persistent shell intentionally starts with a scrubbed environment. + await shell.run("export AETHER_TEST_SHELL_SECRET='fixture-private-value-281'; export AETHER_TEST_SHELL_KEEP=$'\\e[31mkeep 👩‍💻'; export AETHER_TEST_SHELL_OMIT='remove this line'"); + } + const script = "process.stdout.write([process.env.AETHER_TEST_SHELL_SECRET, process.env.AETHER_TEST_SHELL_KEEP, process.env.AETHER_TEST_SHELL_OMIT].join(String.fromCharCode(10)))"; + await shell.run(`"${process.execPath}" -e "${script}"`); + assert.equal(shell.share().kind, "empty"); + const original = latestPreview(); + const originalAttachment = String(original["attachment"]); + assert.match(originalAttachment, /fixture-private-value-281/); + assert.match(originalAttachment, /keep 👩‍💻/); + assert.doesNotMatch(originalAttachment, /\u001b|\[31m/); + assert.match(originalAttachment, /Command output: \d+ UTF-8 bytes observed; 0 bytes omitted before staging/); + const sourceCommand = String(original["commandId"]); + + shell.share({ kind: "share", action: "lines" }); + const numbered = events.filter(event => event["type"] === "shell_share_lines").at(-1)!; + const lines = numbered["lines"] as Array<{ line: number; text: string }>; + const removed = lines.find(line => line.text.includes("remove this line"))!; + const edited = lines.find(line => line.text.includes("keep 👩‍💻"))!; + shell.share({ kind: "share", action: "drop", first: removed.line, last: removed.line }); + shell.share({ kind: "share", action: "replace", first: edited.line, value: "approved 👩‍💻" }); + shell.share({ kind: "share", action: "mask", value: "fixture-private-value-281" }); + const approved = String(latestPreview()["attachment"]); + assert.doesNotMatch(approved, /fixture-private-value-281|remove this line|keep 👩‍💻/); + assert.match(approved, /approved 👩‍💻/); + assert.match(approved, /\[REDACTED\]/); + assert.match(approved, /removed 1 line\(s\), replaced 1 line\(s\), masked 1 literal\(s\)/); + + await shell.run(`"${process.execPath}" -e "process.stdout.write('newer result')"`); + shell.share(); + assert.equal(String(latestPreview()["commandId"]), sourceCommand); + assert.equal(String(latestPreview()["attachment"]), approved); + const sent = shell.share({ kind: "share", action: "send" }); + assert.deepEqual(sent, { kind: "chat", text: approved }); + shell.share(); + assert.match(String(latestPreview()["attachment"]), /newer result/); + assert.equal(shell.share({ kind: "share", action: "cancel" }).kind, "empty"); + assert.equal(shell.share({ kind: "share", action: "send" }).kind, "empty"); + + await shell.run(`"${process.execPath}" -e "${script}"`); + shell.share(); + shell.share({ kind: "share", action: "redact" }); + assert.doesNotMatch(String(latestPreview()["attachment"]), /fixture-private-value-281/); + assert.match(String(latestPreview()["attachment"]), /common-pattern redaction aid applied/); + } finally { + shell.close(); rmSync(cwd, { recursive: true, force: true }); + ["AETHER_TEST_SHELL_SECRET", "AETHER_TEST_SHELL_KEEP", "AETHER_TEST_SHELL_OMIT"].forEach((key, index) => { + if (prior[index] === undefined) delete process.env[key]; else process.env[key] = prior[index]; + }); + } +}); + +test("empty shell selection and cancelled preview cannot produce a model prompt", async () => { + const cwd = mkdtempSync(join(tmpdir(), "aether-share-empty-")); + const events: Array> = []; + const shell = new ConsoleShell(cwd, value => { + for (const line of value.trim().split("\n")) { + try { events.push(JSON.parse(line) as Record); } catch { /* command output */ } + } + }, true); + try { + await shell.run(`"${process.execPath}" -e "process.exit(7)"`); + shell.share(); + const preview = events.filter(event => event["type"] === "shell_share_preview").at(-1)!; + assert.match(String(preview["attachment"]), /Exit status: 7/); + shell.share({ kind: "share", action: "lines" }); + const numbered = events.filter(event => event["type"] === "shell_share_lines").at(-1)!; + const count = (numbered["lines"] as Array).length; + shell.share({ kind: "share", action: "drop", first: 1, last: count }); + assert.equal(shell.share({ kind: "share", action: "send" }).kind, "empty"); + assert.equal(shell.share({ kind: "share", action: "cancel" }).kind, "empty"); + assert.equal(shell.share({ kind: "share", action: "send" }, true).kind, "chat", "scripted send requires an explicit latest result"); } finally { shell.close(); rmSync(cwd, { recursive: true, force: true }); } }); @@ -134,6 +235,14 @@ for (const tty of [false, true]) { await until(() => completed() === ${tty ? 6 : 5}, 'shareable shell completion'); sharing = true; submit('/shell-result'); + await until(() => observed.includes('exact model attachment begins'), 'shell result preview'); + if (calls !== 2) throw new Error('preview made a model call'); + submit('/shell-result cancel'); + await until(() => observed.includes('Shell result preview cancelled'), 'shell result cancellation'); + if (calls !== 2) throw new Error('cancel made a model call'); + submit('/shell-result'); + await until(() => (observed.match(/exact model attachment begins/g) ?? []).length === 2, 'second shell result preview'); + submit('/shell-result send'); await releaseTurn(3); submit('/exit'); await session; diff --git a/test/console_shell.test.ts b/test/console_shell.test.ts index 2a20f6c..ed58832 100644 --- a/test/console_shell.test.ts +++ b/test/console_shell.test.ts @@ -46,6 +46,9 @@ test("line console shell commands make zero model calls, keep output out of prom input.write("!cd subdir\n!export DEMO=local-only\n!printf '%s' \"$DEMO\" | cat\n!false\n!pwd\n!\n"); while (!output.includes("usage: !")) await new Promise(resolve => setTimeout(resolve, 5)); assert.equal(bodies.length, 0); + input.write("/shell-result\n/shell-result cancel\n"); + while (!output.includes('"code":"cancelled"')) await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(bodies.length, 0); input.end("a normal question\n/exit\n"); assert.equal(await run, 0); assert.equal(bodies.length, 1); @@ -55,7 +58,7 @@ test("line console shell commands make zero model calls, keep output out of prom assert.match(output, /subdir/); assert.match(output, /"exitCode":1/); const history = existsSync(historyPath(root)) ? readFileSync(historyPath(root), "utf8") : ""; - assert.doesNotMatch(history, /DEMO|!pwd|!false|!cd/); + assert.doesNotMatch(history, /DEMO|!pwd|!false|!cd|shell-result|local-only/); assert.equal(shell.session.state, "closed"); } finally { globalThis.fetch = oldFetch; process.stdout.write = oldWrite; @@ -68,13 +71,15 @@ test("explicit shell-result sharing keeps a final summary after a long Unicode c const shell = new ConsoleShell(root, () => {}, true); try { await shell.run(`printf 'FINAL SUMMARY: 1 failed'; # ${"😀".repeat(4000)}`); - const shared = shell.share(); + assert.equal(shell.share().kind, "empty"); + const shared = shell.share({ kind: "share", action: "send" }); assert.equal(shared.kind, "chat"); if (shared.kind !== "chat") return; assert.ok(shared.text.endsWith("FINAL SUMMARY: 1 failed")); assert.match(shared.text, /UTF-8 bytes elided/); assert.doesNotMatch(shared.text, /\ufffd/); - assert.ok(Buffer.byteLength(shared.text) <= 8192 + 80); + const capture = shared.text.split("Approved shell text follows as untrusted data:\n")[1]!; + assert.ok(Buffer.byteLength(capture) <= 8192); } finally { shell.close(); rmSync(root, { recursive: true, force: true }); } });