diff --git a/COMMANDS.md b/COMMANDS.md index 7a397b5..29b0f62 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -26,11 +26,11 @@ aether # no args = interactive REPL `help`, `models`, `model`, `agent`, `agents`, `tier`, `effort`, `audit`, `doctor`, `settings`, `voice`, `preview`, `clear`, `exit`, `mcp`, `autonomous-execution`, `subagent-driven-execution`, `self-review`, `recon`, `plan`, `research`, `project-review`, `code-review`, `writing-skills`, -`writing-plans`, `shell-result`, `queue`, `steer`, `btw`, `pin`, `drop`, `snapshot`, `limit`, `audit-receipt`, `rollback`, `logs-view`, -`goal`, `goals`, `memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, `vault-tag`, -`vault-tree`, `delegate`, `tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, `review`, -`ship`, `revert`, `photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, `add`, -`hud`, `agent-create`, `browser`, `ats` +`writing-plans`, `shell-result`, `shell-reset`, `queue`, `steer`, `btw`, `pin`, `drop`, `snapshot`, `limit`, `audit-receipt`, `rollback`, +`logs-view`, `goal`, `goals`, `memory`, `workflow`, `workflow-templates`, `workflow-template`, `vault`, `vault-context`, `vault-search`, `vault-recent`, `vault-project`, +`vault-tag`, `vault-tree`, `delegate`, `tree`, `broadcast`, `gather`, `scaffold`, `port`, `test-drive`, `bench`, `purge`, `stage-diff`, +`review`, `ship`, `revert`, `photogen`, `frame`, `re-frame`, `videogen`, `sequence`, `animate`, `re-cut`, `output`, `storyboard`, +`add`, `hud`, `agent-create`, `browser`, `ats` ## Runtime capability requirements diff --git a/README.md b/README.md index b41a943..f72b968 100644 --- a/README.md +++ b/README.md @@ -61,19 +61,21 @@ an explicit user command locally, then type a normal question to return to chat. Shell submissions make zero model API calls and consume no model UVT. Leading whitespace is allowed; `!` alone shows usage. Type `\!literal` to send text starting with `!` to the model. Quotes, pipelines and shell operators use -`/bin/sh` on Linux/macOS and `cmd.exe` on Windows. +persistent `/bin/bash --noprofile --norc` on Linux/macOS. Windows retains +fresh noninteractive `cmd.exe` commands without persistent cwd or exports. The console displays user origin, checkout directory, running/completed/cancelled state, streamed output and exit code. Commands wait for the current model/tool turn or slash operation before running; Ctrl+C cancels the shell process tree, drops queued follow-ups and restores the composer, preserving any type-ahead draft. A nonzero exit still returns to chat. Shell commands cannot read console -stdin. This first version runs each command in a fresh shell in the selected -checkout: `!cd` and environment changes do not persist. PTY support and persistent -shell state are separate follow-ups. +stdin. Linux/macOS user commands and approved local-model shell tools share +cwd, exports and functions. File tools remain workspace-root relative. +`/shell-reset` starts fresh after exit/crash/cancellation; commands are never +replayed. Checkout switches discard shell state. PTY support remains separate. -TTY bracketed multiline shell paste is refused without execution; normal -multiline chat paste remains chat. In line mode (pipes/CI), each newline is a +TTY bracketed multiline shell paste runs as one command; normal multiline +chat paste remains chat. In line mode (pipes/CI), each newline is a separate submission, processed sequentially. Submit one shell command per line. `/queue !command` is also supported in the TTY coding console. @@ -81,7 +83,9 @@ Shell commands and output are session-local and excluded from saved chat history and automatic hosted prompts. `/shell-result` explicitly sends at most 8 KiB of the most recent result to the next model turn, labelled as untrusted data. Review output for secrets before sharing it. `AETHER_NO_HISTORY=1` continues to -disable chat-history persistence. Explicit user execution never grants future +disable chat-history persistence. See [local shell sessions](docs/LOCAL_SHELL_SESSION.md) +for recovery, workspace boundaries and automatic commit ownership. +Explicit user execution never grants future model execution authority; model tool validation and permission gates still apply. Online managed-agent DMs are a separate surface and do not run `!commands`. diff --git a/docs/LOCAL_SHELL_SESSION.md b/docs/LOCAL_SHELL_SESSION.md new file mode 100644 index 0000000..3bf335a --- /dev/null +++ b/docs/LOCAL_SHELL_SESSION.md @@ -0,0 +1,89 @@ +# Local console shell sessions + +In the coding chat console (`aether chat`), leading `!` submits directly to the +local host. It makes no model request. On Linux and macOS the console selects +`/bin/bash --noprofile --norc`; an unavailable Bash or unsupported platform +returns a visible refusal, with no silent shell substitution. Windows retains +the existing fresh noninteractive `cmd.exe` user-command path; it does not +share cwd, exports or functions. + +```text +!cd subdir +!export DEMO='hello world' +!pwd +``` + +The next approved **local-model** `run_shell` or `run_tests` uses the same cwd +and exported `DEMO`. Variables, functions, shell options and exports persist +in that Bash process until reset/exit. No rc files are sourced. The initial +environment comes from the host's credential-free `childEnv` allowlist, not +all of the parent's environment. Explicit exports affect this session's child +commands only; they never modify the host process or another session. + +The `aether agent` host tool loop also owns a fresh Bash session per coding run, +created **after** selecting its checkout/worktree. Hosted `aether chat` still +uses its existing server-side chat tools: those do not execute locally or +inherit the local shell. Use `aether agent` for host-enforced hosted coding +tools. Online account-agent and ATS chats remain separate surfaces. + +## Input and display + +- Leading whitespace before `!` is accepted; empty `!` prints a local usage + error. `\!literal` sends literal-leading-`!` text to chat. +- A bracketed multiline TTY paste beginning with `!` is one Bash submission, + preserving its embedded newlines. Line-mode stdin is one command per line. +- Quoting and pipelines follow Bash syntax. stdin belongs to the host protocol; + programs receive `/dev/null`. Interactive programs/PTYs are not supported. +- Commands show user/model origin, session ID, command ID, real cwd, state, + bounded output and exit code. The prompt shows cwd (and lost state). Model + approvals show both shell cwd and the independent file-tool workspace root. +- `!` submissions made while a model turn is busy retain their shell type and + wait until that turn completes. All local tools share a FIFO execution slot. + Ctrl+C cancels the active command/turn and discards its queued follow-ups; + typing ahead retains the newer composer draft. +- Shell commands and results are excluded from chat history and hosted prompts. + `/shell-result` explicitly shares up to 8 KiB of the latest user result as + untrusted data. Reset clears that result. + Ordinary chat history still honors `AETHER_NO_HISTORY=1`. + +## Workspace and recovery + +The workspace root is fixed by the local host and is separate from shell cwd. +Relative `read_file`, `write_file`, `repo_search` and diff snapshots resolve at +that root. Their existing traversal/symlink guards still apply. `cd` checks the +physical target before changing directory and refuses targets outside the +workspace, including symlink escapes. Failed `cd` leaves cwd intact. A shell +which bypasses the `cd` wrapper and ends outside that boundary is terminated +and loses its state. Arbitrary approved shell execution is **not an OS sandbox**: +it retains the same filesystem authority as the existing shell tool. + +Branch/checkout identity changes reset cwd, environment, functions and commit +ownership. An external checkout switch refuses the next tool/submission and +asks for resubmission or fresh approval; it does not replay it. A new project +or coding worktree requires a new host session, never a retargeted executor. +Model approvals are bound to the displayed session/cwd/state revision; if +another local operation changes it before execution, the tool is refused. + +`exit`, a Bash crash, cancellation, timeout, invalid cwd or broken protocol +ends the session visibly. Use `/shell-reset` to start fresh at the original +workspace root. State is not reconstructed and a mutating command is never +replayed. Timeout/cancellation terminate the process group and escalate to +SIGKILL. Background jobs are awaited as part of the command, so they belong +to its timeout/cancellation scope. Deliberately detached processes are outside +this non-PTY session-control guarantee. + +## Automatic commit ownership + +`git_commit` stages only paths observed changing during model operations. +Pre-existing dirty/staged paths retain the existing refusal/exclusion rules. +User shell mutations and external edits observed between operations are +excluded, including later edits to an agent-owned file. A file containing both +user and agent work is excluded as a whole, even if the agent edits it again. +There is no implicit hunk attribution or approval to sweep up user work. + +Ownership probes fail closed on unreadable/unattributable files. They are +conservative attribution between serialized operations, not a filesystem lock: +an unrelated editor writing during a model command cannot always be attributed +automatically. Review changes before committing. Explicit user git commands and +approved model `run_shell` commands still have their original shell authority; +these staging restrictions apply to the automatic `git_commit` tool. diff --git a/docs/generated/commands.md b/docs/generated/commands.md index 1c593f9..5210ae0 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. @@ -475,6 +475,12 @@ explicitly share the last local shell result with chat \(bounded\) Permission: `network` · Availability: `runtime-dependent` · Telemetry: `slash.shell-result` +#### `/shell-reset` + +discard local shell cwd/environment/functions; never replay + +Permission: `unknown` · Availability: `runtime-dependent` · Telemetry: `slash.shell-reset` + #### `/queue ` queue a task \(runs when current finishes\) diff --git a/src/commands/chat.ts b/src/commands/chat.ts index 4c419d7..e978938 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -2,7 +2,6 @@ // This is the coding front door: build an envelope, POST to the universal // stream, decode frames, render. The agent brain runs on Aether's servers. -import { classifyConsoleInput, ConsoleShell, type ConsoleInput } from "./console_input.js"; import { createInterface } from "node:readline"; import { StringDecoder } from "node:string_decoder"; import type { AppContext, GlobalFlags } from "../core/context.js"; @@ -55,6 +54,7 @@ import { localModelId, resolveHostedModel, resolveLocalModel } from "../core/loc import type { Brain } from "../core/brain.js"; import type { RunOptions, ToolResult } from "../core/tool_executor.js"; import { ToolExecutor } from "../core/tool_executor.js"; +import { ConsoleShell, classifyConsoleInput, type ConsoleInput } from "./console_input.js"; import { HostRenderer } from "../ui/host_render.js"; import type { TaskCommand } from "../core/brain.js"; import { getRegistry } from "../core/context_registry.js"; @@ -96,6 +96,8 @@ interface ChatJsonResponse { export interface TurnSkillOptions { explicitSkill?: string; noSkills?: boolean; + /** Local console authority, never serialized to Cloud. */ + exec?: ToolExecutor; } export const DEFAULT_CHAT_TURN_DEADLINE_MS = 30 * 60_000; @@ -502,7 +504,7 @@ export async function runTurn( getRegistry().markLocalUnmetered(); // The signal used to be dropped here, so the REPL Ctrl+C controller could // not reach a local turn at all: the abort fired and nothing observed it. - return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt }, run.guard); + return await runLocalTurn(ctx, brief, boundedSignal.signal, { lifecycle, onPulsePaint, deadlineAt, ...(skillOpts.exec ? { exec: skillOpts.exec } : {}) }, run.guard); } // The cloud REPL turn streams from /agent/chat/stream, where the SERVER runs // the tools. This host executes nothing on that path, so it can enforce @@ -769,6 +771,9 @@ export interface LocalTurnDeps { brain?: Brain; exec?: { executeAsync(name: string, args: Record, options?: RunOptions): Promise; + readonly shellCwd?: string; + readonly shellContext?: string; + close?(): void; }; /** Reuse runTurn's lifecycle; direct callers get a fresh one automatically. */ lifecycle?: TurnLifecycle; @@ -816,7 +821,7 @@ export async function runLocalTurn( } const detail = String(args["path"] ?? args["command"] ?? args["message"] ?? ""); const shown = detail.length > 120 ? detail.slice(0, 117) + "..." : detail; - return ctx.confirm(`\nwarning ${name}${shown ? " " + shown : ""} - run it? [y/N] `); + return ctx.confirm(`\nwarning ${name}${shown ? " " + shown : ""} [cwd: ${sanitizeServerText(exec.shellCwd ?? cwd)}; file root: ${sanitizeServerText(cwd)}] - run it? [y/N] `); }; const task: TaskCommand = { type: "task", @@ -897,6 +902,7 @@ export async function runLocalTurn( brain.sendToolResult(ev.id, refusalToolResult(refusal)); } else { // executeAsync so the two web tools (web_search/web_fetch) work too. + const approvalContext = exec.shellContext; const approved = await boundedLocalOperation( () => approveTool(ev.name, ev.args), controller.signal, @@ -909,6 +915,7 @@ export async function runLocalTurn( : undefined; const toolOptions: RunOptions = { signal: controller.signal, + ...(approvalContext !== undefined ? { expectedShellContext: approvalContext } : {}), ...(remaining === undefined ? {} : { timeoutMs: remaining }), }; const result = approved @@ -941,6 +948,7 @@ export async function runLocalTurn( signal?.removeEventListener("abort", forwardAbort); controller.signal.removeEventListener("abort", onAbort); closeBrain(); + if (!deps.exec) exec.close?.(); // A non-compliant iterator may park forever or throw synchronously from // return(). Observe both shapes without replacing the real timeout/error. if (iterator?.return) void Promise.resolve().then(() => iterator!.return!()).catch(() => {}); @@ -1082,7 +1090,7 @@ export async function cmdChat( // skillOpts is session-level (`--skill` / `--no-skills` on the launching // command): every turn in this REPL opens its run session with it. -async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise { +export async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise { const username = userInfo().username || "you"; const backend = await resolveBackend(ctx); const model = backend === "local" @@ -1105,9 +1113,11 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< // One-line dim banner: which brain serves turns this session (local-first). const where = backend === "local" ? "local Ollama (offline)" : "cloud (Aether API)"; process.stdout.write(theme.dim(`backend: ${where}`) + "\n"); - process.stdout.write("Type a prompt, !command for local shell, /shell-result to share output, or /help. /exit to quit.\n\n"); + process.stdout.write("Type a prompt, or /help for commands. /exit to quit.\n\n"); } - if (!process.stdin.isTTY) return replLines(ctx, skillOpts); + const consoleShell = new ConsoleShell(ctx.flags.cwd, text => { process.stdout.write(text); }, ctx.flags.json); + skillOpts = { ...skillOpts, exec: consoleShell.exec }; + if (!process.stdin.isTTY) return replLines(ctx, skillOpts, consoleShell); const buf = new InputBuffer(); const histPath = historyPath(ctx.flags.cwd); @@ -1142,7 +1152,7 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< }; const repaint = (): void => { if (busy) return; - process.stdout.write(repaintString(prompt, buf.value, buf.pos, process.stdout.columns ?? 80)); + process.stdout.write(repaintString(prompt + consoleShell.prompt(), buf.value, buf.pos, process.stdout.columns ?? 80)); }; // Unlike repaint(), this does NOT gate on busy: it's the thinking-pulse's // onPaint hook, fired from inside the pulse's own \r-repaint on stderr @@ -1151,7 +1161,7 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< // followed by re-drawing whatever the user has typed ahead, or their // in-progress keystrokes get stomped by the pulse's next `\r\x1b[2K`. const redrawInput = (frame: string): void => { - process.stdout.write(repaintString(`${frame} ${prompt}`, buf.value, buf.pos, process.stdout.columns ?? 80)); + process.stdout.write(repaintString(`${frame} ${prompt}${consoleShell.prompt()}`, buf.value, buf.pos, process.stdout.columns ?? 80)); }; process.stdin.setRawMode(true); process.stdin.resume(); @@ -1171,7 +1181,6 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< let pasteAcc = ""; let carry = ""; // partial escape sequence held across chunk boundaries const queue: ConsoleInput[] = []; - const shell = new ConsoleShell(ctx.flags.cwd, (text) => process.stdout.write(text)); let steering: string | null = null; const btwNotes: string[] = []; let turnAbort: AbortController | null = null; // live while a local/cloud turn runs @@ -1193,6 +1202,7 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< return await new Promise((resolve) => { const onResize = (): void => repaint(); const cleanup = (): void => { + consoleShell.close(); process.stdout.write("\x1b[?2004l\x1b[?25h"); // paste off + cursor shown try { process.stdin.setRawMode(false); @@ -1205,16 +1215,26 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< process.stdout.removeListener("resize", onResize); }; const finish = (code: number): void => { - turnAbort?.abort(); - slashAbort?.abort(); - queue.length = 0; + turnAbort?.abort(); slashAbort?.abort(); queue.length = 0; cleanup(); process.stdout.write("\n"); resolve(code); }; /** Run one turn without sacrificing an existing type-ahead draft. */ - const runQueuedTurn = async (text: string): Promise<"completed" | "aborted" | "failed"> => { + const runQueuedTurn = async (input: ConsoleInput): Promise<"completed" | "aborted" | "failed"> => { + if (input.kind === "share") input = consoleShell.share(); + if (input.kind === "error") { process.stdout.write(input.message + "\n"); return "completed"; } + if (input.kind === "empty") return "completed"; + if (input.kind !== "chat") { + turnAbort = new AbortController(); + try { + const result = await consoleShell.run(input, turnAbort.signal); + if (result !== "completed") queue.length = 0; + return result; + } finally { turnAbort = null; } + } + const text = input.text; const built = buildPromptContext(text, steering, btwNotes); steering = built.steering; btwNotes.length = 0; @@ -1318,6 +1338,22 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< } }; + const runAndDrain = async (input: ConsoleInput): Promise => { + try { + getRegistry().startAgentTimer(); + let result = await runQueuedTurn(input); + while (result === "completed" && queue.length > 0) { + const next = queue.shift()!; + const preview = next.kind === "chat" ? next.text : next.kind === "shell" ? "!" + next.command : next.kind === "share" ? "/shell-result" : "/shell-reset"; + process.stdout.write(`\n→ Queued: "${previewLine(preview)}"\n`); + result = await runQueuedTurn(next); + } + } finally { + busy = false; + getRegistry().startUserTimer(); + } + }; + const onCtrlC = (): void => { const now = Date.now(); const armed = now - ctrlCArmedAt <= CTRL_C_WINDOW_MS && ctrlCArmedAt > 0; @@ -1354,47 +1390,52 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< } }; - const runInput = async (input: ConsoleInput): Promise<"completed" | "aborted" | "failed"> => { - if (input.kind === "share") input = shell.share(); - if (input.kind === "error") { process.stdout.write(input.message + "\n"); return "completed"; } - if (input.kind === "empty") return "completed"; - if (input.kind === "shell") { - turnAbort = new AbortController(); - try { return await shell.run(input.command, turnAbort.signal); } - finally { turnAbort = null; } - } - return runQueuedTurn(input.text); - }; - const onSubmit = async (): Promise => { const raw = buf.value; const queuePrefix = /^\s*\/queue[ \t]+/.exec(raw); const input = classifyConsoleInput(queuePrefix ? raw.slice(queuePrefix[0].length) : raw); - let t = input.kind === "chat" ? input.text : raw.trim(); const commit = (): void => { if (input.kind === "chat") { remember(buf.value); buf.commit(buf.value); } else buf.clear(); }; + let t = input.kind === "chat" ? input.text : ""; + if (input.kind !== "chat" && input.kind !== "empty") { + buf.clear(); // shell commands never enter chat history or prompt context + if (busy) { + queue.push(input); + process.stdout.write(`\n⏳ Local shell queued (${queue.length}).\n`); + return; + } + process.stdout.write("\n"); + busy = true; + await runAndDrain(input); + renderHudLine(); repaint(); + return; + } // ── mid-turn Enter: bypass commands + type-ahead queueing ── if (busy) { if (t.startsWith("/steer ")) { steering = t.slice(7).trim() || steering; - commit(); + remember(buf.value); + buf.commit(buf.value); if (steering) process.stdout.write(`\n🎯 Steering set: "${steering}"\n`); return; } if (t.startsWith("/btw ")) { const note = t.slice(5).trim(); - commit(); + remember(buf.value); + buf.commit(buf.value); if (note) { btwNotes.push(note); process.stdout.write(`\n📝 Noted: "${note}"\n`); } return; } - commit(); - if (!t || (input.kind === "chat" && t.startsWith("/"))) return; // stateful commands above; typed shell/share items keep their identity - queue.push(input); + if (t.startsWith("/queue ")) t = t.slice(7).trim(); + remember(buf.value); + buf.commit(buf.value); + if (!t || t.startsWith("/")) return; // other slashes wait for the turn + queue.push({ kind: "chat", text: t }); process.stdout.write(`\n⏳ Queued (${queue.length}): "${previewLine(t)}"\n`); return; } @@ -1405,16 +1446,6 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< repaint(); return; } - if (input.kind !== "chat") { - busy = true; - try { - let result = await runInput(input); - while (result === "completed" && queue.length) result = await runInput(queue.shift()!); - if (result !== "completed") queue.length = 0; - } finally { busy = false; } - repaint(); - return; - } // ── /steer /btw /queue — stateful, stay inline ── if (t.startsWith("/steer ") || t === "/steer") { const guidance = t.slice(6).trim(); @@ -1430,7 +1461,13 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< process.stdout.write(`📝 Noted: "${note}"\n`); repaint(); return; } - if (t === "/queue") { process.stdout.write("usage: /queue \n"); repaint(); return; } + if (t.startsWith("/queue ") || t === "/queue") { + const task = t.slice(6).trim(); + if (!task) { process.stdout.write("usage: /queue \n"); repaint(); return; } + // not busy — run immediately as a normal turn + process.stdout.write(`⏳ Running: "${task}"\n`); + t = task; + } // ── stateless prompt-rewrite modes (/recon, /plan, /research, …) ── const mode = applyPromptMode(t); if (mode.handled) { @@ -1467,32 +1504,12 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< busy = false; slashAbort = null; } - if (queue.length) { - busy = true; - try { - let result: "completed" | "aborted" | "failed" = "completed"; - while (result === "completed" && queue.length) result = await runInput(queue.shift()!); - if (result !== "completed") queue.length = 0; - } finally { busy = false; } - } + if (queue.length) { busy = true; await runAndDrain(queue.shift()!); } renderHudLine(); repaint(); return; } - try { - getRegistry().startAgentTimer(); - // An aborted turn skips the drain entirely — even an item that slipped - // into the queue during abort teardown must not auto-run. - let result = await runQueuedTurn(t); - while (result === "completed" && queue.length > 0) { - const next = queue.shift()!; - result = await runInput(next); - } - if (result !== "completed") queue.length = 0; - } finally { - busy = false; - getRegistry().startUserTimer(); - } + await runAndDrain({ kind: "chat", text: t }); renderHudLine(); repaint(); }; @@ -1735,32 +1752,29 @@ async function repl(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise< * Ctrl+C to cancel the current turn/slash-command rather than killing the * whole process (a bare non-TTY session, e.g. `ssh host aether`, still gets * SIGINT delivered normally since readline isn't in terminal mode here). */ -async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Promise { - const rl = createInterface({ input: process.stdin }); - const shell = new ConsoleShell(ctx.flags.cwd, (text) => process.stdout.write(text)); +export async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {}, consoleShell = new ConsoleShell(ctx.flags.cwd, text => { process.stdout.write(text); }, ctx.flags.json), inputStream: NodeJS.ReadableStream = process.stdin): Promise { + skillOpts = { ...skillOpts, exec: consoleShell.exec }; + const rl = createInterface({ input: inputStream }); const p = ctx.flags.json ? "" : promptPrefix(userInfo().username || "you"); let inflight: AbortController | null = null; const onSigint = (): void => inflight?.abort(); process.on("SIGINT", onSigint); try { - if (p) process.stdout.write(p); + if (p) process.stdout.write(p + consoleShell.prompt()); for await (const line of rl) { let input = classifyConsoleInput(line); - if (input.kind === "share") input = shell.share(); - if (input.kind === "shell") { + if (input.kind === "share") input = consoleShell.share(); + if (input.kind === "error") { process.stdout.write(input.message + "\n"); if (p) process.stdout.write(p + consoleShell.prompt()); continue; } + const t = input.kind === "chat" ? input.text : ""; + if (input.kind === "shell" || input.kind === "reset-shell") { inflight = new AbortController(); - try { await shell.run(input.command, inflight.signal); } finally { inflight = null; } - if (p) process.stdout.write(p); + try { await consoleShell.run(input, inflight.signal); } + finally { inflight = null; } + if (p) process.stdout.write(p + consoleShell.prompt()); continue; } - if (input.kind === "error") { - process.stdout.write(input.message + "\n"); - if (p) process.stdout.write(p); - continue; - } - const t = input.kind === "chat" ? input.text : ""; if (!t) { - if (p) process.stdout.write(p); + if (p) process.stdout.write(p + consoleShell.prompt()); continue; } if (historyEnabled() && line.trim() !== "/shell-result") appendHistory(line.trim(), historyPath(ctx.flags.cwd)); @@ -1782,7 +1796,7 @@ async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Pro } finally { inflight = null; } - if (p) process.stdout.write(p); + if (p) process.stdout.write(p + consoleShell.prompt()); continue; } inflight = new AbortController(); @@ -1812,10 +1826,11 @@ async function replLines(ctx: AppContext, skillOpts: TurnSkillOptions = {}): Pro } finally { inflight = null; } - if (p) process.stdout.write((printed ? "" : "\n") + p); + if (p) process.stdout.write((printed ? "" : "\n") + p + consoleShell.prompt()); } return 0; } finally { + consoleShell.close(); process.off("SIGINT", onSigint); rl.close(); if (p) process.stdout.write("\n"); diff --git a/src/commands/code.ts b/src/commands/code.ts index 6a2dae5..ee5ef5e 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -17,6 +17,7 @@ import { OllamaBrain } from "../core/brain_ollama.js"; import { resolveHostedModel, resolveLocalModelSelection } from "../core/local_ollama.js"; import { CloudBrain } from "../core/brain_cloud.js"; import { ToolExecutor } from "../core/tool_executor.js"; +import { ShellSession } from "../core/shell_session.js"; import { stdioPrompt } from "../ui/interact.js"; import { defaultRunner, type Runner } from "../core/worktree.js"; import { isCurrentWorkspace } from "../core/workspace_scope.js"; @@ -670,7 +671,9 @@ export async function cmdCode( // silently accept the one-way chat transport, whose tools run // server-side against the cloud vault (brain_cloud CloudBrainOptions). new CloudBrain(ctx.api, undefined, { requireLocalAuthority: true }); - const exec = new ToolExecutor(cwd, opts.testCmd); + // A worktree gets its own fresh shell; launch-project state never follows it. + const shellSession = process.platform === "linux" || process.platform === "darwin" ? new ShellSession(cwd) : undefined; + const exec = new ToolExecutor(cwd, opts.testCmd, { mode: "coding", ...(shellSession ? { shellSession } : {}) }); // Scope the session manifest to the ORIGINAL launch directory (ctx.flags.cwd), // not the possibly-substituted `cwd` (an auto-created worktree, or a manually // redirected directory from the repo gate) — resume always compares against @@ -788,7 +791,7 @@ export async function cmdCode( } const detail = String(args["command"] ?? args["path"] ?? args["message"] ?? ""); const shown = detail.length > 200 ? detail.slice(0, 197) + "…" : detail; - return ctx.confirm(`\n⚠ ${name}${shown ? ` ${shown}` : ""} — run it? [y/N] `); + return ctx.confirm(`\n⚠ ${name}${shown ? ` ${shown}` : ""} [cwd: ${sanitizeServerText(exec.shellCwd)}; file root: ${sanitizeServerText(cwd)}] — run it? [y/N] `); }; // Presentation fork — TTY (and not --json/--quiet) gets the live animated @@ -1044,6 +1047,7 @@ export async function cmdCode( if (outcome.state === "cancelled") return signalExitCode ?? 130; return verifyExit > 0 ? verifyExit : outcome.exitCode; } finally { + exec.close(); process.removeListener("SIGINT", onSigint); process.removeListener("SIGTERM", onSigterm); } @@ -1195,6 +1199,7 @@ export async function hostLoop( modelOutput.reset(); break; } + const approvalContext = exec.shellContext; const approved = gate ? await boundedCodeOperation( () => gate({ name: ev.name, args: ev.args }), @@ -1207,6 +1212,7 @@ export async function hostLoop( : true; const remaining = remainingCodeProgressMs(timeoutMs, lastMeaningfulAt); const runOptions: RunOptions = { + expectedShellContext: approvalContext, ...(signal ? { signal } : {}), ...(timeoutMs > 0 ? { timeoutMs: Math.max(1, remaining) } : {}), }; diff --git a/src/commands/command_manifest_data.ts b/src/commands/command_manifest_data.ts index 5bd1aa5..32d0736 100644 --- a/src/commands/command_manifest_data.ts +++ b/src/commands/command_manifest_data.ts @@ -4038,6 +4038,45 @@ export const COMMAND_MANIFEST_SOURCE: readonly CommandManifestEntry[] = [ "note": "Coding console only; explicit sharing of at most 8 KiB of untrusted local output." } }, + { + "key": "slash:shell-reset", + "surface": "slash", + "name": "shell-reset", + "aliases": [], + "compatibilityAliases": [], + "deprecatedAliases": [], + "summary": "discard local shell cwd/environment/functions; never replay", + "detailedHelp": "/shell-reset\ndiscard local shell cwd/environment/functions; never replay", + "section": "Steering", + "hidden": false, + "permissionClass": "unknown", + "availability": { + "state": "runtime-dependent", + "capabilityRequirements": [] + }, + "telemetryName": "slash.shell-reset", + "acceptedGlobalFlags": [], + "ownedFlags": {}, + "handler": { + "id": "handler:slash:shell-reset", + "kind": "host", + "module": "src/commands/slash.ts", + "symbol": "handleSlash" + }, + "docs": { + "kind": "manifest", + "module": "src/commands/command_manifest_data.ts", + "symbol": "COMMAND_MANIFEST_SOURCE", + "target": "shell-reset", + "usage": "/shell-reset", + "visible": true, + "disposition": "generated" + }, + "release": { + "disposition": "new", + "note": "Local coding console only; handled before model routing." + } + }, { "key": "slash:queue", "surface": "slash", diff --git a/src/commands/console_input.ts b/src/commands/console_input.ts index 1b6a17f..21566b6 100644 --- a/src/commands/console_input.ts +++ b/src/commands/console_input.ts @@ -1,55 +1,76 @@ -import { resolve } from "node:path"; +import { ShellSession, type ShellCommandEvent } from "../core/shell_session.js"; +import { randomUUID } from "node:crypto"; import { ToolExecutor } from "../core/tool_executor.js"; -import { sanitizeTerm } from "../ui/text.js"; +import { sanitizeServerText } from "../core/transport.js"; +/** Classify before history, prompt rewriting, or the busy queue. */ export type ConsoleInput = | { kind: "shell"; command: string } - | { kind: "chat"; text: string } - | { kind: "empty" } + | { kind: "reset-shell" } + | { kind: "share" } | { kind: "error"; message: string } - | { kind: "share" }; + | { kind: "chat"; text: string } + | { kind: "empty" }; -/** Classify once, before rewriting, history, or queueing. Never infer shell from chat. */ 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-reset") return { kind: "reset-shell" }; if (text.startsWith("\\!")) return { kind: "chat", text: text.slice(1) }; - if (!text.startsWith("!")) return { kind: "chat", text }; - const command = text.slice(1).trim(); - if (!command) return { kind: "error", message: "usage: ! (escape a literal ! with \\!)" }; - if (/[\r\n]/.test(raw)) return { kind: "error", message: "Multiline shell paste refused; submit one command (pipelines and ; are supported)." }; - return { kind: "shell", command }; + if (text.startsWith("!")) { + const command = text.slice(1).trim(); + return command ? { kind: "shell", command } : { kind: "error", message: "usage: ! (escape a literal ! with \\!)" }; + } + return { kind: "chat", text }; } -/** Session-only output; sharing is explicit and bounded, never automatic. */ +/** Local shell presentation is shared by raw TTY and line-mode routing. */ export class ConsoleShell { private result: string | null = null; - private readonly cwd: string; - constructor(cwd: string, private readonly write: (text: string) => void) { this.cwd = resolve(cwd); } - - 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}` }; + readonly session: ShellSession; + readonly exec: ToolExecutor; + constructor(root: string, private readonly write: (text: string) => void, private readonly json = false) { + this.session = new ShellSession(root, event => this.event(event)); + this.exec = new ToolExecutor(root, undefined, { mode: "coding", ...(process.platform === "win32" ? {} : { shellSession: this.session }) }); } - - async run(command: string, signal: AbortSignal): Promise<"completed" | "aborted"> { + private event(event: ShellCommandEvent): void { + 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.write(`[shell user | cwd ${sanitizeTerm(this.cwd)} | running] !${sanitizeTerm(command)}\n`); - try { - const result = await new ToolExecutor(this.cwd).runUserCommand(command, { - signal, - onOutput: (chunk) => this.write(sanitizeTerm(chunk)), - }); - const state = signal.aborted ? "cancelled" : "completed"; - const full = `!${command}\ncwd: ${this.cwd}\nstate: ${state}; exit: ${result.exitCode}\n${result.output}`; - this.result = Buffer.from(full).subarray(0, 8192).toString("utf8").replace(/\ufffd$/, ""); - this.write(`\n[shell user | ${state} | exit ${result.exitCode}]\n`); - return signal.aborted ? "aborted" : "completed"; - } catch (err) { - this.write(`\n[shell user | failed | exit 1] ${sanitizeTerm(String(err))}\n`); + if (input.kind === "reset-shell") { + this.session.reset(); + this.write(this.json ? JSON.stringify({ type: "shell_reset", sessionId: this.session.id, cwd: this.session.cwd }) + "\n" : "shell reset — cwd/environment/functions cleared; commands were not replayed.\n"); return "completed"; } + const fallback = process.platform === "win32" ? { + sessionId: this.session.id, commandId: randomUUID(), origin: "user" as const, + command: input.command, cwd: this.session.cwd, + } : null; + if (fallback) this.event({ ...fallback, state: "running" }); + let streamed = false; + const result = await this.exec.runUserCommand(input.command, { ...(signal ? { signal } : {}), onOutput: text => { + streamed = true; + 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}`; + this.result = Buffer.from(full).subarray(0, 8192).toString("utf8").replace(/\ufffd$/, ""); + // 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; + this.write(this.json ? JSON.stringify({ type: "shell_result", sessionId: this.session.id, ...result }) + "\n" : sanitizeServerText(visible) + "\n"); + // 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}` }; } + prompt(): string { return `[${sanitizeServerText(this.session.cwd)}${this.session.state === "lost" ? "; shell lost" : ""}] `; } + close(): void { this.session.close(); } } diff --git a/src/commands/slash.ts b/src/commands/slash.ts index d929cf3..b324e8a 100644 --- a/src/commands/slash.ts +++ b/src/commands/slash.ts @@ -114,6 +114,9 @@ export async function handleSlash( const arg = parts.slice(1).join(" "); switch (cmd) { + case "shell-reset": + out.write("/shell-reset belongs to the local coding console; submit it there to discard shell state.\n"); + break; case "exit": case "quit": return { exit: true }; diff --git a/src/core/git_commit_guard.ts b/src/core/git_commit_guard.ts index 438bb36..41e0c4d 100644 --- a/src/core/git_commit_guard.ts +++ b/src/core/git_commit_guard.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; export interface GitRunResult { ok: boolean; @@ -23,12 +24,13 @@ export interface GitRunner { */ export const GIT_GLOBAL_ARGS: readonly string[] = [ "--no-optional-locks", + "--literal-pathspecs", "-c", "core.literalPathspecs=true", ]; /** - * The pathspec appended to the two repository-state probes. + * The pathspec appended to the dirty-worktree probe. * * `git status` reports the WHOLE repository, not the directory it runs in. * Unbounded, the probe is O(entire repository) even when the agent's workspace @@ -48,20 +50,33 @@ export const STATUS_PROBE: readonly string[] = [ ...WORKSPACE_PATHSPEC, ]; -/** `git diff --cached` probe: staged paths in the workspace subtree. */ +/** A commit consumes the ENTIRE index, including staged paths outside a nested + * workspace. Inspect all staged paths so unrelated user work cannot be swept in. */ export const STAGED_PROBE: readonly string[] = [ "diff", "--cached", "--name-only", "-z", - ...WORKSPACE_PATHSPEC, ]; export class SpawnGitRunner implements GitRunner { + private repoRoot: string | null = null; constructor(private readonly cwd: string) {} run(args: string[]): GitRunResult { - const result = spawnSync("git", [...GIT_GLOBAL_ARGS, ...args], { + // Porcelain names are repo-relative even when cwd is a workspace subtree. + // Candidate staging/reset/drift checks must use that same path base. + let argv = args; + const separator = args.indexOf("--"); + if (["add", "reset", "diff"].includes(args[0] ?? "") && separator >= 0 && args[separator + 1] !== ".") { + if (!this.repoRoot) { + const root = spawnSync("git", [...GIT_GLOBAL_ARGS, "rev-parse", "--show-toplevel"], { cwd: this.cwd, encoding: "utf8" }); + if (root.status !== 0) return { ok: false, stdout: "", stderr: "unable to resolve git root", exitCode: 1 }; + this.repoRoot = root.stdout.trim(); + } + argv = [...args.slice(0, separator + 1), ...args.slice(separator + 1).map(path => resolve(this.repoRoot!, path))]; + } + const result = spawnSync("git", [...GIT_GLOBAL_ARGS, ...argv], { cwd: this.cwd, shell: false, encoding: "utf8", @@ -170,7 +185,7 @@ export class GitCommitGuard { this.initError = dirty.ok && staged.ok ? null : "not a usable git repository"; } - commit(message: string): GitCommitResult { + commit(message: string, ownedPaths?: ReadonlySet): GitCommitResult { if (this.initError) return { output: "[git_commit refused: " + this.initError + "]", exitCode: 1 }; const dirty = this.runner.run([...STATUS_PROBE]); @@ -185,6 +200,7 @@ export class GitCommitGuard { parseNulPaths(staged.stdout), ); if (!plan.ok) return { output: "[git_commit refused: " + plan.reason + "]", exitCode: 1 }; + if (ownedPaths) plan.candidates = plan.candidates.filter(path => ownedPaths.has(path)); if (!plan.candidates.length) return { output: "[nothing new to commit]", exitCode: 0 }; const added = this.runner.run(["add", "-A", "--", ...plan.candidates]); diff --git a/src/core/shell_session.ts b/src/core/shell_session.ts new file mode 100644 index 0000000..50b1c49 --- /dev/null +++ b/src/core/shell_session.ts @@ -0,0 +1,246 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { realpathSync, statSync } from "node:fs"; +import { resolve, sep } from "node:path"; +import type { Readable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; +import { childEnv } from "./child_env.js"; +import type { RunOptions, ToolResult } from "./tool_executor.js"; + +export interface ShellCommandEvent { + sessionId: string; + commandId: string; + origin: "user" | "model"; + command: string; + cwd: string; + state: "running" | "completed" | "cancelled" | "lost"; + exitCode?: number; +} + +const quote = (value: string): string => "'" + value.replaceAll("'", "'\\''") + "'"; + +/** Console-owned, non-interactive Bash. No credentials or rc files inherited. + * File tools continue resolving at workspaceRoot, regardless of shell cwd. + * This is session control, not an OS sandbox for arbitrary shell commands. + */ +export class ShellSession { + readonly workspaceRoot: string; + readonly shell = "/bin/bash"; + id = randomUUID(); + cwd: string; + state: "ready" | "lost" | "closed" = "ready"; + private child: ChildProcess | null = null; + private tail: Promise = Promise.resolve(); + private active = false; + private queued = 0; + private generation = 0; + revision = 0; + private failActive: ((reason: string) => void) | null = null; + private readonly rootIdentity: string; + + constructor(root: string, private readonly onEvent?: (event: ShellCommandEvent) => void) { + this.workspaceRoot = realpathSync(resolve(root)); + this.cwd = this.workspaceRoot; + const stat = statSync(this.workspaceRoot); + this.rootIdentity = `${stat.dev}:${stat.ino}`; + } + + get busy(): boolean { return this.queued > 0; } + + /** Every local tool and user submission uses the same FIFO host slot. */ + async withSlot(work: () => Promise): Promise { + const generation = this.generation; + this.queued++; + const pending = this.tail.then(async () => { + if (generation !== this.generation || this.state === "closed") { + throw new Error("shell session changed; submission discarded (not replayed)"); + } + this.active = true; + try { return await work(); } + finally { this.active = false; } + }); + const tracked = pending.finally(() => { this.queued--; }); + this.tail = tracked.catch(() => {}); + return tracked; + } + + /** Host/user action only. Invalidates queued commands; no automatic replay. */ + reset(): void { + this.stop("shell explicitly reset; previous state discarded"); + this.generation++; + this.id = randomUUID(); + this.revision = 0; + this.cwd = this.workspaceRoot; + this.state = "ready"; + } + + close(): void { + this.stop("console closed"); + this.generation++; + this.state = "closed"; + } + + private stop(reason: string): void { + this.state = "lost"; + const fail = this.failActive; + const child = this.child; + this.child = null; + if (!child?.pid) { fail?.(reason); return; } + const pid = child.pid; + try { process.kill(-pid, "SIGTERM"); } catch { /* already gone */ } + // Reap all descendants, including ones which ignored TERM. Do not replay. + const escalation = setTimeout(() => { + try { process.kill(-pid, "SIGKILL"); } catch { /* already gone */ } + fail?.(reason); + }, 200); + if (!fail) escalation.unref(); + } + + private start(): ChildProcess { + if (process.platform !== "linux" && process.platform !== "darwin") { + throw new Error("persistent shell requires Linux/macOS Bash; this console does not fall back silently"); + } + const child = spawn(this.shell, ["--noprofile", "--norc"], { + cwd: this.workspaceRoot, env: childEnv(), detached: true, + stdio: ["pipe", "pipe", "pipe", "pipe"], + }); + this.child = child; + child.stdin?.on("error", (error) => { + if (this.child === child) this.stop(`Bash input failed: ${error.message}`); + }); + child.on("error", (error) => { + if (this.child === child) this.stop(`Bash spawn failed: ${error.message}`); + }); + child.on("exit", (code, signal) => { + if (this.child === child) this.stop(`Bash exited (${signal ?? code ?? "unknown"}); state lost`); + }); + // Drain idle output too. Background jobs are awaited at each command end. + child.stdout?.on("data", () => {}); + child.stderr?.on("data", () => {}); + // cd validates the physical target BEFORE changing this shell's directory. + // Readonly prevents casual replacement; explicit builtin cd is checked at + // completion and loses the session on escape. Shell authority is unchanged. + const root = quote(this.workspaceRoot); + child.stdin!.write(`cd() { local target; target=$(builtin cd "$@" >/dev/null && builtin pwd -P) || return; case "$target" in ${root}|${root}/*) builtin cd -- "$target" ;; *) builtin printf 'refusing cwd outside workspace\\n' >&2; return 1 ;; esac; }; readonly -f cd\n`); + return child; + } + + /** Called only while holding withSlot. Preserves variables/functions/cwd. */ + runInSlot(command: string, origin: ShellCommandEvent["origin"], options: RunOptions = {}): Promise { + if (!this.active) throw new Error("shell command requires the local execution slot"); + if (!command.trim()) return Promise.resolve({ output: "[empty shell command]", exitCode: 1 }); + if (options.signal?.aborted) return Promise.resolve({ output: "[aborted before start]", exitCode: 130 }); + if (this.state !== "ready") { + return Promise.resolve({ output: "[shell state lost; use /shell-reset to start fresh; command not replayed]", exitCode: 1 }); + } + try { + if (realpathSync(this.workspaceRoot) !== this.workspaceRoot) throw new Error("workspace root replaced"); + const stat = statSync(this.workspaceRoot); + if (`${stat.dev}:${stat.ino}` !== this.rootIdentity) throw new Error("workspace root replaced"); + } catch { + this.stop("approved workspace is no longer accessible"); + return Promise.resolve({ output: "[approved workspace changed; command refused; open a new console]", exitCode: 1 }); + } + this.revision++; + const commandId = randomUUID(); + const event = { sessionId: this.id, commandId, origin, command, cwd: this.cwd }; + const emit = (state: ShellCommandEvent["state"], exitCode?: number): void => { + this.onEvent?.({ ...event, cwd: this.cwd, state, ...(exitCode !== undefined ? { exitCode } : {}) }); + }; + let child: ChildProcess; + try { child = this.child ?? this.start(); } + catch (error) { + this.state = "lost"; + emit("lost", 1); + return Promise.resolve({ output: `[shell unavailable: ${String(error)}; use /shell-reset]`, exitCode: 1 }); + } + emit("running"); + return new Promise((settle) => { + const marker = `\x1e${commandId}\x1f`; + let output = ""; + let completed = false; + let control = ""; + let code: number | null = null; + let cwd: string | null = null; + let outDone = false; + let errDone = false; + const retain = (text: string): void => { + if (output.length < 8000) { + const kept = text.slice(0, 8000 - output.length); + output += kept; + options.onOutput?.(kept); + } + }; + const streamReader = (stream: Readable, end: () => void): (() => void) => { + let pending = ""; + const decoder = new StringDecoder("utf8"); + const read = (chunk: Buffer): void => { + pending += decoder.write(chunk); + const index = pending.indexOf(marker); + if (index >= 0) { + retain(pending.slice(0, index)); + pending = ""; + end(); + } else { + // A marker may span chunks. Drain everything except its suffix. + const safe = Math.max(0, pending.length - marker.length + 1); + retain(pending.slice(0, safe)); + pending = pending.slice(safe); + } + }; + stream.on("data", read); + return () => { stream.off("data", read); retain(pending + decoder.end()); }; + }; + const finish = (result: ToolResult, state: ShellCommandEvent["state"]): void => { + if (completed) return; + completed = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + cleanupOut(); cleanupErr(); + if (state !== "completed") result.output += output; + fd.off("data", onControl); + this.failActive = null; + emit(state, result.exitCode); + settle(result); + }; + const check = (): void => { + if (code === null || cwd === null || !outDone || !errDone || completed) return; + let physical: string; + try { physical = realpathSync(cwd); } + catch { this.stop("shell cwd is no longer accessible"); return; } + if (physical !== this.workspaceRoot && !physical.startsWith(this.workspaceRoot + sep)) { + this.stop("shell changed cwd outside approved workspace"); + return; + } + this.cwd = physical; + finish({ output: `[exit ${code}]\n${output}`, exitCode: code }, "completed"); + }; + const cleanupOut = streamReader(child.stdout!, () => { outDone = true; check(); }); + const cleanupErr = streamReader(child.stderr!, () => { errDone = true; check(); }); + const fd = child.stdio[3] as Readable; + const controlDecoder = new StringDecoder("utf8"); + const onControl = (chunk: Buffer): void => { + control += controlDecoder.write(chunk); + if (control.length > 16384) { this.stop("shell control channel overflow"); return; } + const parts = control.split("\0"); + if (parts.length >= 4 && parts[0] === commandId) { + code = Number(parts[1]); cwd = parts[2]!; check(); + } + }; + fd.on("data", onControl); + let verdict = 1; + this.failActive = (reason) => finish({ + output: `[${reason}; shell state lost; use /shell-reset; command not replayed]\n`, + exitCode: verdict, + }, verdict === 130 ? "cancelled" : "lost"); + const abort = (): void => { verdict = 130; this.stop("aborted"); }; + options.signal?.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(() => { verdict = 124; this.stop("shell command timed out"); }, options.timeoutMs ?? 900_000); + timer.unref(); + // eval executes in this process; stdin belongs to the protocol. PTY and + // interactive programs are deliberately a separate follow-up. wait makes + // background jobs part of the same command/cancellation ownership. + child.stdin!.write(`builtin eval -- ${quote(command)} &3\nbuiltin printf '%s' ${quote(marker)}\nbuiltin printf '%s' ${quote(marker)} >&2\n`); + }); + } +} diff --git a/src/core/tool_executor.ts b/src/core/tool_executor.ts index 4cff513..499ebe2 100644 --- a/src/core/tool_executor.ts +++ b/src/core/tool_executor.ts @@ -13,6 +13,8 @@ import type { ToolName } from "./brain_protocol.js"; import { validateToolCall } from "./tool_registry.js"; import { GitCommitGuard, SpawnGitRunner } from "./git_commit_guard.js"; import { webFetch, webSearch } from "./web.js"; +import { ShellSession } from "./shell_session.js"; +import { WorkspaceOwnership } from "./workspace_ownership.js"; const MAX_OUTPUT = 8000; // CONTRACTS.md invariant 5: an unset test_cmd means "no ground truth to assert" — @@ -42,6 +44,8 @@ export interface RunOptions { timeoutMs?: number; signal?: AbortSignal; onOutput?: (chunk: string) => void; + /** Bind a model approval to the exact session and cwd displayed by the host. */ + expectedShellContext?: string; } export interface ToolResult { @@ -52,6 +56,7 @@ export interface ToolResult { /** Chosen by the local host when it creates an executor, never by a tool call. */ export interface ToolExecutionContext { readonly mode: "coding" | "pc"; + readonly shellSession?: ShellSession; } /** @@ -75,6 +80,9 @@ export class ToolExecutor { * eagerly froze the CLI before its first turn. */ private committer: GitCommitGuard | null = null; + private ownership: WorkspaceOwnership | null = null; + private readonly shellSession?: ShellSession; + private checkout: string | undefined; constructor( cwd: string, @@ -90,6 +98,46 @@ export class ToolExecutor { // Canonicalize the root once (resolve any symlinks in the workspace path). const r = resolve(cwd); this.root = existsSync(r) ? realpathSync(r) : r; + if (context.shellSession && (context.mode !== "coding" || context.shellSession.workspaceRoot !== this.root)) { + throw new Error("shell session must belong to this coding workspace"); + } + this.shellSession = context.shellSession; + } + + /** Directory shown to the user; never substitutes for the file-tool root. */ + get shellCwd(): string { return this.shellSession?.cwd ?? this.root; } + get shellContext(): string { return `${this.shellSession?.id ?? "one-shot"}\0${this.shellSession?.revision ?? 0}\0${this.shellCwd}`; } + + close(): void { this.shellSession?.close(); } + + /** Compatibility entrypoint; a console-owned executor shares its session. */ + async runUserCommand(command: string, options: RunOptions = {}): Promise { + if (this.mode === "pc") return this.pcToolRefusal(); + if (this.shellSession) return this.runUserShell(command, options); + this.armCommitGuard(); + const before = this.ownership!.before(); + try { return await this.run(command, options); } + finally { this.ownership!.after(before, "user"); } + } + + /** Explicit user submissions are not model tools and confer no permission. */ + async runUserShell(command: string, options: RunOptions = {}): Promise { + if (this.mode === "pc" || !this.shellSession) return { output: "[no console shell session]", exitCode: 1 }; + if (!command.trim()) return { output: "[empty shell command]", exitCode: 1 }; + try { + return await this.shellSession.withSlot(async () => { + this.armCommitGuard(); + if (this.reconcileCheckout()) return { output: "[checkout changed; shell state reset; re-submit command]", exitCode: 1 }; + const before = this.ownership!.before(); + try { + const result = await this.shellSession!.runInSlot(command, "user", options); + this.ownership!.after(before, "user"); + if (this.reconcileCheckout()) result.output += "\n[checkout changed; shell cwd/environment/functions reset; queued commands discarded]"; + return result; + } + catch (error) { this.ownership!.after(before, "user"); throw error; } + }); + } catch (error) { return { output: `[shell rejected: ${String(error)}]`, exitCode: 1 }; } } /** @@ -111,10 +159,33 @@ export class ToolExecutor { private armCommitGuard(): GitCommitGuard { if (!this.committer) { this.committer = new GitCommitGuard(new SpawnGitRunner(this.root)); + this.ownership = new WorkspaceOwnership(this.root); + this.checkout = this.checkoutIdentity(); } return this.committer; } + private checkoutIdentity(): string { + const runner = new SpawnGitRunner(this.root); + const dir = runner.run(["rev-parse", "--absolute-git-dir"]); + const branch = runner.run(["symbolic-ref", "--quiet", "HEAD"]); + // Detached checkout changes also discard state; ordinary attached commits + // preserve the session. An unusable repository is still a shell workspace. + const head = branch.ok ? branch.stdout : runner.run(["rev-parse", "HEAD"]).stdout; + return dir.ok ? dir.stdout + "\0" + head : "no-git"; + } + + private reconcileCheckout(): boolean { + if (!this.shellSession || this.checkout === undefined) return false; + const current = this.checkoutIdentity(); + if (current === this.checkout) return false; + this.shellSession.reset(); + this.committer = null; + this.ownership = null; + this.checkout = undefined; + return true; + } + private pcToolRefusal(): ToolResult { return { output: "[tool rejected: PC task mode accepts only registered PC operations through the local host gateway]", @@ -146,11 +217,6 @@ export class ToolExecutor { return abs; } - /** Explicit console input only; never changes model permissions or custody. */ - runUserCommand(command: string, options: RunOptions = {}): Promise { - return this.run(command, options); - } - /** * Run a shell command in the workspace; capture combined output, capped. * @@ -169,6 +235,7 @@ export class ToolExecutor { * operator, one is the clock, and the caller needs to tell them apart. */ private run(command: string, options: RunOptions = {}): Promise { + if (this.shellSession) return this.shellSession.runInSlot(command, "model", options); const timeoutMs = options.timeoutMs ?? 900_000; const signal = options.signal; const onWindows = process.platform === "win32"; @@ -279,6 +346,16 @@ export class ToolExecutor { * pointer rather than silently no-op'ing. */ execute(name: string, rawArgs: unknown): ToolResult { + if (this.shellSession?.busy) return { output: "[local execution busy — call executeAsync to queue]", exitCode: 1 }; + if (this.reconcileCheckout()) return { output: "[checkout changed; shell state reset; request fresh approval]", exitCode: 1 }; + const mutation = (name === "write_file" || name === "git_commit") && validateToolCall(name, rawArgs).ok && this.mode === "coding"; + if (mutation) this.armCommitGuard(); + const before = mutation ? this.ownership!.before() : null; + try { return this.executeSync(name, rawArgs); } + finally { if (before) this.ownership!.after(before, "model"); } + } + + private executeSync(name: string, rawArgs: unknown): ToolResult { if (this.mode === "pc") return this.pcToolRefusal(); const validation = validateToolCall(name, rawArgs); if (!validation.ok) { @@ -323,6 +400,27 @@ export class ToolExecutor { * output the brain reads as ordinary tool output). */ async executeAsync(name: string, rawArgs: unknown, options: RunOptions = {}): Promise { + const dispatch = async (): Promise => { + if (options.signal?.aborted) return { output: "[aborted before start]", exitCode: 130 }; + if (this.reconcileCheckout()) return { output: "[checkout changed; shell state reset; request fresh approval]", exitCode: 1 }; + if (options.expectedShellContext !== undefined && options.expectedShellContext !== this.shellContext) { + return { output: "[tool refused: shell session/cwd changed after approval; request fresh approval]", exitCode: 1 }; + } + const mutation = MUTATING_TOOLS.has(name) && validateToolCall(name, rawArgs).ok && this.mode === "coding"; + if (mutation) this.armCommitGuard(); + const before = mutation ? this.ownership!.before() : null; + try { + const result = await this.dispatchAsync(name, rawArgs, options); + if (before) this.ownership!.after(before, "model"); + if (this.reconcileCheckout()) result.output += "\n[checkout changed; shell cwd/environment/functions reset; queued commands discarded]"; + return result; + } catch (error) { if (before) this.ownership!.after(before, "model"); throw error; } + }; + try { return await (this.shellSession ? this.shellSession.withSlot(dispatch) : dispatch()); } + catch (error) { return { output: `[tool ${name} error: ${String(error)}]`, exitCode: 1 }; } + } + + private async dispatchAsync(name: string, rawArgs: unknown, options: RunOptions): Promise { if (this.mode === "pc") return this.pcToolRefusal(); const validation = validateToolCall(name, rawArgs); if (!validation.ok) { @@ -356,7 +454,7 @@ export class ToolExecutor { const text = await webFetch(String(args["url"] ?? ""), MAX_OUTPUT); return { output: capHeadTail(text, MAX_OUTPUT), exitCode: 0 }; } - return this.execute(name, args); + return this.executeSync(name, args); } private readFile(path: string): ToolResult { @@ -440,7 +538,7 @@ export class ToolExecutor { } private gitCommit(message: string): ToolResult { - return this.armCommitGuard().commit(message); + return this.armCommitGuard().commit(message, this.ownership!.candidates()); } } diff --git a/src/core/workspace_ownership.ts b/src/core/workspace_ownership.ts new file mode 100644 index 0000000..05e5a68 --- /dev/null +++ b/src/core/workspace_ownership.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs"; +import { resolve, sep } from "node:path"; +import { SpawnGitRunner, STATUS_PROBE, parsePorcelainPaths } from "./git_commit_guard.js"; + +/** Conservative whole-path ownership. A mixed user/model file is never staged + * automatically. This is attribution between serialized local operations, + * not a filesystem lock against unrelated programs writing concurrently. + */ +export class WorkspaceOwnership { + private readonly runner: SpawnGitRunner; + private readonly repoRoot: string; + private last = new Map(); + private readonly excluded = new Set(); + private readonly owned = new Set(); + private usable = true; + + constructor(private readonly root: string) { + this.runner = new SpawnGitRunner(root); + const repo = this.runner.run(["rev-parse", "--show-toplevel"]); + this.repoRoot = repo.ok ? repo.stdout.trim() : root; + this.last = this.capture(); + for (const path of this.last.keys()) this.excluded.add(path); + } + + private capture(): Map { + const status = this.runner.run([...STATUS_PROBE]); + const result = new Map(); + if (!status.ok) { this.usable = false; return result; } + for (const path of parsePorcelainPaths(status.stdout)) { + const abs = resolve(this.repoRoot, path); + if (abs !== this.root && !abs.startsWith(this.root + sep)) { this.usable = false; continue; } + try { + const stat = lstatSync(abs); + let value: string; + if (stat.isSymbolicLink()) value = "link:" + readlinkSync(abs); + else if (stat.isFile()) { + const physical = realpathSync(abs); + if (!physical.startsWith(this.root + sep)) { this.usable = false; continue; } + value = createHash("sha256").update(readFileSync(abs)).digest("hex"); + } else { this.usable = false; continue; } + result.set(path, `${stat.mode}:${value}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") result.set(path, "deleted"); + else this.usable = false; + } + } + return result; + } + + private changed(before: Map, after: Map): string[] { + return [...new Set([...before.keys(), ...after.keys()])].filter(path => before.get(path) !== after.get(path)); + } + + /** Capture drift BEFORE every local mutation, including automatic commit. */ + before(): Map { + const now = this.capture(); + for (const path of this.changed(this.last, now)) { + this.excluded.add(path); + this.owned.delete(path); + } + this.last = now; + return now; + } + + after(before: Map, origin: "user" | "model"): void { + const now = this.capture(); + for (const path of this.changed(before, now)) { + if (origin === "user") { this.excluded.add(path); this.owned.delete(path); } + else if (!this.excluded.has(path)) this.owned.add(path); + } + this.last = now; + } + + candidates(): ReadonlySet { + return this.usable ? this.owned : new Set(); + } +} diff --git a/test/console_input.test.ts b/test/console_input.test.ts index 12d3813..e1b138a 100644 --- a/test/console_input.test.ts +++ b/test/console_input.test.ts @@ -17,8 +17,9 @@ for (const [raw, expected] of [ assert.deepEqual(classifyConsoleInput(raw), expected); }); -test("empty ! and multiline shell paste are refused", () => { - for (const raw of ["!", " ! ", "!pwd\nls", "!pwd\rwhoami", "!pwd\n", "\n!pwd"]) { +test("empty ! is refused and multiline shell paste is one preserved command", () => { + assert.deepEqual(classifyConsoleInput("!pwd\nls"), { kind: "shell", command: "pwd\nls" }); + for (const raw of ["!", " ! "]) { assert.equal(classifyConsoleInput(raw).kind, "error"); } }); @@ -43,11 +44,12 @@ test("user execution uses the chosen checkout, quotes/pipelines, bounded explici setTimeout(() => controller.abort(), 50); assert.equal(await pending, "aborted"); 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(); assert.equal(shared.kind, "chat"); if (shared.kind === "chat") assert.ok(shared.text.length < 8300); - } finally { rmSync(cwd, { recursive: true, force: true }); } + } finally { shell.close(); rmSync(cwd, { recursive: true, force: true }); } }); /** Exercise the real submit handlers in isolated processes, with a synthetic @@ -91,7 +93,7 @@ for (const tty of [false, true]) { await delay(20); submit('!echo QUEUED_SHELL'); await delay(250); - submit('!exit 7'); + submit(${JSON.stringify(`!"${process.execPath}" -e "process.exit(7)"`)}); await delay(100); submit('!'); await delay(50); @@ -99,9 +101,11 @@ for (const tty of [false, true]) { submit(${JSON.stringify(`!"${process.execPath}" -e "setTimeout(()=>{},30000)"`)}); await delay(100); ${tty ? "input.write('\\\\!literal'); input.write('\\x03');" : "process.emit('SIGINT');"} - await delay(150); + await delay(350); ${tty ? "input.write(enter);" : "submit('\\\\!literal');"} await delay(200); + submit('/shell-reset'); + await delay(100); submit('!echo SHARE_ALLOWED'); await delay(100); sharing = true; @@ -129,7 +133,7 @@ for (const tty of [false, true]) { assert.ok(result.output.includes("VERIFIED_CALLS_3"), result.output); assert.ok(result.output.includes("completed | exit 7"), result.output); assert.ok(result.output.includes("cancelled | exit 130"), result.output); - if (tty) assert.ok(result.output.includes("Multiline shell paste refused"), result.output); + if (tty) assert.ok(result.output.includes("MULTILINE_BAD") && result.output.includes("SECOND_BAD"), result.output); assert.ok(result.output.includes("usage: !"), result.output); assert.ok(result.output.indexOf("model response") < result.output.indexOf("running] !echo QUEUED_SHELL"), result.output); assert.ok(result.output.includes(cwd), result.output); @@ -142,6 +146,6 @@ test("console submit routing remains before history/rewrite and uses typed queue const code = readFileSync("src/commands/chat.ts", "utf8"); assert.ok(code.includes("const queue: ConsoleInput[]")); assert.ok(code.indexOf("classifyConsoleInput(queuePrefix") < code.indexOf("const commit =")); - assert.ok(code.includes("result = await runInput(next)")); + assert.ok(code.includes("result = await runQueuedTurn(next)")); assert.ok(code.includes("appendHistory(line.trim(), historyPath(ctx.flags.cwd))"), "save the original escaped input, not its model prompt"); }); diff --git a/test/console_shell.test.ts b/test/console_shell.test.ts new file mode 100644 index 0000000..2d1d65f --- /dev/null +++ b/test/console_shell.test.ts @@ -0,0 +1,165 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PassThrough } from "node:stream"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { replLines, repl, runLocalTurn } from "../src/commands/chat.js"; +import { ConsoleShell } from "../src/commands/console_input.js"; +import { ApiClient } from "../src/core/transport.js"; +import { DEFAULT_CONFIG } from "../src/core/config.js"; +import type { AppContext } from "../src/core/context.js"; +import type { TokenStore } from "../src/core/auth.js"; +import type { Brain, TaskCommand } from "../src/core/brain.js"; +import type { BrainEvent } from "../src/core/brain_protocol.js"; +import type { ToolResult } from "../src/core/tool_executor.js"; +import { historyPath } from "../src/core/history_store.js"; + +const supported = process.platform === "linux" || process.platform === "darwin"; +const tokens = { get: async () => "fixture-token" } as unknown as TokenStore; +function context(root: string): AppContext { + return { + cfg: { ...DEFAULT_CONFIG, baseUrl: "https://stub.test", backend: "cloud", defaultModel: "", permissionMode: "ask", autoApply: false }, + flags: { cwd: root, json: true, yes: false, audit: false }, + tokens, api: new ApiClient("https://stub.test", tokens), confirm: async () => false, + } as AppContext; +} +const turnResponse = (): Response => new Response('data: {"type":"delta","text":"answer"}\n\ndata: {"type":"done","uvt":0,"cents":0}\n\n', { headers: { "content-type": "text/event-stream" } }); + +test("line console shell commands make zero model calls, keep output out of prompts/history, and return to chat", { skip: !supported }, async () => { + const root = mkdtempSync(join(tmpdir(), "aether-console-lines-")); + mkdirSync(join(root, "subdir")); + const input = new PassThrough(); + const oldFetch = globalThis.fetch; + const oldWrite = process.stdout.write; + let output = ""; + const bodies: string[] = []; + const shell = new ConsoleShell(root, text => { output += text; }, true); + globalThis.fetch = (async (_url, init) => { bodies.push(String(init?.body ?? "")); return turnResponse(); }) as typeof fetch; + process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; + try { + const run = replLines(context(root), { noSkills: true }, shell, input); + 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.end("a normal question\n/exit\n"); + assert.equal(await run, 0); + assert.equal(bodies.length, 1); + assert.match(bodies[0]!, /a normal question/); + assert.doesNotMatch(bodies[0]!, /local-only|printf|export DEMO/); + assert.match(output, /local-only/); + 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.equal(shell.session.state, "closed"); + } finally { + globalThis.fetch = oldFetch; process.stdout.write = oldWrite; + shell.close(); input.destroy(); rmSync(historyPath(root), { force: true }); rmSync(root, { recursive: true, force: true }); + } +}); + +class ShellBrain implements Brain { + result: ToolResult | null = null; + async *run(_task: TaskCommand): AsyncGenerator { + yield { type: "tool_call", id: "shell-call", name: "run_shell", args: { command: "pwd; printf '%s' \"$DEMO\"" } }; + yield { type: "done", ok: true, result: "done", remaining: 0, reason: "" }; + } + sendToolResult(_id: string, result: ToolResult): void { this.result = result; } + control(): void {} + close(): void {} +} + +test("user command does not authorize model commands; an approved local model uses the same shell", { skip: !supported }, async () => { + const root = mkdtempSync(join(tmpdir(), "aether-console-local-")); + mkdirSync(join(root, "subdir")); + const shell = new ConsoleShell(root, () => {}, true); + const ctx = context(root); + ctx.flags.local = true; + ctx.flags.model = "fixture-model"; + const oldFetch = globalThis.fetch; + globalThis.fetch = (async () => { throw new Error("local shell must not call API"); }) as typeof fetch; + try { + await shell.run({ kind: "shell", command: "cd subdir; export DEMO=shared" }); + const denied = new ShellBrain(); + await runLocalTurn(ctx, "inspect", undefined, { brain: denied, exec: shell.exec }); + assert.match(denied.result!.output, /denied/); + ctx.flags.yes = true; + const approved = new ShellBrain(); + await runLocalTurn(ctx, "inspect", undefined, { brain: approved, exec: shell.exec }); + assert.equal(approved.result!.exitCode, 0); + assert.equal(approved.result!.output, `[exit 0]\n${root}/subdir\nshared`); + } finally { globalThis.fetch = oldFetch; shell.close(); rmSync(root, { recursive: true, force: true }); } +}); + +test("raw TTY queues shell while a model turn is busy, preserves the draft and never sends shell input to the model", { skip: !supported }, async () => { + const root = mkdtempSync(join(tmpdir(), "aether-console-tty-")); + const oldFetch = globalThis.fetch; + const oldWrite = process.stdout.write; + const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const raw = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode"); + let output = ""; + const bodies: string[] = []; + let release: (() => void) | null = null; + let pending: Promise | null = null; + const until = async (predicate: () => boolean): Promise => { + const deadline = Date.now() + 5000; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("TTY test timed out: " + output.slice(-500)); + await new Promise(resolve => setTimeout(resolve, 5)); + } + }; + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stdin, "setRawMode", { value: () => process.stdin, configurable: true }); + process.stdout.write = ((text: string | Uint8Array) => { output += String(text); return true; }) as typeof process.stdout.write; + globalThis.fetch = (async (url, init) => { + if (!String(url).includes("/agent/chat/stream")) return Response.json({ models: [] }); + bodies.push(String(init?.body ?? "")); + await new Promise(resolve => { release = resolve; }); + return turnResponse(); + }) as typeof fetch; + const submit = (text: string): void => { process.stdin.emit("data", Buffer.from(text + "\r")); }; + try { + pending = repl(context(root), { noSkills: true }); + await until(() => output.includes("\x1b[?2004h")); + submit("!printf user-first"); + await until(() => output.includes("shell_result")); + assert.equal(bodies.length, 0); + submit("model question"); + await until(() => release !== null); + submit("!printf queued-once >> queued.txt"); + process.stdin.emit("data", Buffer.from("new draft")); + assert.equal(existsSync(join(root, "queued.txt")), false); + release!(); release = null; + await until(() => output.match(/"type":"shell_result"/g)?.length === 2); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(readFileSync(join(root, "queued.txt"), "utf8"), "queued-once"); + assert.equal(bodies.length, 1); + assert.doesNotMatch(bodies[0]!, /queued-once|user-first|new draft/); + assert.match(output, /new draft/); + // Clear the retained draft before leaving, then exercise shell cancellation. + process.stdin.emit("data", Buffer.from("\x03")); + submit("!sleep 1; touch never-replayed"); + await until(() => output.includes('"command":"sleep 1; touch never-replayed"')); + process.stdin.emit("data", Buffer.from("\x03")); + await until(() => output.includes('"state":"cancelled"')); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(existsSync(join(root, "never-replayed")), false); + submit("/shell-reset"); + await until(() => output.includes('"type":"shell_reset"')); + await new Promise(resolve => setImmediate(resolve)); + submit("/exit"); + assert.equal(await Promise.race([pending, new Promise((_, reject) => setTimeout(() => reject(new Error("TTY exit timed out")), 1000))]), 0); pending = null; + } finally { + (release as (() => void) | null)?.(); + if (pending) { + process.stderr.write("TTY failure capture: " + output.slice(-2000) + "\n"); + process.stdin.emit("data", Buffer.from("\x03\x03\x04")); + await Promise.race([pending.catch(() => {}), new Promise(resolve => setTimeout(resolve, 1000))]); + } + globalThis.fetch = oldFetch; process.stdout.write = oldWrite; + if (tty) Object.defineProperty(process.stdin, "isTTY", tty); else delete (process.stdin as unknown as { isTTY?: boolean }).isTTY; + if (raw) Object.defineProperty(process.stdin, "setRawMode", raw); else delete (process.stdin as unknown as { setRawMode?: unknown }).setRawMode; + rmSync(historyPath(root), { force: true }); rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/git_commit_guard.test.ts b/test/git_commit_guard.test.ts index 9f81f37..925a25a 100644 --- a/test/git_commit_guard.test.ts +++ b/test/git_commit_guard.test.ts @@ -30,7 +30,7 @@ class FakeRunner implements GitRunner { } } -test("repository probes are bounded to the workspace and take no optional locks", () => { +test("dirty probe stays workspace-bounded, staged probe covers the whole commit index, and neither takes locks", () => { // `git status` reports the whole repository regardless of where it runs, and // `--untracked-files=all` enumerates every untracked path individually. A // workspace that is a small directory inside a large repository therefore @@ -39,10 +39,11 @@ test("repository probes are bounded to the workspace and take no optional locks" // pathspec bounds it to the subtree the guard can actually stage from. assert.ok(STATUS_PROBE.includes("--"), "status probe must carry a pathspec"); assert.deepEqual(STATUS_PROBE.slice(-2), ["--", "."]); - assert.deepEqual(STAGED_PROBE.slice(-2), ["--", "."]); + assert.equal(STAGED_PROBE.includes("--"), false, "a commit also consumes staged paths outside a nested workspace"); // Reading a repository must not write to it: without this, merely starting // the agent rewrites the user's index. assert.ok(GIT_GLOBAL_ARGS.includes("--no-optional-locks")); + assert.ok(GIT_GLOBAL_ARGS.includes("--literal-pathspecs")); assert.ok(GIT_GLOBAL_ARGS.includes("core.literalPathspecs=true")); }); diff --git a/test/shell_session.test.ts b/test/shell_session.test.ts new file mode 100644 index 0000000..5355f2a --- /dev/null +++ b/test/shell_session.test.ts @@ -0,0 +1,233 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, symlinkSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { ShellSession, type ShellCommandEvent } from "../src/core/shell_session.js"; +import { ToolExecutor } from "../src/core/tool_executor.js"; +import { classifyConsoleInput } from "../src/commands/console_input.js"; + +const supported = process.platform === "linux" || process.platform === "darwin"; +const quote = (value: string): string => "'" + value.replaceAll("'", "'\\''") + "'"; +function workspace(git = false): { root: string; session: ShellSession; exec: ToolExecutor; events: ShellCommandEvent[]; close: () => void } { + const root = mkdtempSync(join(tmpdir(), "aether-shell-")); + mkdirSync(join(root, "subdir")); + if (git) { + for (const args of [["init", "-q"], ["config", "user.name", "Test"], ["config", "user.email", "test@example.com"]]) { + assert.equal(spawnSync("git", args, { cwd: root }).status, 0); + } + writeFileSync(join(root, "base.txt"), "base"); + spawnSync("git", ["add", "."], { cwd: root }); + spawnSync("git", ["commit", "-qm", "init"], { cwd: root }); + } + const events: ShellCommandEvent[] = []; + const session = new ShellSession(root, event => events.push(event)); + const exec = new ToolExecutor(root, undefined, { mode: "coding", shellSession: session }); + return { root, session, exec, events, close: () => { session.close(); rmSync(root, { recursive: true, force: true }); } }; +} + +test("console classifier preserves shell type, multiline and literal leading !", () => { + assert.deepEqual(classifyConsoleInput(" !cd subdir\nexport DEMO=hello "), { kind: "shell", command: "cd subdir\nexport DEMO=hello" }); + assert.equal(classifyConsoleInput("!").kind, "error"); + assert.deepEqual(classifyConsoleInput(" \\!literal"), { kind: "chat", text: "!literal" }); + assert.deepEqual(classifyConsoleInput("hello!"), { kind: "chat", text: "hello!" }); + assert.deepEqual(classifyConsoleInput("/shell-reset"), { kind: "reset-shell" }); +}); + +test("user and model commands share real cwd, exports/functions; file tools stay root-relative", { skip: !supported }, async () => { + const w = workspace(); + try { + assert.equal((await w.exec.runUserShell("cd subdir\nexport DEMO='hello world'\nf() { printf function; }")).exitCode, 0); + const result = await w.exec.executeAsync("run_shell", { command: "pwd; printf '%s\\n' \"$DEMO\"; f" }); + assert.equal(result.exitCode, 0); + assert.match(result.output, new RegExp(w.root + "/subdir")); + assert.match(result.output, /hello world\nfunction/); + assert.equal(w.session.cwd, join(w.root, "subdir")); + assert.equal((await w.exec.executeAsync("write_file", { path: "root.txt", content: "root" })).exitCode, 0); + assert.equal(readFileSync(join(w.root, "root.txt"), "utf8"), "root"); + assert.equal(existsSync(join(w.root, "subdir", "root.txt")), false); + assert.equal((await w.exec.executeAsync("read_file", { path: "../outside" })).exitCode, 1); + assert.deepEqual(w.events.filter(e => e.state === "running").map(e => e.origin), ["user", "model"]); + assert.equal(new Set(w.events.map(e => e.sessionId)).size, 1); + assert.equal(new Set(w.events.map(e => e.commandId)).size, 2); + } finally { w.close(); } +}); + +test("failed cd and symlink escapes leave cwd intact; explicit builtin escape loses session", { skip: !supported }, async () => { + const w = workspace(); + const outside = mkdtempSync(join(tmpdir(), "aether-outside-")); + try { + symlinkSync(outside, join(w.root, "escape")); + await w.exec.runUserShell("cd subdir"); + for (const command of ["cd missing", "cd ..; cd escape", `cd ${quote(outside)}`]) { + assert.equal((await w.exec.runUserShell(command)).exitCode, 1); + } + assert.equal(w.session.cwd, w.root); + const escaped = await w.exec.runUserShell(`builtin cd ${quote(outside)}`); + assert.equal(escaped.exitCode, 1); + assert.match(escaped.output, /outside approved workspace/); + assert.equal(w.session.state, "lost"); + } finally { w.close(); rmSync(outside, { recursive: true, force: true }); } +}); + +test("simultaneous user/model commands and file writes serialize, output drains before completion", { skip: !supported }, async () => { + const w = workspace(); + try { + const a = w.exec.runUserShell("sleep 0.05; cd subdir; export ORDER=first; printf user"); + const b = w.exec.executeAsync("run_shell", { command: "printf '%s' \"$ORDER\"; pwd; printf stderr >&2" }); + const c = w.exec.executeAsync("write_file", { path: "serial.txt", content: "serial" }); + const [user, model, file] = await Promise.all([a, b, c]); + assert.match(user.output, /user/); + assert.match(model.output, /first/); + assert.match(model.output, /subdir/); + assert.match(model.output, /stderr/); + assert.equal(file.exitCode, 0); + assert.deepEqual(w.events.map(e => e.state), ["running", "completed", "running", "completed"]); + const big = await w.exec.runUserShell("printf '%100000s' x; printf tail >&2"); + assert.equal(big.exitCode, 0); + assert.ok(big.output.length <= 8020); + const next = await w.exec.runUserShell("printf clean"); + assert.equal(next.output, "[exit 0]\nclean"); + } finally { w.close(); } +}); + +test("crash and cancellation lose state visibly, reap descendants, and never replay", { skip: !supported }, async () => { + const w = workspace(); + try { + await w.exec.runUserShell("export DEMO=old; cd subdir"); + const crashed = await w.exec.runUserShell("printf before-crash; kill -KILL $$"); + assert.equal(crashed.exitCode, 1); + assert.match(crashed.output, /state lost/); + assert.match(crashed.output, /before-crash/); + assert.equal((await w.exec.runUserShell("touch replayed")).exitCode, 1); + w.session.reset(); + assert.equal((await w.exec.runUserShell("printf '%s' \"${DEMO-unset}\"; pwd")).output, `[exit 0]\nunset${w.root}\n`); + const abort = new AbortController(); + const pending = w.exec.runUserShell("sleep 0.5; touch late", { signal: abort.signal }); + setTimeout(() => abort.abort(), 30); + const cancelled = await pending; + assert.equal(cancelled.exitCode, 130); + assert.equal(w.session.state, "lost"); + assert.equal((await w.exec.runUserShell("touch late")).exitCode, 1); + await new Promise(resolve => setTimeout(resolve, 550)); + assert.equal(existsSync(join(w.root, "late")), false); + assert.equal(existsSync(join(w.root, "subdir", "replayed")), false); + w.session.reset(); + const timeout = await w.exec.runUserShell("sleep 1", { timeoutMs: 10 }); + assert.equal(timeout.exitCode, 124); + assert.match(timeout.output, /timed out/); + } finally { w.close(); } +}); + +test("queued cancellation and stale approvals execute nothing", { skip: !supported }, async () => { + const w = workspace(); + try { + const approval = w.exec.shellContext; + await w.exec.runUserShell("cd subdir"); + const stale = await w.exec.executeAsync("run_shell", { command: "touch stale" }, { expectedShellContext: approval }); + assert.equal(stale.exitCode, 1); + assert.match(stale.output, /fresh approval/); + const a = w.exec.runUserShell("sleep 0.05"); + const abort = new AbortController(); + const b = w.exec.runUserShell("touch cancelled", { signal: abort.signal }); + abort.abort(); + await a; + assert.equal((await b).exitCode, 130); + assert.equal(existsSync(join(w.root, "subdir", "cancelled")), false); + } finally { w.close(); } +}); + +test("reset invalidates queued commands; a fresh project/worktree never inherits exports", { skip: !supported }, async () => { + const w = workspace(); + const other = workspace(); + try { + await w.exec.runUserShell("export PROJECT=old; cd subdir"); + assert.equal((await other.exec.runUserShell("printf '%s' \"${PROJECT-unset}\"; pwd")).output, `[exit 0]\nunset${other.root}\n`); + const a = w.exec.runUserShell("sleep 1"); + const b = w.exec.runUserShell("touch old-queue"); + await new Promise(resolve => setTimeout(resolve, 20)); + w.session.reset(); + assert.equal((await a).exitCode, 1); + assert.equal((await b).exitCode, 1); + assert.equal((await w.exec.runUserShell("pwd")).output, `[exit 0]\n${w.root}\n`); + assert.equal(existsSync(join(w.root, "old-queue")), false); + } finally { w.close(); other.close(); } +}); + +test("automatic commit excludes unrelated user edits and mixed files between turns", { skip: !supported }, async () => { + const w = workspace(true); + try { + await w.exec.executeAsync("write_file", { path: "agent.txt", content: "agent" }); + await w.exec.executeAsync("write_file", { path: "mixed.txt", content: "agent" }); + await w.exec.runUserShell("printf user > user.txt; printf user >> mixed.txt"); + writeFileSync(join(w.root, "external.txt"), "external editor"); + writeFileSync(join(w.root, "agent.txt"), "external editor touched agent file"); + await w.exec.executeAsync("write_file", { path: "only-agent.txt", content: "owned" }); + await w.exec.executeAsync("write_file", { path: "mixed.txt", content: "model again" }); + const committed = await w.exec.executeAsync("git_commit", { message: "only own paths" }); + assert.equal(committed.exitCode, 0, committed.output); + const paths = spawnSync("git", ["show", "--pretty=", "--name-only", "HEAD"], { cwd: w.root, encoding: "utf8" }).stdout.trim().split("\n"); + assert.deepEqual(paths, ["only-agent.txt"]); + const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: w.root, encoding: "utf8" }).stdout; + for (const file of ["mixed.txt", "user.txt", "external.txt", "agent.txt"]) assert.match(dirty, new RegExp(file)); + } finally { w.close(); } +}); + +test("branch/worktree switches reset shell and commit baseline; session root cannot be swapped", { skip: !supported }, async () => { + const w = workspace(true); + const worktree = join(tmpdir(), `aether-shell-worktree-${Date.now()}`); + try { + await w.exec.runUserShell("cd subdir; export PROJECT=old"); + const switched = await w.exec.runUserShell("git switch -c new-project"); + assert.equal(switched.exitCode, 0); + assert.match(switched.output, /shell cwd\/environment\/functions reset/); + assert.equal(w.session.cwd, w.root); + assert.equal((await w.exec.runUserShell("printf '%s' \"${PROJECT-unset}\"; pwd")).output, `[exit 0]\nunset${w.root}\n`); + assert.equal(spawnSync("git", ["worktree", "add", "-b", "isolated", worktree], { cwd: w.root }).status, 0); + const isolated = new ShellSession(worktree); + const exec = new ToolExecutor(worktree, undefined, { mode: "coding", shellSession: isolated }); + try { + assert.equal((await exec.runUserShell("printf '%s' \"${PROJECT-unset}\"; pwd")).output, `[exit 0]\nunset${worktree}\n`); + assert.throws(() => new ToolExecutor(w.root, undefined, { mode: "coding", shellSession: isolated }), /belong/); + } finally { isolated.close(); } + spawnSync("git", ["switch", "new-project"], { cwd: w.root }); + const context = w.exec.shellContext; + spawnSync("git", ["switch", "-c", "external-switch"], { cwd: w.root }); + const refused = await w.exec.executeAsync("run_shell", { command: "touch should-not-run" }, { expectedShellContext: context }); + assert.equal(refused.exitCode, 1); + assert.match(refused.output, /checkout changed/); + assert.equal(existsSync(join(w.root, "should-not-run")), false); + } finally { w.close(); rmSync(worktree, { recursive: true, force: true }); } +}); + +test("shell credentials/rc environment never comes from the parent; exports stay local", { skip: !supported }, async () => { + const prior = process.env["AETHER_TEST_SECRET_TOKEN"]; + process.env["AETHER_TEST_SECRET_TOKEN"] = "fixture-only-secret"; + const w = workspace(); + try { + const result = await w.exec.runUserShell("printf '%s' \"${AETHER_TEST_SECRET_TOKEN-unset}\"; export LOCAL_ONLY=yes"); + assert.equal(result.output, "[exit 0]\nunset"); + assert.equal(process.env["LOCAL_ONLY"], undefined); + } finally { + if (prior === undefined) delete process.env["AETHER_TEST_SECRET_TOKEN"]; else process.env["AETHER_TEST_SECRET_TOKEN"] = prior; + w.close(); + } +}); + +test("nested workspace commits use repo-relative names and refuse staged user paths anywhere in the index", { skip: !supported }, async () => { + const w = workspace(true); + const nested = new ToolExecutor(join(w.root, "subdir")); + try { + assert.equal(nested.execute("write_file", { path: "agent.txt", content: "owned" }).exitCode, 0); + writeFileSync(join(w.root, "outside.txt"), "user"); + spawnSync("git", ["add", "outside.txt"], { cwd: w.root }); + const refused = nested.execute("git_commit", { message: "must not sweep outside index" }); + assert.equal(refused.exitCode, 1); + assert.match(refused.output, /unexpected staged/); + spawnSync("git", ["reset", "--", "outside.txt"], { cwd: w.root }); + const committed = nested.execute("git_commit", { message: "own nested path" }); + assert.equal(committed.exitCode, 0, committed.output); + assert.equal(spawnSync("git", ["show", "--pretty=", "--name-only", "HEAD"], { cwd: w.root, encoding: "utf8" }).stdout.trim(), "subdir/agent.txt"); + } finally { w.close(); } +});