feat(core,tooling): Windows support — shell selection, install.ps1, win-x64 release package, Windows CI (#79)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:e2e": "PENGUIN_E2E=1 vitest run test/llm.e2e.test.ts",
|
||||
"test:e2e": "node ../../scripts/run-with-env.mjs PENGUIN_E2E=1 -- vitest run test/llm.e2e.test.ts",
|
||||
"build": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
export { CommandSessionManager } from "./session-manager.js";
|
||||
export { ManagedSession, resultForExit } from "./session.js";
|
||||
export type { ProcessExit, SpawnOptions } from "./session.js";
|
||||
export { resolveShell, sessionShell } from "./shell.js";
|
||||
export type { ShellInvocation, ResolveShellOptions } from "./shell.js";
|
||||
export {
|
||||
DEFAULT_EXEC_YIELD_MS,
|
||||
DEFAULT_WRITE_YIELD_MS,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* ManagedSession — runtime state and collection logic for a single command session.
|
||||
*
|
||||
* Spawns the process with `bash -lc <cmd>`, with stdout/stderr going through plain pipes (no
|
||||
* Spawns the process with `bash -lc <cmd>` (on Windows, the shell picked by `sessionShell()`
|
||||
* — see shell.ts), with stdout/stderr going through plain pipes (no
|
||||
* native dependency, clean output; an interactive program that detects no TTY falls back to
|
||||
* non-interactive mode, which parses more cleanly for the Agent anyway). `detached` makes the
|
||||
* child process the process-group leader, so both Ctrl-C and killing the whole group rely on
|
||||
* **process-group signals** (sending a signal to `-pid` also reaches background child processes).
|
||||
* Windows has neither process groups nor real signals: every "signal" degrades to a hard
|
||||
* TerminateProcess, and tree-wide cleanup goes through `taskkill /t` instead (see signalGroup).
|
||||
*
|
||||
* Key semantics:
|
||||
* - **Termination is determined by the foreground process exiting (the exit event, waitpid
|
||||
@@ -22,9 +25,10 @@
|
||||
* - `kill()` sends SIGTERM to the process group, then SIGKILL after a grace period, reaping any
|
||||
* leftover background child processes; idempotent.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import type { ToolResult } from "../types.js";
|
||||
import { CappedTextBuffer, WakeSignal } from "../background/index.js";
|
||||
import { sessionShell } from "./shell.js";
|
||||
|
||||
/** Process-group semantics are available on POSIX; Windows falls back to signaling the child process directly. */
|
||||
const SUPPORTS_PROCESS_GROUP = process.platform !== "win32";
|
||||
@@ -48,7 +52,7 @@ export interface ProcessExit {
|
||||
|
||||
/** Arguments required to start a command. */
|
||||
export interface SpawnOptions {
|
||||
/** Command string handed to `bash -lc`. */
|
||||
/** Command string handed to the session shell (`bash -lc` on POSIX; see shell.ts for Windows). */
|
||||
cmd: string;
|
||||
/** Working directory (absolute path). */
|
||||
cwd: string;
|
||||
@@ -71,11 +75,13 @@ export class ManagedSession {
|
||||
private readonly wakeSignal = new WakeSignal();
|
||||
|
||||
constructor(opts: SpawnOptions) {
|
||||
this.child = spawn("bash", ["-lc", opts.cmd], {
|
||||
const shell = sessionShell();
|
||||
this.child = spawn(shell.command, [...shell.args, opts.cmd], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
detached: SUPPORTS_PROCESS_GROUP, // Become the process-group leader, so the whole group can be signaled
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true, // No flashing console window on Windows (ignored elsewhere)
|
||||
});
|
||||
this.child.stdout?.setEncoding("utf8");
|
||||
this.child.stderr?.setEncoding("utf8");
|
||||
@@ -92,11 +98,26 @@ export class ManagedSession {
|
||||
this.child.on("error", (err) => this.handleError(err));
|
||||
}
|
||||
|
||||
/** Signals the process group; ignores the case where the process/group has already exited (ESRCH). */
|
||||
private signalGroup(sig: NodeJS.Signals): void {
|
||||
/**
|
||||
* Signals the process group; ignores the case where the process/group has already exited (ESRCH).
|
||||
*
|
||||
* Windows has no signals: `child.kill()` is an unconditional TerminateProcess of the direct
|
||||
* child only, which would orphan grandchildren (a `node server.js` started by the shell).
|
||||
* Every signal therefore becomes a hard kill of the whole tree via `taskkill /t /f` — including
|
||||
* SIGINT: without a shared console there is no way to deliver a real Ctrl-C to a piped child,
|
||||
* so input_command's Ctrl-C degrades to this hard kill on Windows. `sync` uses spawnSync for
|
||||
* the process-'exit' fallback, where the event loop is no longer running.
|
||||
*/
|
||||
private signalGroup(sig: NodeJS.Signals, sync = false): void {
|
||||
try {
|
||||
if (SUPPORTS_PROCESS_GROUP && typeof this.child.pid === "number" && this.child.pid > 0) {
|
||||
process.kill(-this.child.pid, sig); // Negative pid = the whole process group
|
||||
} else if (
|
||||
process.platform === "win32" &&
|
||||
typeof this.child.pid === "number" &&
|
||||
this.child.pid > 0
|
||||
) {
|
||||
this.killTreeWindows(this.child.pid, sync);
|
||||
} else {
|
||||
this.child.kill(sig);
|
||||
}
|
||||
@@ -105,6 +126,26 @@ export class ManagedSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hard-kills the whole process tree on Windows; falls back to child.kill() if taskkill itself fails. */
|
||||
private killTreeWindows(pid: number, sync: boolean): void {
|
||||
const args = ["/pid", String(pid), "/t", "/f"];
|
||||
if (sync) {
|
||||
const res = spawnSync("taskkill", args, { stdio: "ignore", windowsHide: true });
|
||||
if (res.error || res.status !== 0) this.child.kill();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const killer = spawn("taskkill", args, { stdio: "ignore", windowsHide: true });
|
||||
// taskkill missing or failing (e.g. restricted environment): still terminate the direct child.
|
||||
killer.on("error", () => this.child.kill());
|
||||
killer.on("exit", (code) => {
|
||||
if (code !== 0 && !this.exited) this.child.kill();
|
||||
});
|
||||
} catch {
|
||||
this.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
private handleData(chunk: string): void {
|
||||
this.buffer.append(chunk);
|
||||
this.wakeSignal.notify();
|
||||
@@ -191,6 +232,7 @@ export class ManagedSession {
|
||||
// stdin may already be closed, ignored.
|
||||
}
|
||||
}
|
||||
/** Ctrl-C. POSIX: SIGINT to the process group; Windows: degrades to a hard tree kill (see signalGroup). */
|
||||
interrupt(): void {
|
||||
this.lastUsed = Date.now();
|
||||
this.signalGroup("SIGINT");
|
||||
@@ -214,7 +256,7 @@ export class ManagedSession {
|
||||
clearTimeout(this.killTimer);
|
||||
this.killTimer = null;
|
||||
}
|
||||
this.signalGroup("SIGKILL");
|
||||
this.signalGroup("SIGKILL", true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Shell selection for command sessions.
|
||||
*
|
||||
* POSIX behavior is unchanged: commands run via `bash -lc <cmd>`. On Windows there is no
|
||||
* bash by default, so the resolver picks the best available shell once per process:
|
||||
*
|
||||
* 1. `PENGUIN_SHELL` (explicit executable name or path) always wins, on every platform;
|
||||
* the argument shape is inferred from its basename (see below).
|
||||
* 2. Otherwise, non-Windows uses `bash -lc` (today's behavior, bit for bit).
|
||||
* 3. On Windows, probe PATH for `bash` (Git for Windows — best compatibility with the
|
||||
* skill/prompt ecosystem, which is written for a POSIX shell), then `pwsh`
|
||||
* (PowerShell 7+), then fall back to `powershell` (Windows PowerShell 5.1, always
|
||||
* present). A `bash` that resolves into the Windows system directory is ignored: that
|
||||
* is the WSL launcher, which runs commands inside a Linux distro with a different
|
||||
* filesystem view (and fails outright when no distro is configured).
|
||||
*
|
||||
* Argument shapes by basename (also applied to `PENGUIN_SHELL` values):
|
||||
* - `pwsh` / `powershell` -> `-NoLogo -NoProfile -Command <cmd>`
|
||||
* - `cmd` -> `/d /s /c <cmd>`
|
||||
* - anything else -> `-lc <cmd>` (bash/zsh/sh-style login shell)
|
||||
*
|
||||
* The resolved shell's name is surfaced to the model via the session environment (the
|
||||
* `Shell:` line in the system prompt), so it knows which syntax the exec tool speaks.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
/** A resolved shell invocation: `spawn(command, [...args, cmd])` runs `cmd` in that shell. */
|
||||
export interface ShellInvocation {
|
||||
/** Executable name or absolute path handed to spawn(). */
|
||||
command: string;
|
||||
/** Fixed argument prefix placed before the command string. */
|
||||
args: string[];
|
||||
/** Short lowercase name (basename without extension), shown to the model (e.g. "bash", "pwsh"). */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Injection points for unit tests; production callers use the defaults. */
|
||||
export interface ResolveShellOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** Returns the PATH resolutions of an executable name, best match first ([] when not found). */
|
||||
whichAll?: (cmd: string) => string[];
|
||||
/** The Windows system root (to recognize the WSL bash launcher); default `env.SystemRoot` or C:\Windows. */
|
||||
systemRoot?: string;
|
||||
}
|
||||
|
||||
/** Basename without a trailing .exe/.cmd/.bat/.ps1 extension, lowercased ("C:\...\pwsh.EXE" -> "pwsh"). */
|
||||
function shellBasename(command: string): string {
|
||||
// path.win32 handles both separators, so PENGUIN_SHELL=/usr/bin/zsh still yields "zsh".
|
||||
return path.win32
|
||||
.basename(command)
|
||||
.replace(/\.(exe|cmd|bat|ps1)$/i, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Argument prefix for a shell, chosen by its basename (PowerShell-style vs cmd vs POSIX-style). */
|
||||
function argsForShell(name: string): string[] {
|
||||
if (name === "pwsh" || name === "powershell") return ["-NoLogo", "-NoProfile", "-Command"];
|
||||
if (name === "cmd") return ["/d", "/s", "/c"];
|
||||
return ["-lc"];
|
||||
}
|
||||
|
||||
/** Default PATH probe: `where` lists every match line by line, in PATH order (win32 only). */
|
||||
function defaultWhichAll(cmd: string): string[] {
|
||||
try {
|
||||
const res = spawnSync("where", [cmd], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (res.status !== 0 || !res.stdout) return [];
|
||||
return res.stdout
|
||||
.toString("utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the shell for command sessions (pure given its options; see the module comment
|
||||
* for the order). Exported for unit tests; runtime code uses the cached `sessionShell()`.
|
||||
*/
|
||||
export function resolveShell(opts: ResolveShellOptions = {}): ShellInvocation {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
const env = opts.env ?? process.env;
|
||||
|
||||
const explicit = env.PENGUIN_SHELL?.trim();
|
||||
if (explicit) {
|
||||
const name = shellBasename(explicit);
|
||||
return { command: explicit, args: argsForShell(name), name };
|
||||
}
|
||||
|
||||
if (platform !== "win32") {
|
||||
return { command: "bash", args: ["-lc"], name: "bash" };
|
||||
}
|
||||
|
||||
const whichAll = opts.whichAll ?? defaultWhichAll;
|
||||
const systemRoot = opts.systemRoot ?? env.SystemRoot ?? "C:\\Windows";
|
||||
// The WSL launcher lives in <SystemRoot>\System32 (or Sysnative under WOW64); a Git for
|
||||
// Windows bash lives under the Git install dir. Only the first PATH match counts — that
|
||||
// is the one spawn("bash") would run.
|
||||
const bash = whichAll("bash")[0];
|
||||
if (bash && !bash.toLowerCase().startsWith(systemRoot.toLowerCase() + path.win32.sep)) {
|
||||
return { command: "bash", args: ["-lc"], name: "bash" };
|
||||
}
|
||||
if (whichAll("pwsh").length > 0) {
|
||||
return { command: "pwsh", args: argsForShell("pwsh"), name: "pwsh" };
|
||||
}
|
||||
return { command: "powershell", args: argsForShell("powershell"), name: "powershell" };
|
||||
}
|
||||
|
||||
let cached: ShellInvocation | null = null;
|
||||
|
||||
/** The process-wide shell for command sessions; resolved once (probing PATH costs a subprocess on Windows). */
|
||||
export function sessionShell(): ShellInvocation {
|
||||
if (!cached) cached = resolveShell();
|
||||
return cached;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import path from "node:path";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
|
||||
import { formatLocalDate } from "./dates.js";
|
||||
import { sessionShell } from "../environment/tools/command/shell.js";
|
||||
import type { SessionEnvironmentValues } from "../state/agent-state.js";
|
||||
import { workspacesDir } from "../state/index.js";
|
||||
import { userText } from "../omnimessage/index.js";
|
||||
@@ -47,6 +48,9 @@ export function sessionEnvironment(
|
||||
modelId: ids.modelId,
|
||||
platform: process.platform,
|
||||
osVersion: getOsVersion(),
|
||||
// The shell exec_command actually runs (bash on POSIX; resolved on Windows): the model
|
||||
// must know whether to write bash or PowerShell syntax.
|
||||
shell: sessionShell().name,
|
||||
date: formatLocalDate(date),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
PLATFORM_PLACEHOLDER,
|
||||
PROJECT_DIR_PLACEHOLDER,
|
||||
SESSION_ID_PLACEHOLDER,
|
||||
SHELL_PLACEHOLDER,
|
||||
type SystemConfig,
|
||||
} from "./default-config.js";
|
||||
import { builtinProjectAgentPresets, type AgentPreset } from "./builtin-agents.js";
|
||||
@@ -92,6 +93,8 @@ export interface SessionEnvironmentValues {
|
||||
modelId: string;
|
||||
platform: string;
|
||||
osVersion: string;
|
||||
/** The shell command sessions run in (system Prompt placeholder {{SHELL}}; e.g. "bash", "pwsh") — tells the model which command syntax exec_command speaks. */
|
||||
shell: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -383,6 +386,43 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows compatibility fallback for pre-`{{SHELL}}` system prompt templates.
|
||||
*
|
||||
* `system_config.yaml` is baked at Agent creation and never auto-upgraded, so an Agent created
|
||||
* before the `{{SHELL}}` placeholder existed never tells its model which shell `exec_command`
|
||||
* speaks — and on Windows (where the shell may be PowerShell, not bash) the model then keeps
|
||||
* emitting bash syntax into the wrong shell. When the platform is win32 and the template carries
|
||||
* no `{{SHELL}}` placeholder, inject a `- Shell: <shell>` line into the assembled prompt at
|
||||
* render time: right after the Environment section heading when one exists, else appended as a
|
||||
* minimal final line. In-memory only — the stored template is never rewritten. On POSIX the
|
||||
* assembled prompt stays byte-identical (bash was always implied there), and a prompt that
|
||||
* already carries the exact line is left untouched (idempotent).
|
||||
*
|
||||
* Retirement condition: remove this fallback once pre-`{{SHELL}}` Agent configs (created before
|
||||
* the placeholder shipped in PR #79) are no longer expected in the wild.
|
||||
*/
|
||||
function withShellLineFallback(
|
||||
assembled: string,
|
||||
template: string,
|
||||
sessionEnvironment?: SessionEnvironmentValues,
|
||||
): string {
|
||||
if (sessionEnvironment?.platform !== "win32") return assembled;
|
||||
if (!sessionEnvironment.shell) return assembled;
|
||||
if (template.includes(SHELL_PLACEHOLDER)) return assembled; // The template already renders the line itself.
|
||||
// Any existing `- Shell:` line means the model is already told a shell — including a custom
|
||||
// template hardcoding a different value on purpose; never add a second, contradicting line.
|
||||
if (/^- Shell: /m.test(assembled)) return assembled;
|
||||
const line = `- Shell: ${sessionEnvironment.shell}`;
|
||||
// The default templates head the section with `# Environment`; accept any heading level.
|
||||
const heading = /^#+ Environment[ \t]*$/m.exec(assembled);
|
||||
if (heading) {
|
||||
const insertAt = heading.index + heading[0].length;
|
||||
return `${assembled.slice(0, insertAt)}\n${line}${assembled.slice(insertAt)}`;
|
||||
}
|
||||
return `${assembled}\n${line}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the complete runtime system Prompt: substitutes `AGENTS.md`, vault key names, Skill
|
||||
* metadata, and the concrete Session runtime environment placeholders into the system Prompt
|
||||
@@ -390,7 +430,8 @@ export function skillMetadataSection(skills: SkillMetadata[]): string {
|
||||
* wrapper text such as `[developer_instructions]` and the # Vault / # Skills statements are
|
||||
* written directly into the system Prompt template itself (the Prompt is fully
|
||||
* transparent and editable via `system_config.yaml`). Other files in Agent State / Workspace are
|
||||
* never auto-injected.
|
||||
* never auto-injected. Sole exception: on win32 a template without `{{SHELL}}` gets a `- Shell:`
|
||||
* line injected at render time (see `withShellLineFallback`).
|
||||
*
|
||||
* `{{VAULT_KEYS}}` is replaced with the vault key-name list (an empty string if empty/not
|
||||
* provided): this lets the model know which APIs requiring a key it can call; values are never
|
||||
@@ -406,7 +447,8 @@ export function assembleSystemPrompt(
|
||||
vaultKeys?: string[],
|
||||
skillMetadata?: SkillMetadata[],
|
||||
): string {
|
||||
return state.systemConfig.system_prompt
|
||||
const template = state.systemConfig.system_prompt;
|
||||
const assembled = template
|
||||
.split(AGENTS_MD_PLACEHOLDER)
|
||||
.join(state.agentsMd.trim())
|
||||
.split(VAULT_KEYS_PLACEHOLDER)
|
||||
@@ -429,9 +471,12 @@ export function assembleSystemPrompt(
|
||||
.join(sessionEnvironment?.platform ?? "")
|
||||
.split(OS_VERSION_PLACEHOLDER)
|
||||
.join(sessionEnvironment?.osVersion ?? "")
|
||||
.split(SHELL_PLACEHOLDER)
|
||||
.join(sessionEnvironment?.shell ?? "")
|
||||
.split(DATE_PLACEHOLDER)
|
||||
.join(sessionEnvironment?.date ?? "")
|
||||
.trim();
|
||||
return withShellLineFallback(assembled, template, sessionEnvironment);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,8 @@ export const PROVIDER_PLACEHOLDER = "{{PROVIDER}}";
|
||||
export const MODEL_ID_PLACEHOLDER = "{{MODEL_ID}}";
|
||||
export const PLATFORM_PLACEHOLDER = "{{PLATFORM}}";
|
||||
export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}";
|
||||
/** The shell exec_command runs (`bash` on POSIX; on Windows whatever shell.ts resolved), so the model knows which command syntax to write. */
|
||||
export const SHELL_PLACEHOLDER = "{{SHELL}}";
|
||||
export const DATE_PLACEHOLDER = "{{DATE}}";
|
||||
|
||||
/**
|
||||
@@ -147,6 +149,7 @@ Skills are reusable instruction packages stored under <app_data_dir>/agents/<age
|
||||
# Environment
|
||||
- Platform: {{PLATFORM}}
|
||||
- OS Version: {{OS_VERSION}}
|
||||
- Shell: {{SHELL}}
|
||||
- Date: {{DATE}}
|
||||
- App Data Dir: {{PROJECT_DIR}}
|
||||
- Agent ID: {{AGENT_ID}}
|
||||
|
||||
@@ -126,6 +126,7 @@ describe("App Data Dir / Agent ID placeholders", () => {
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "linux",
|
||||
osVersion: "test",
|
||||
shell: "bash",
|
||||
date: "2026-07-08",
|
||||
});
|
||||
expect(prompt).toContain("Agent ID: env_agent");
|
||||
|
||||
@@ -132,8 +132,10 @@ describe("ContextEngine ReAct loop (mock LLM, approve callback)", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(traces, { recursive: true, force: true });
|
||||
// Retries (here and in the other cleanups below): on Windows a just-killed process tree
|
||||
// releases its cwd locks asynchronously, so an immediate rm can hit EBUSY.
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it("approves a tool call, writes the file, returns the final answer, traces it", async () => {
|
||||
@@ -733,7 +735,7 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", (
|
||||
workspace = await mkdtemp(join(tmpdir(), "penguin-ws2-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it("emits both tool calls in one round; second is approved while the first executes; outputs come back in completion order", async () => {
|
||||
@@ -809,7 +811,13 @@ describe("ContextEngine async/incremental tool calls (overlapping execution)", (
|
||||
// command has not finished yet) -- i.e., execution does not block the next approval.
|
||||
expect(approvedAt["t2"]!).toBeLessThan(firstCompleteAt["t1"] ?? Infinity);
|
||||
// The fast b.txt finishes first, the slow a.txt finishes later (outputs in completion order).
|
||||
expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!);
|
||||
// POSIX only: on Windows a cold Git-Bash spawn costs 1-2s, which can swamp the 400ms sleep
|
||||
// delta that makes t1 "the slow one" — CI has seen the two complete within 5ms — so the
|
||||
// relative completion order is not controllable there. The overlap assertion above and the
|
||||
// file contents below still run on Windows.
|
||||
if (process.platform !== "win32") {
|
||||
expect(firstCompleteAt["t2"]!).toBeLessThan(firstCompleteAt["t1"]!);
|
||||
}
|
||||
|
||||
expect(await readFile(join(workspace, "a.txt"), "utf8")).toBe("one");
|
||||
expect(await readFile(join(workspace, "b.txt"), "utf8")).toBe("two");
|
||||
@@ -895,7 +903,7 @@ describe("ContextEngine tool execution resilience", () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it("feeds a failed tool output back and keeps tool_use/result paired (Environment converges errors, never throws)", async () => {
|
||||
@@ -1105,7 +1113,7 @@ describe("ContextEngine abort during execution", () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "penguin-ws3-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it("aborting a long-running tool ends the turn, emits abort, and carries tool results over (model output completed)", async () => {
|
||||
@@ -1255,7 +1263,7 @@ describe("ContextEngine LLM timeout / network interruption (PRN-012)", () => {
|
||||
workspace = await mkdtemp(join(tmpdir(), "penguin-ws4-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it("auto-retries on LLM timeout: original input + [turn_retried] carrying partial products", async () => {
|
||||
@@ -1792,8 +1800,8 @@ describe("ContextEngine mid-run steering ([user_steering])", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(traces, { recursive: true, force: true });
|
||||
await rm(workspace, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
await rm(traces, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
/** Fake environment: streams a delta then closes with a fixed complete output (no real shell). */
|
||||
|
||||
@@ -67,7 +67,9 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
// Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously,
|
||||
// so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff.
|
||||
await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
describe("Environment.listTools", () => {
|
||||
@@ -485,9 +487,12 @@ describe("Environment.executeTool — relaxed tool contract", () => {
|
||||
|
||||
describe("Environment.executeTool — timeoutMs (PRN-013)", () => {
|
||||
it("fails a tool exceeding timeoutMs, keeps prior output, and streams the timeout reason", async () => {
|
||||
// The timeout must stay below MIN_YIELD_MS (250): for larger values exec_command yields to
|
||||
// background (with a process_id) before the Environment timeout can ever fire.
|
||||
const timeoutMs = 200;
|
||||
const env = new Environment({
|
||||
workspaceDir: tmp,
|
||||
toolConfig: makeToolConfig(execTool({ timeoutMs: 200 })),
|
||||
toolConfig: makeToolConfig(execTool({ timeoutMs })),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -513,8 +518,13 @@ describe("Environment.executeTool — timeoutMs (PRN-013)", () => {
|
||||
};
|
||||
expect(last.type).toBe("tool_call_output");
|
||||
expect(last.stop_reason).toBe("failed");
|
||||
expect(last.output).toContain("begin");
|
||||
expect(last.output).toContain("[tool timeout: exceeded 200ms]");
|
||||
// Kept-prior-output is asserted only where the shell can win the race: a Git-Bash login
|
||||
// shell on Windows needs several hundred ms to start, so nothing is printed before a
|
||||
// sub-250ms timeout there — the timeout mechanics above are still fully exercised.
|
||||
if (process.platform !== "win32") {
|
||||
expect(last.output).toContain("begin");
|
||||
}
|
||||
expect(last.output).toContain(`[tool timeout: exceeded ${timeoutMs}ms]`);
|
||||
// The timeout marker is also produced via streaming: concatenating the streamed deltas ==
|
||||
// the complete content.
|
||||
const streamed = messages
|
||||
@@ -735,14 +745,14 @@ describe("Environment.executeTool — robustness", () => {
|
||||
describe("Environment.toolPermission", () => {
|
||||
it("returns the configured permission for a known tool", () => {
|
||||
const env = new Environment({
|
||||
workspaceDir: "/tmp",
|
||||
workspaceDir: tmpdir(),
|
||||
toolConfig: makeToolConfig(execTool({ permission: "rw" })),
|
||||
});
|
||||
expect(env.toolPermission("exec_command")).toBe("rw");
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown tool", () => {
|
||||
const env = new Environment({ workspaceDir: "/tmp", toolConfig: makeToolConfig() });
|
||||
const env = new Environment({ workspaceDir: tmpdir(), toolConfig: makeToolConfig() });
|
||||
expect(env.toolPermission("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,9 @@ beforeEach(async () => {
|
||||
|
||||
afterEach(async () => {
|
||||
env.dispose();
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
// Retries: on Windows a just-killed process tree releases its cwd/file locks asynchronously,
|
||||
// so an immediate recursive rm can hit EBUSY; fs.rm retries those with a linear backoff.
|
||||
await rm(tmp, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
describe("exec_command — long-running command sessions", () => {
|
||||
|
||||
@@ -514,15 +514,19 @@ describe("edit_file — review follow-ups", () => {
|
||||
describe("write_file — review follow-ups", () => {
|
||||
const tool = () => createWriteFileTool(def(WRITE_FILE_NAME, "rw"));
|
||||
|
||||
it("preserves the permission bits of an overwritten file (atomic rename)", async () => {
|
||||
const file = path.join(tmp, "mode.txt");
|
||||
await writeFile(file, "old");
|
||||
await chmod(file, 0o600);
|
||||
const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(file, "utf8")).toBe("new");
|
||||
});
|
||||
// POSIX-only: Windows has no owner-only mode bits to preserve (chmod maps to the read-only attribute).
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves the permission bits of an overwritten file (atomic rename)",
|
||||
async () => {
|
||||
const file = path.join(tmp, "mode.txt");
|
||||
await writeFile(file, "old");
|
||||
await chmod(file, 0o600);
|
||||
const { result } = await run(tool(), { file_path: "mode.txt", content: "new" }, tmp);
|
||||
expect(result?.stopReason).toBeUndefined();
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(file, "utf8")).toBe("new");
|
||||
},
|
||||
);
|
||||
|
||||
it("writes atomically: no temp files are left behind", async () => {
|
||||
await run(tool(), { file_path: "fresh.txt", content: "x" }, tmp);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Unit tests for the command-session shell resolver (pure function; platform, env and the
|
||||
* PATH probe are injected — no real shells are spawned here).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveShell } from "../src/environment/tools/command/shell.js";
|
||||
|
||||
const POWERSHELL_ARGS = ["-NoLogo", "-NoProfile", "-Command"];
|
||||
|
||||
/** A whichAll stub resolving only the given names (value = returned PATH matches). */
|
||||
function which(table: Record<string, string[]>): (cmd: string) => string[] {
|
||||
return (cmd) => table[cmd] ?? [];
|
||||
}
|
||||
|
||||
describe("resolveShell — POSIX", () => {
|
||||
it("uses bash -lc on linux without probing (today's behavior, unchanged)", () => {
|
||||
let probed = false;
|
||||
const shell = resolveShell({
|
||||
platform: "linux",
|
||||
env: {},
|
||||
whichAll: () => {
|
||||
probed = true;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
expect(probed).toBe(false);
|
||||
});
|
||||
|
||||
it("uses bash -lc on darwin", () => {
|
||||
const shell = resolveShell({ platform: "darwin", env: {} });
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell — win32 probing", () => {
|
||||
it("prefers bash on PATH (Git for Windows)", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
whichAll: which({
|
||||
bash: ["C:\\Program Files\\Git\\bin\\bash.exe"],
|
||||
pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"],
|
||||
}),
|
||||
});
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
|
||||
it("skips the WSL launcher bash under the system root and falls through to pwsh", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { SystemRoot: "C:\\WINDOWS" },
|
||||
whichAll: which({
|
||||
bash: ["C:\\Windows\\System32\\bash.exe"],
|
||||
pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"],
|
||||
}),
|
||||
});
|
||||
expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
|
||||
});
|
||||
|
||||
it("falls back to pwsh when bash is absent", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
whichAll: which({ pwsh: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe"] }),
|
||||
});
|
||||
expect(shell).toEqual({ command: "pwsh", args: POWERSHELL_ARGS, name: "pwsh" });
|
||||
});
|
||||
|
||||
it("falls back to powershell when neither bash nor pwsh resolve", () => {
|
||||
const shell = resolveShell({ platform: "win32", env: {}, whichAll: which({}) });
|
||||
expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveShell — PENGUIN_SHELL override", () => {
|
||||
it("wins on every platform and keeps POSIX-style args for a POSIX shell path", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "linux",
|
||||
env: { PENGUIN_SHELL: "/usr/bin/zsh" },
|
||||
});
|
||||
expect(shell).toEqual({ command: "/usr/bin/zsh", args: ["-lc"], name: "zsh" });
|
||||
});
|
||||
|
||||
it("uses PowerShell-style args when the basename is pwsh (case/extension-insensitive)", () => {
|
||||
const shell = resolveShell({
|
||||
platform: "win32",
|
||||
env: { PENGUIN_SHELL: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE" },
|
||||
whichAll: which({ bash: ["C:\\Program Files\\Git\\bin\\bash.exe"] }),
|
||||
});
|
||||
expect(shell).toEqual({
|
||||
command: "C:\\Program Files\\PowerShell\\7\\pwsh.EXE",
|
||||
args: POWERSHELL_ARGS,
|
||||
name: "pwsh",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses PowerShell-style args for a bare powershell name", () => {
|
||||
const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "powershell" } });
|
||||
expect(shell).toEqual({ command: "powershell", args: POWERSHELL_ARGS, name: "powershell" });
|
||||
});
|
||||
|
||||
it("uses cmd-style args when the basename is cmd", () => {
|
||||
const shell = resolveShell({ platform: "win32", env: { PENGUIN_SHELL: "cmd" } });
|
||||
expect(shell).toEqual({ command: "cmd", args: ["/d", "/s", "/c"], name: "cmd" });
|
||||
});
|
||||
|
||||
it("ignores a blank PENGUIN_SHELL", () => {
|
||||
const shell = resolveShell({ platform: "linux", env: { PENGUIN_SHELL: " " } });
|
||||
expect(shell).toEqual({ command: "bash", args: ["-lc"], name: "bash" });
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PLATFORM_PLACEHOLDER,
|
||||
PROJECT_DIR_PLACEHOLDER,
|
||||
SESSION_ID_PLACEHOLDER,
|
||||
SHELL_PLACEHOLDER,
|
||||
addModel,
|
||||
setVisionModel,
|
||||
agentsMdPath,
|
||||
@@ -64,7 +65,10 @@ afterEach(async () => {
|
||||
} else {
|
||||
process.env.PENGUIN_HOME = prevHome;
|
||||
}
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
// Retries: when a test times out, vitest runs this cleanup while the test's un-cancelled
|
||||
// init may still be writing files, so an immediate recursive rm can hit ENOTEMPTY on
|
||||
// Windows (fs.rm retries ENOTEMPTY/EBUSY/EPERM); a no-op when removal succeeds first try.
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
@@ -83,87 +87,96 @@ describe("paths / resolveRoot", () => {
|
||||
});
|
||||
|
||||
describe("loadOrInitAgentState", () => {
|
||||
it("initializes an empty agent directory with the full state layout", async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
expect(state.root).toBe(tmpRoot);
|
||||
expect(state.projectId).toBe(DEFAULT_PROJECT_ID);
|
||||
expect(state.agentId).toBe(DEFAULT_AGENT_ID);
|
||||
// Timeout: initialization writes the full layout — 15 library skills plus the example
|
||||
// benchmark, dozens of small files — and this first init test also pays the cold-I/O cost
|
||||
// (first-touch reads of the skills package, Defender scans) on Windows runners, where a
|
||||
// slow-disk moment has pushed it past the 5s default. Purely a failure deadline: passing
|
||||
// runs stay as fast as before on every platform.
|
||||
it(
|
||||
"initializes an empty agent directory with the full state layout",
|
||||
{ timeout: 20_000 },
|
||||
async () => {
|
||||
const state = await loadOrInitAgentState();
|
||||
expect(state.root).toBe(tmpRoot);
|
||||
expect(state.projectId).toBe(DEFAULT_PROJECT_ID);
|
||||
expect(state.agentId).toBe(DEFAULT_AGENT_ID);
|
||||
|
||||
const root = tmpRoot;
|
||||
expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
// The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id).
|
||||
expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
const root = tmpRoot;
|
||||
expect(await exists(systemConfigPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(agentsMdPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(toolsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
expect(await exists(skillsDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
// The scratchpad/ directory alongside agent_state (model temp files get a subdirectory per Session id).
|
||||
expect(await exists(scratchpadDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toBe(true);
|
||||
|
||||
expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID));
|
||||
expect(state.stateDir).toBe(agentStateDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID));
|
||||
|
||||
// The default system Prompt states the Agent's identity, without repeating tool details
|
||||
// already in the tool schema (Suggested workflows only points to the run_subagent
|
||||
// delegation entry point).
|
||||
expect(state.systemConfig.system_prompt).toContain("PenguinHarness");
|
||||
expect(state.systemConfig.system_prompt).not.toContain("exec_command");
|
||||
// Suggested workflows absorbs Subagent delegation and task conventions (self-reported
|
||||
// identity as a soft convention, parallelism, file exchange).
|
||||
expect(state.systemConfig.system_prompt).toContain("# Suggested workflows");
|
||||
expect(state.systemConfig.system_prompt).toContain("run_subagent");
|
||||
expect(state.systemConfig.system_prompt).toContain("Caller agent");
|
||||
// The default AGENTS.md is empty: it carries no preset guidance.
|
||||
expect(state.agentsMd).toBe("");
|
||||
expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER);
|
||||
// AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md
|
||||
// before Environment; the [developer_instructions] wrapper text is written directly into
|
||||
// the template (the Prompt is transparent about the config).
|
||||
expect(state.systemConfig.system_prompt).toContain("[developer_instructions]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]");
|
||||
// The default template explains the semantics of system-synthesized markers to the model,
|
||||
// and recommends preferring tool use.
|
||||
expect(state.systemConfig.system_prompt).toContain("[turn_aborted]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[turn_retried]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[context_summary]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[user_steering]");
|
||||
expect(state.systemConfig.system_prompt).toContain("# Tool use");
|
||||
// Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file,
|
||||
// which holds API keys) and each Agent's .vault.toml, and states that config can only be
|
||||
// changed via the CLI (penguin config ...).
|
||||
expect(state.systemConfig.system_prompt).toContain("Never read");
|
||||
expect(state.systemConfig.system_prompt).toContain(".project_config.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("CLI-only");
|
||||
expect(state.systemConfig.system_prompt).toContain("penguin config");
|
||||
expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml");
|
||||
expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan(
|
||||
state.systemConfig.system_prompt.indexOf("# Environment"),
|
||||
);
|
||||
// The # Vault and # Skills body sections plus their placeholders: the default template
|
||||
// places them after [/developer_instructions] and before # Environment, in the order
|
||||
// Vault -> Skills (the statement text is part of the template body, kept even with no
|
||||
// keys/skills).
|
||||
const tpl = state.systemConfig.system_prompt;
|
||||
expect(tpl).toContain("# Vault");
|
||||
expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER);
|
||||
expect(tpl).toContain("# Skills");
|
||||
expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER);
|
||||
expect(tpl).toContain("[use_skills]");
|
||||
expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault"));
|
||||
expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER));
|
||||
expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills"));
|
||||
expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER));
|
||||
expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment"));
|
||||
expect(state.systemConfig.model?.max_tokens).toBe(32000);
|
||||
expect(state.systemConfig.model?.thinking_level).toBe("medium");
|
||||
expect(state.systemConfig.model?.timeoutMs).toBe(120000);
|
||||
expect(state.systemConfig.tools?.mcpServers).toEqual([]);
|
||||
expect(Object.hasOwn(state.systemConfig, "description")).toBe(false);
|
||||
expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false);
|
||||
});
|
||||
// The default system Prompt states the Agent's identity, without repeating tool details
|
||||
// already in the tool schema (Suggested workflows only points to the run_subagent
|
||||
// delegation entry point).
|
||||
expect(state.systemConfig.system_prompt).toContain("PenguinHarness");
|
||||
expect(state.systemConfig.system_prompt).not.toContain("exec_command");
|
||||
// Suggested workflows absorbs Subagent delegation and task conventions (self-reported
|
||||
// identity as a soft convention, parallelism, file exchange).
|
||||
expect(state.systemConfig.system_prompt).toContain("# Suggested workflows");
|
||||
expect(state.systemConfig.system_prompt).toContain("run_subagent");
|
||||
expect(state.systemConfig.system_prompt).toContain("Caller agent");
|
||||
// The default AGENTS.md is empty: it carries no preset guidance.
|
||||
expect(state.agentsMd).toBe("");
|
||||
expect(state.systemConfig.system_prompt).toContain(AGENTS_MD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(SESSION_ID_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(CWD_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(PLATFORM_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(OS_VERSION_PLACEHOLDER);
|
||||
expect(state.systemConfig.system_prompt).toContain(DATE_PLACEHOLDER);
|
||||
// AGENTS.md and the Environment injection sit at the end of the template, with AGENTS.md
|
||||
// before Environment; the [developer_instructions] wrapper text is written directly into
|
||||
// the template (the Prompt is transparent about the config).
|
||||
expect(state.systemConfig.system_prompt).toContain("[developer_instructions]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[/developer_instructions]");
|
||||
// The default template explains the semantics of system-synthesized markers to the model,
|
||||
// and recommends preferring tool use.
|
||||
expect(state.systemConfig.system_prompt).toContain("[turn_aborted]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[turn_retried]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[context_summary]");
|
||||
expect(state.systemConfig.system_prompt).toContain("[user_steering]");
|
||||
expect(state.systemConfig.system_prompt).toContain("# Tool use");
|
||||
// Privacy hardening: explicitly forbids reading .project_config.toml (the sole config file,
|
||||
// which holds API keys) and each Agent's .vault.toml, and states that config can only be
|
||||
// changed via the CLI (penguin config ...).
|
||||
expect(state.systemConfig.system_prompt).toContain("Never read");
|
||||
expect(state.systemConfig.system_prompt).toContain(".project_config.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("agent_state/.vault.toml");
|
||||
expect(state.systemConfig.system_prompt).toContain("CLI-only");
|
||||
expect(state.systemConfig.system_prompt).toContain("penguin config");
|
||||
expect(state.systemConfig.system_prompt).not.toContain(".credentials.toml");
|
||||
expect(state.systemConfig.system_prompt.indexOf(AGENTS_MD_PLACEHOLDER)).toBeLessThan(
|
||||
state.systemConfig.system_prompt.indexOf("# Environment"),
|
||||
);
|
||||
// The # Vault and # Skills body sections plus their placeholders: the default template
|
||||
// places them after [/developer_instructions] and before # Environment, in the order
|
||||
// Vault -> Skills (the statement text is part of the template body, kept even with no
|
||||
// keys/skills).
|
||||
const tpl = state.systemConfig.system_prompt;
|
||||
expect(tpl).toContain("# Vault");
|
||||
expect(tpl).toContain(VAULT_KEYS_PLACEHOLDER);
|
||||
expect(tpl).toContain("# Skills");
|
||||
expect(tpl).toContain(SKILL_METADATA_PLACEHOLDER);
|
||||
expect(tpl).toContain("[use_skills]");
|
||||
expect(tpl.indexOf("[/developer_instructions]")).toBeLessThan(tpl.indexOf("# Vault"));
|
||||
expect(tpl.indexOf("# Vault")).toBeLessThan(tpl.indexOf(VAULT_KEYS_PLACEHOLDER));
|
||||
expect(tpl.indexOf(VAULT_KEYS_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Skills"));
|
||||
expect(tpl.indexOf("# Skills")).toBeLessThan(tpl.indexOf(SKILL_METADATA_PLACEHOLDER));
|
||||
expect(tpl.indexOf(SKILL_METADATA_PLACEHOLDER)).toBeLessThan(tpl.indexOf("# Environment"));
|
||||
expect(state.systemConfig.model?.max_tokens).toBe(32000);
|
||||
expect(state.systemConfig.model?.thinking_level).toBe("medium");
|
||||
expect(state.systemConfig.model?.timeoutMs).toBe(120000);
|
||||
expect(state.systemConfig.tools?.mcpServers).toEqual([]);
|
||||
expect(Object.hasOwn(state.systemConfig, "description")).toBe(false);
|
||||
expect(Object.hasOwn(state.systemConfig, "subagents")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("loads an existing agent directory and returns the same system prompt", async () => {
|
||||
const first = await loadOrInitAgentState();
|
||||
@@ -500,6 +513,7 @@ describe("assembleSystemPrompt", () => {
|
||||
`pdir=${PROJECT_DIR_PLACEHOLDER}`,
|
||||
`platform=${PLATFORM_PLACEHOLDER}`,
|
||||
`os=${OS_VERSION_PLACEHOLDER}`,
|
||||
`shell=${SHELL_PLACEHOLDER}`,
|
||||
`date=${DATE_PLACEHOLDER}`,
|
||||
"middle",
|
||||
AGENTS_MD_PLACEHOLDER,
|
||||
@@ -518,6 +532,7 @@ describe("assembleSystemPrompt", () => {
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
shell: "zsh",
|
||||
date: "2026-06-30",
|
||||
});
|
||||
expect(prompt).toBe(
|
||||
@@ -529,6 +544,7 @@ describe("assembleSystemPrompt", () => {
|
||||
"pdir=/tmp/proj",
|
||||
"platform=darwin",
|
||||
"os=Darwin 25.0.0",
|
||||
"shell=zsh",
|
||||
"date=2026-06-30",
|
||||
"middle",
|
||||
"# Agent Rules\nFollow local rules.",
|
||||
@@ -572,6 +588,7 @@ describe("assembleSystemPrompt", () => {
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform: "darwin",
|
||||
osVersion: "Darwin 25.0.0",
|
||||
shell: "zsh",
|
||||
date: "2026-06-30",
|
||||
});
|
||||
expect(prompt).toBe("base prompt");
|
||||
@@ -657,9 +674,11 @@ describe("assembleSystemPrompt", () => {
|
||||
expect(prompt).toContain("Model ID: gpt-5.5");
|
||||
expect(prompt).toContain("Platform:");
|
||||
expect(prompt).toContain("OS Version:");
|
||||
expect(prompt).toContain("Shell:");
|
||||
expect(prompt).toContain("Date: 2026-06-30");
|
||||
expect(prompt.indexOf("Platform:")).toBeLessThan(prompt.indexOf("OS Version:"));
|
||||
expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Date:"));
|
||||
expect(prompt.indexOf("OS Version:")).toBeLessThan(prompt.indexOf("Shell:"));
|
||||
expect(prompt.indexOf("Shell:")).toBeLessThan(prompt.indexOf("Date:"));
|
||||
expect(prompt.indexOf("Date:")).toBeLessThan(prompt.indexOf("App Data Dir:"));
|
||||
expect(prompt.indexOf("App Data Dir:")).toBeLessThan(prompt.indexOf("Agent ID:"));
|
||||
expect(prompt.indexOf("Agent ID:")).toBeLessThan(prompt.indexOf("CWD:"));
|
||||
@@ -667,6 +686,114 @@ describe("assembleSystemPrompt", () => {
|
||||
expect(prompt.indexOf("Provider:")).toBeLessThan(prompt.indexOf("Model ID:"));
|
||||
expect(prompt.indexOf("Model ID:")).toBeLessThan(prompt.indexOf("Session ID:"));
|
||||
});
|
||||
|
||||
// The win32 Shell-line fallback for pre-{{SHELL}} templates (system_config.yaml is baked at
|
||||
// Agent creation and never auto-upgraded). Removable together with `withShellLineFallback`
|
||||
// once pre-{{SHELL}} Agent configs are no longer expected in the wild.
|
||||
describe("Shell line fallback for templates without {{SHELL}}", () => {
|
||||
const stateWithPrompt = (system_prompt: string) => ({
|
||||
root: tmpRoot,
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
agentId: DEFAULT_AGENT_ID,
|
||||
stateDir: agentStateDir(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID),
|
||||
systemConfig: { system_prompt },
|
||||
agentsMd: "",
|
||||
});
|
||||
const envFor = (platform: string) => ({
|
||||
sessionId: "session-1",
|
||||
cwd: "C:\\ws",
|
||||
agentId: "agent-x",
|
||||
projectDir: "C:\\proj",
|
||||
provider: "deepseek",
|
||||
modelId: "deepseek-v4-pro",
|
||||
platform,
|
||||
osVersion: "Windows 11 Pro 10.0.26100",
|
||||
shell: "pwsh",
|
||||
date: "2026-07-27",
|
||||
});
|
||||
// A pre-{{SHELL}} default-template Environment section (Platform/OS Version/Date, no Shell).
|
||||
const preShellTemplate = [
|
||||
"intro",
|
||||
"# Environment",
|
||||
`- Platform: ${PLATFORM_PLACEHOLDER}`,
|
||||
`- OS Version: ${OS_VERSION_PLACEHOLDER}`,
|
||||
`- Date: ${DATE_PLACEHOLDER}`,
|
||||
"",
|
||||
"# Tail section",
|
||||
"tail",
|
||||
].join("\n");
|
||||
|
||||
it("injects the line exactly once into the Environment section on win32", () => {
|
||||
const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), envFor("win32"));
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"intro",
|
||||
"# Environment",
|
||||
"- Shell: pwsh",
|
||||
"- Platform: win32",
|
||||
"- OS Version: Windows 11 Pro 10.0.26100",
|
||||
"- Date: 2026-07-27",
|
||||
"",
|
||||
"# Tail section",
|
||||
"tail",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(prompt.split("- Shell: pwsh").length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps POSIX output byte-identical (no injected line)", () => {
|
||||
for (const platform of ["linux", "darwin"]) {
|
||||
const prompt = assembleSystemPrompt(stateWithPrompt(preShellTemplate), {
|
||||
...envFor(platform),
|
||||
shell: "bash",
|
||||
osVersion: "Linux 6.1.0",
|
||||
});
|
||||
expect(prompt).toBe(
|
||||
[
|
||||
"intro",
|
||||
"# Environment",
|
||||
`- Platform: ${platform}`,
|
||||
"- OS Version: Linux 6.1.0",
|
||||
"- Date: 2026-07-27",
|
||||
"",
|
||||
"# Tail section",
|
||||
"tail",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(prompt).not.toContain("Shell:");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not duplicate the line when the template has {{SHELL}}", () => {
|
||||
const template = [
|
||||
"# Environment",
|
||||
`- Platform: ${PLATFORM_PLACEHOLDER}`,
|
||||
`- Shell: ${SHELL_PLACEHOLDER}`,
|
||||
].join("\n");
|
||||
const prompt = assembleSystemPrompt(stateWithPrompt(template), envFor("win32"));
|
||||
expect(prompt).toBe(["# Environment", "- Platform: win32", "- Shell: pwsh"].join("\n"));
|
||||
expect(prompt.split("- Shell:").length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("does not duplicate a hardcoded line and appends a minimal line without an Environment section", () => {
|
||||
// A custom template that hardcodes the exact line: left untouched (idempotent).
|
||||
const hardcoded = assembleSystemPrompt(
|
||||
stateWithPrompt("base prompt\n- Shell: pwsh"),
|
||||
envFor("win32"),
|
||||
);
|
||||
expect(hardcoded).toBe("base prompt\n- Shell: pwsh");
|
||||
// A hardcoded line with a different value is a deliberate template choice:
|
||||
// never add a second, contradicting Shell line.
|
||||
const pinned = assembleSystemPrompt(
|
||||
stateWithPrompt("base prompt\n- Shell: bash"),
|
||||
envFor("win32"),
|
||||
);
|
||||
expect(pinned).toBe("base prompt\n- Shell: bash");
|
||||
// No Environment section at all: the line is appended at the end.
|
||||
const appended = assembleSystemPrompt(stateWithPrompt("base prompt"), envFor("win32"));
|
||||
expect(appended).toBe("base prompt\n- Shell: pwsh");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetSystemConfigToDefaults", () => {
|
||||
@@ -1174,28 +1301,35 @@ describe("single hidden config file (.project_config.toml, credentials inlined)"
|
||||
// The sole config file is hidden (not shown by ls by default) and has 0600 permission
|
||||
// (owner read/write only).
|
||||
expect(path.basename(file)).toBe(".project_config.toml");
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
// POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
|
||||
if (process.platform !== "win32") {
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
expect(await fs.readFile(file, "utf8")).toContain("sk-split-1");
|
||||
// The old two-file layout is no longer produced.
|
||||
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, "project_config.toml"))).toBe(false);
|
||||
expect(await exists(path.join(tmpRoot, DEFAULT_PROJECT_ID, ".credentials.toml"))).toBe(false);
|
||||
});
|
||||
|
||||
it("chmod converges an existing file back to 0600 on save", async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-1",
|
||||
});
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.chmod(file, 0o644);
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-2",
|
||||
});
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
// POSIX-only: Windows has no owner-only mode bits to converge.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"chmod converges an existing file back to 0600 on save",
|
||||
async () => {
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-1",
|
||||
});
|
||||
const file = projectConfigPath(tmpRoot, DEFAULT_PROJECT_ID);
|
||||
await fs.chmod(file, 0o644);
|
||||
await addModel(tmpRoot, DEFAULT_PROJECT_ID, {
|
||||
provider: "custom",
|
||||
model_id: "m-perm",
|
||||
api_key: "sk-2",
|
||||
});
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it("writes provider and model_id as separate fields; refs are TOML inline tables", async () => {
|
||||
await addModel(
|
||||
@@ -1238,7 +1372,10 @@ describe("agent vault (agent_state/.vault.toml)", () => {
|
||||
// with 0600 permission (owner read/write only).
|
||||
const file = agentVaultPath(tmpRoot, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID);
|
||||
expect(path.basename(file)).toBe(".vault.toml");
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
// POSIX-only: Windows has no owner-only mode bits (chmod maps to the read-only attribute).
|
||||
if (process.platform !== "win32") {
|
||||
expect((await fs.stat(file)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
const raw = await fs.readFile(file, "utf8");
|
||||
expect(raw).toContain("sk-secret-2");
|
||||
// The Project config no longer carries the vault.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Vitest config for core. The one non-default setting is a platform-aware test timeout:
|
||||
* many core tests spawn a real shell (Git-Bash on the Windows CI runners) or write the
|
||||
* full Agent State layout, and Windows runner I/O variance (cold shell spawns, Defender
|
||||
* first-touch scans, slow-disk moments) has pushed individually fast tests past vitest's
|
||||
* 5s default — a different test on each run. A larger failure deadline changes nothing
|
||||
* for passing tests; POSIX keeps the 5s default to fail fast during local development.
|
||||
*/
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
testTimeout: process.platform === "win32" ? 30_000 : 5_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user