From 2c1ed89a695143d6ad07efcd87f032df510fccc6 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:15:46 +0000 Subject: [PATCH 1/8] feat: prepare package CLI workspace contract --- .claude/hooks/protect-files.sh | 4 +- CLAUDE.md | 3 + README.md | 26 +- .../extensions/guardian-protect-files.ts | 65 +-- bot/package-lock.json | 7 + bot/package.json | 25 +- bot/scripts/build-package-artifacts.mjs | 72 ++++ bot/scripts/clean-package-dist.mjs | 9 + bot/src/__tests__/cli.test.ts | 202 ++++++++++ bot/src/__tests__/config-defaults.test.ts | 175 ++++++++ bot/src/__tests__/cron-merge.test.ts | 73 ++++ bot/src/__tests__/cron-runner-pi.test.ts | 81 +++- bot/src/__tests__/cron-runner.test.ts | 21 +- .../__tests__/message-content-index.test.ts | 47 ++- .../__tests__/message-thread-cache.test.ts | 56 ++- bot/src/__tests__/package-install.test.ts | 376 ++++++++++++++++++ bot/src/__tests__/pi-rpc-protocol.test.ts | 193 ++++++++- bot/src/__tests__/project-naming.test.ts | 9 +- bot/src/__tests__/session-store.test.ts | 8 +- bot/src/__tests__/workspace-contract.test.ts | 187 +++++++++ bot/src/__tests__/workspace-validator.test.ts | 224 +++++++++++ bot/src/cli.ts | 232 +++++++++++ bot/src/config.ts | 51 ++- bot/src/cron-runner.ts | 35 +- bot/src/message-content-index.ts | 20 +- bot/src/message-thread-cache.ts | 20 +- bot/src/pi-extensions/README.md | 22 +- .../pi-extensions/write-allowlist-schema.ts | 136 +++++++ bot/src/pi-rpc-protocol.ts | 104 +++-- bot/src/session-store.ts | 11 +- bot/src/workspace-contract.ts | 285 +++++++++++++ bot/src/workspace-validator.ts | 192 +++++++++ .../agent-workspace/.gitkeep | 1 + .../minimal-workspace/config.yaml | 9 + .../minimal-workspace/crons.yaml | 6 + bot/test-fixtures/minimal-workspace/schema.md | 7 + config.local.yaml.example | 2 +- ...ssue-148-package-cli-workspace-contract.md | 241 +++++++++++ 38 files changed, 3084 insertions(+), 153 deletions(-) create mode 100644 bot/scripts/build-package-artifacts.mjs create mode 100644 bot/scripts/clean-package-dist.mjs create mode 100644 bot/src/__tests__/cli.test.ts create mode 100644 bot/src/__tests__/package-install.test.ts create mode 100644 bot/src/__tests__/workspace-contract.test.ts create mode 100644 bot/src/__tests__/workspace-validator.test.ts create mode 100644 bot/src/cli.ts create mode 100644 bot/src/pi-extensions/write-allowlist-schema.ts create mode 100644 bot/src/workspace-contract.ts create mode 100644 bot/src/workspace-validator.ts create mode 100644 bot/test-fixtures/minimal-workspace/agent-workspace/.gitkeep create mode 100644 bot/test-fixtures/minimal-workspace/config.yaml create mode 100644 bot/test-fixtures/minimal-workspace/crons.yaml create mode 100644 bot/test-fixtures/minimal-workspace/schema.md create mode 100644 docs/plans/completed/2026-06-06-issue-148-package-cli-workspace-contract.md diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh index f151b522..aceb7ab6 100755 --- a/.claude/hooks/protect-files.sh +++ b/.claude/hooks/protect-files.sh @@ -27,11 +27,11 @@ fi # Normalize path: prevent bypass via non-canonical paths # Collapse multiple slashes: // → / while [[ "$FILE_PATH" == *//* ]]; do - FILE_PATH="${FILE_PATH//\/\//\/}" + FILE_PATH="${FILE_PATH//\/\///}" done # Collapse /./ → / while [[ "$FILE_PATH" == *"/./"* ]]; do - FILE_PATH="${FILE_PATH//\/.\//\/}" + FILE_PATH="${FILE_PATH//\/.\///}" done # Resolve /component/.. sequences while [[ "$FILE_PATH" == *"/.."* ]]; do diff --git a/CLAUDE.md b/CLAUDE.md index aae24e06..6fe0e51f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,10 @@ Five hooks are wired in `.claude/settings.json`: - Bundled scout/planner/reviewer agents allow `web_search` and `web_fetch`; worker has no explicit tools allowlist. - Use `thinking` for Pi agents; `effort` is obsolete and rejected by config validation. - Runtime bot tokens use `bot/src/secrets.ts`: SOPS first, then configured env; legacy `*tokenService` Keychain fields are rejected. Telegram/Discord SOPS files resolve relative to the bot config file, while Tavily uses `config/secrets.sops.yaml` relative to each Pi session `workspaceCwd` and should contain only `tavily.api_key`. +- Workspace contract defaults live in `bot/src/workspace-contract.ts`: CLI `--workspace`, then `MINIME_WORKSPACE_ROOT`, then source-checkout fallback. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` resolve relative to the workspace root; relative agent `workspaceCwd` values are resolved against that root and agent workspaces must stay inside it before runtime spawns. +- Package extension artifacts are generated under `bot/dist/extensions/pi` by `npm run build` / `npm pack`; source development still uses `bot/.claude/extensions`. - Bot validation commands: `cd bot && npm test`, `npm run typecheck`, and `npm run validate-config`. +- Package validation commands: `cd bot && npm run build`, `npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace`, and `npm pack --dry-run`. - Sampler dry-run check: `cd bot && CODEX_QUOTA_TEXTFILE_DIR=/tmp/codex-quota-test CODEX_QUOTA_STATE_FILE=/tmp/codex-quota-test/state.json npx tsx scripts/codex-quota-sampler.ts --dry-run`. ## Skills diff --git a/README.md b/README.md index 1f810ec0..0c7eead5 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,30 @@ The bot maintains persistent context across sessions through a memory system roo ## Configuration +### CLI validation + +The package exposes a built CLI as `minime-bot` after `npm run build`, `npm pack`, or package installation. From a source checkout, the same workspace validator is available through npm scripts: + +```bash +cd bot +npm run build +npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace +npm run validate-config +node dist/cli.js --help +``` + +Installed-package commands use the same surface: + +```bash +minime-bot --help +minime-bot config validate --workspace /path/to/workspace +minime-bot workspace validate --workspace /path/to/workspace +``` + +`--workspace` takes precedence over `MINIME_WORKSPACE_ROOT`; if neither is set in the current source checkout, the workspace defaults to the repository root. To validate the repository root itself, make sure it has the expected workspace files, including `schema.md` and configured agent `workspaceCwd` directories. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` override the corresponding workspace files and resolve relative to the workspace root when not absolute. + +Validation is structural by default. These commands load config with secret resolution disabled, parse crons and the workspace `schema.md` write allow-list, and print effective paths without decrypting SOPS files or printing secret values. Hard failures include an absent or invalid workspace root, missing or invalid config, malformed crons, missing/empty/malformed schema while guards are enabled, missing configured agent workspaces, agent workspaces outside the resolved workspace root, a missing Pi extension directory, or validator/live-guard schema path disagreement. A missing crons file is a warning. Setting `PI_EXTENSIONS_DISABLED=1` skips schema enforcement as a warning for workspace validation, but Pi LLM crons still require the A1 guard. + ### Provider backends Interactive agents run through Pi RPC + OpenAI Codex. The optional per-agent `provider` field remains only as a compatibility field: @@ -402,7 +426,7 @@ The Pi binary (`@earendil-works/pi-coding-agent`) is resolved from `PATH`; the b #### Pi extensions (A1-A3) -Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads three first-party extensions so Pi sessions reach parity with the workspace guard, web-tools, and subagent capabilities expected by deployed agents. They are loaded as repeatable `--extension ` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)) — loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. +Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads three first-party extensions so Pi sessions reach parity with the workspace guard, web-tools, and subagent capabilities expected by deployed agents. They are loaded as repeatable `--extension ` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)) — loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. Source checkout runs load the TypeScript wrappers under `bot/.claude/extensions/`; built and installed package runs load generated wrappers under `bot/dist/extensions/pi/` or `node_modules/minime/dist/extensions/pi/`, including copied subagent `agents/*.md` and `prompts/*.md` resources. | Extension | Wrapper | What it does | |---|---|---| diff --git a/bot/.claude/extensions/guardian-protect-files.ts b/bot/.claude/extensions/guardian-protect-files.ts index 0f059b7a..e931c8bf 100644 --- a/bot/.claude/extensions/guardian-protect-files.ts +++ b/bot/.claude/extensions/guardian-protect-files.ts @@ -20,10 +20,12 @@ * `docs/plans/2026-06-02-pi-claude-write-guard-enforcers.md`. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { classifyToolCall } from "../../src/pi-extensions/guard.js"; +import { + readWriteAllowlistEntriesForGuard, + resolveWriteAllowlistSchemaPath, +} from "../../src/pi-extensions/write-allowlist-schema.js"; import { PI_GUARD_WORKSPACE_ROOT_ENV } from "../../src/pi-rpc-protocol.js"; /** @@ -34,27 +36,17 @@ import { PI_GUARD_WORKSPACE_ROOT_ENV } from "../../src/pi-rpc-protocol.js"; */ const WRITE_TARGET_TOOLS = new Set(["write", "edit", "bash"]); -/** The fence tag that opens the single write-allowlist block in `schema.md`. */ -const WRITE_ALLOWLIST_FENCE = "```write-allowlist"; - /** - * Per-process cache of the parsed write-allowlist, keyed by workspace root. The + * Per-process cache of the parsed write-allowlist, keyed by schema path. The * `schema.md` block is read once per spawn (Pi sessions are short-lived); an * edit to `schema.md` is picked up on the next spawn, not mid-session. */ const writeAllowlistCache = new Map(); /** - * Read the workspace write allow-list — the lines of the single - * ```` ```write-allowlist ```` fenced block in `/schema.md`. - * Mirrors the awk extraction `guardian.sh` uses - * (`/^```write-allowlist$/{f=1;next} f&&/^```/{exit} f`): the lines strictly - * between an opening fence that is EXACTLY ```` ```write-allowlist ```` and the - * next line starting with ```` ``` ````. Both stop after the FIRST block (the awk - * `exit`s, this loop `break`s) so they stay identical even if schema.md carries a - * second block against its contract. Each extracted line then has `#` comments - * stripped, is trimmed, and blanks dropped — the same stripping the guardian - * orphan-allowlist uses. + * Read the workspace write allow-list from the resolved schema path. When + * MINIME_SCHEMA_PATH is set, it is resolved exactly like the workspace contract: + * absolute paths are used as-is and relative paths are based on the guard root. * * Returns the parsed lines, or an EMPTY array when `schema.md` is missing / * unreadable or has no `write-allowlist` block. The empty array is DELIBERATE @@ -67,47 +59,22 @@ const writeAllowlistCache = new Map(); * filesystem; this wrapper does the I/O and injects the result. */ function readWriteAllowlist(workspaceRoot: string): string[] { - const cached = writeAllowlistCache.get(workspaceRoot); + const schemaPath = resolveWriteAllowlistSchemaPath(workspaceRoot, process.env); + const cached = writeAllowlistCache.get(schemaPath); if (cached !== undefined) { return cached; } - const lines: string[] = []; - let content: string; - try { - content = readFileSync(join(workspaceRoot, "schema.md"), "utf8"); - } catch { - // Missing/unreadable schema.md → empty list → fail-closed in the classifier. - writeAllowlistCache.set(workspaceRoot, lines); - return lines; - } - let inBlock = false; - for (const rawLine of content.split("\n")) { - if (!inBlock) { - if (rawLine === WRITE_ALLOWLIST_FENCE) { - inBlock = true; - } - continue; - } - if (rawLine.startsWith("```")) { - break; // closing fence of the write-allowlist block - } - const line = rawLine.replace(/#.*$/, "").trim(); - if (line) { - lines.push(line); - } - } - writeAllowlistCache.set(workspaceRoot, lines); + const lines = readWriteAllowlistEntriesForGuard(schemaPath); + writeAllowlistCache.set(schemaPath, lines); return lines; } export default function (pi: ExtensionAPI): void { pi.on("tool_call", async (event, ctx) => { - // Protection is anchored at the IMMUTABLE workspace root. For a subagent - // CHILD that is the parent workspace (carried in PI_GUARD_WORKSPACE_ROOT), so - // a caller-supplied `cwd` cannot move the guard root and let a delegated - // absolute write reach a protected dir. For a top-level parent the env is - // unset (scrubbed by buildPiSpawnEnv) → the guard root IS `ctx.cwd`. Relative - // targets still resolve against the real `ctx.cwd` (where the OS writes them). + // Protection is anchored at the IMMUTABLE workspace root carried in + // PI_GUARD_WORKSPACE_ROOT. Top-level agents and subagent children can both run + // from a child cwd, so relative targets still resolve against the real + // `ctx.cwd` (where the OS writes them). const guardRoot = process.env[PI_GUARD_WORKSPACE_ROOT_ENV]?.trim() || ctx.cwd; // Schema-enforced DENY-BY-DEFAULT (the new model). Read the `schema.md` diff --git a/bot/package-lock.json b/bot/package-lock.json index e99d257f..65469ad9 100644 --- a/bot/package-lock.json +++ b/bot/package-lock.json @@ -9,14 +9,21 @@ "version": "2026.03.0", "license": "MIT", "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", "@earendil-works/pi-coding-agent": "0.75.3", + "@earendil-works/pi-tui": "^0.75.3", "@grammyjs/auto-retry": "^2.0.2", "discord.js": "^14.25.1", "grammy": "^1.41.1", "p-queue": "^9.1.0", "prom-client": "^15.1.3", + "typebox": "^1.1.24", "yaml": "^2.8.2" }, + "bin": { + "minime-bot": "dist/cli.js" + }, "devDependencies": { "@types/node": "^25.4.0", "tsx": "^4.21.0", diff --git a/bot/package.json b/bot/package.json index 6f2eb90b..91a6c4b8 100644 --- a/bot/package.json +++ b/bot/package.json @@ -2,18 +2,35 @@ "name": "minime", "version": "2026.03.0", "description": "Multi-agent Telegram and Discord bot powered by Pi/Codex sessions", - "main": "index.js", + "main": "./dist/main.js", + "bin": { + "minime-bot": "./dist/cli.js" + }, "repository": { "type": "git", "url": "https://github.com/Fitz123/claude-code-bot.git" }, "scripts": { "test": "MINIME_TEST_MEDIA_BASE=/tmp/bot-media-test node --experimental-test-module-mocks --import tsx --test src/__tests__/*.test.ts", - "build": "tsc", + "build": "node scripts/clean-package-dist.mjs && tsc && node scripts/build-package-artifacts.mjs", "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", + "prepare": "npm run build", + "prepack": "npm run build", + "workspace:validate": "tsx src/cli.ts workspace validate", "validate-config": "tsx src/config.ts --validate --no-resolve-secrets" }, + "files": [ + "dist/*.d.ts", + "dist/*.js", + "dist/*.js.map", + "dist/pi-extensions/**/*.d.ts", + "dist/pi-extensions/**/*.js", + "dist/pi-extensions/**/*.js.map", + "dist/extensions/pi/**/*.js", + "dist/extensions/pi/**/*.md", + "scripts/deliver.sh" + ], "keywords": [ "telegram", "discord", @@ -27,12 +44,16 @@ "license": "MIT", "type": "module", "dependencies": { + "@earendil-works/pi-agent-core": "^0.75.3", + "@earendil-works/pi-ai": "^0.75.3", "@earendil-works/pi-coding-agent": "0.75.3", + "@earendil-works/pi-tui": "^0.75.3", "@grammyjs/auto-retry": "^2.0.2", "discord.js": "^14.25.1", "grammy": "^1.41.1", "p-queue": "^9.1.0", "prom-client": "^15.1.3", + "typebox": "^1.1.24", "yaml": "^2.8.2" }, "devDependencies": { diff --git a/bot/scripts/build-package-artifacts.mjs b/bot/scripts/build-package-artifacts.mjs new file mode 100644 index 00000000..a77a7923 --- /dev/null +++ b/bot/scripts/build-package-artifacts.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { chmodSync, cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(scriptDir, ".."); +const sourceExtensionDir = join(packageRoot, ".claude", "extensions"); +const artifactExtensionDir = join(packageRoot, "dist", "extensions", "pi"); + +const wrappers = [ + ["guardian-protect-files.ts", "guardian-protect-files.js"], + ["web-tools.ts", "web-tools.js"], + [join("subagent", "agents.ts"), join("subagent", "agents.js")], + [join("subagent", "index.ts"), join("subagent", "index.js")], +]; + +rmSync(artifactExtensionDir, { recursive: true, force: true }); + +function rewriteImports(source) { + return source + .replaceAll("../../src/pi-extensions/", "../../pi-extensions/") + .replaceAll("../../src/pi-rpc-protocol.js", "../../pi-rpc-protocol.js") + .replaceAll("../../../src/pi-extensions/", "../../../pi-extensions/") + .replaceAll("../../../src/pi-rpc-protocol.js", "../../../pi-rpc-protocol.js") + .replaceAll('from "./agents.ts"', 'from "./agents.js"'); +} + +function transpileWrapper(sourcePath, targetPath) { + const source = rewriteImports(readFileSync(sourcePath, "utf8")); + const result = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + moduleResolution: ts.ModuleResolutionKind.Bundler, + esModuleInterop: true, + sourceMap: false, + }, + fileName: sourcePath, + reportDiagnostics: true, + }); + const diagnostics = result.diagnostics?.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) ?? []; + if (diagnostics.length > 0) { + throw new Error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => packageRoot, + getNewLine: () => "\n", + })); + } + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, result.outputText, "utf8"); +} + +for (const [sourceRel, targetRel] of wrappers) { + transpileWrapper( + join(sourceExtensionDir, sourceRel), + join(artifactExtensionDir, targetRel), + ); +} + +for (const rel of [ + join("subagent", "agents"), + join("subagent", "prompts"), +]) { + cpSync(join(sourceExtensionDir, rel), join(artifactExtensionDir, rel), { + recursive: true, + force: true, + }); +} + +chmodSync(join(packageRoot, "dist", "cli.js"), 0o755); diff --git a/bot/scripts/clean-package-dist.mjs b/bot/scripts/clean-package-dist.mjs new file mode 100644 index 00000000..3d0c238f --- /dev/null +++ b/bot/scripts/clean-package-dist.mjs @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(scriptDir, ".."); + +rmSync(join(packageRoot, "dist"), { recursive: true, force: true }); diff --git a/bot/src/__tests__/cli.test.ts b/bot/src/__tests__/cli.test.ts new file mode 100644 index 00000000..d82449d4 --- /dev/null +++ b/bot/src/__tests__/cli.test.ts @@ -0,0 +1,202 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCli } from "../cli.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); +const CLI_TS = join(BOT_ROOT, "src", "cli.ts"); +const TSX_LOADER = createRequire(import.meta.url).resolve("tsx"); +const MINIMAL_WORKSPACE_FIXTURE = join(BOT_ROOT, "test-fixtures", "minimal-workspace"); + +function createWorkspace(): string { + const workspace = mkdtempSync(join(tmpdir(), "minime-cli-workspace-")); + mkdirSync(join(workspace, "agent-workspace"), { recursive: true }); + writeFileSync( + join(workspace, "config.yaml"), + ` +agents: + main: + workspaceCwd: ./agent-workspace + model: gpt-5.5 +secrets: + sopsFile: missing.sops.yaml +telegramTokenSopsKey: telegram.bot_token +bindings: + - chatId: 111 + agentId: main + kind: dm +`, + ); + writeFileSync( + join(workspace, "crons.yaml"), + ` +crons: + - name: smoke + schedule: "0 9 * * *" + prompt: "smoke" + agentId: main + deliveryChatId: 111 +`, + ); + writeFileSync( + join(workspace, "schema.md"), + [ + "# Fixture schema", + "", + "```write-allowlist", + "agent-workspace/", + "*.md", + "schema.md", + "```", + "", + ].join("\n"), + ); + return workspace; +} + +function runWithCapture(args: readonly string[], workspace?: string, env: NodeJS.ProcessEnv = {}): { + code: number; + stdout: string; + stderr: string; +} { + let stdout = ""; + let stderr = ""; + const code = runCli(args, { + cwd: workspace, + env, + stdout: (text) => { + stdout += text; + }, + stderr: (text) => { + stderr += text; + }, + }); + return { code, stdout, stderr }; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +describe("minime-bot CLI", () => { + it("prints help", () => { + const result = runWithCapture(["--help"]); + assert.equal(result.code, 0); + assert.match(result.stdout, /minime-bot config validate --workspace /); + assert.match(result.stdout, /minime-bot workspace validate --workspace /); + assert.equal(result.stderr, ""); + }); + + it("validates config with explicit --workspace and does not resolve SOPS secrets", () => { + const workspace = createWorkspace(); + try { + const result = runWithCapture(["config", "validate", "--workspace", workspace], workspace); + assert.equal(result.code, 0); + assert.match(result.stdout, /Config valid\./); + assert.match(result.stdout, /Agents: main/); + assert.doesNotMatch(result.stdout, /telegram\.bot_token/); + assert.equal(result.stderr, ""); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("validates a workspace and prints effective path diagnostics", () => { + const workspace = createWorkspace(); + try { + const result = runWithCapture(["workspace", "validate", "--workspace", workspace], workspace); + assert.equal(result.code, 0); + assert.match(result.stdout, /Workspace valid\./); + assert.match(result.stdout, /Effective paths:/); + assert.match(result.stdout, new RegExp(`workspace root: ${workspace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(cli\\)`)); + assert.match(result.stdout, /schema path:/); + assert.match(result.stdout, /Pi extension dir:/); + assert.match(result.stdout, /Crons: 1/); + assert.match(result.stdout, /Schema allow-list entries: 3/); + assert.equal(result.stderr, ""); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("validates the tracked fixture through MINIME_WORKSPACE_ROOT", () => { + const result = runWithCapture( + ["workspace", "validate"], + BOT_ROOT, + { [MINIME_WORKSPACE_ROOT_ENV]: MINIMAL_WORKSPACE_FIXTURE }, + ); + + assert.equal(result.code, 0); + assert.match(result.stdout, /Workspace valid\./); + assert.match(result.stdout, new RegExp(`workspace root: ${MINIMAL_WORKSPACE_FIXTURE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(env\\)`)); + assert.match(result.stdout, /config path: .*minimal-workspace\/config\.yaml \(workspace-default\)/); + assert.match(result.stdout, /schema path: .*minimal-workspace\/schema\.md \(workspace-default\)/); + assert.equal(result.stderr, ""); + }); + + it("reports workspace validation hard failures separately from warnings", () => { + const workspace = createWorkspace(); + try { + rmSync(join(workspace, "schema.md"), { force: true }); + const result = runWithCapture(["workspace", "validate", "--workspace", workspace], workspace); + + assert.equal(result.code, 1); + assert.match(result.stdout, /Workspace invalid\./); + assert.match(result.stdout, /Hard failures:/); + assert.match(result.stdout, /schema validation failed/); + assert.match(result.stderr, /Error: Workspace validation failed\./); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("runs through a minime-bot bin-style shim in source development", () => { + const temp = mkdtempSync(join(tmpdir(), "minime-cli-bin-")); + const binDir = join(temp, "node_modules", ".bin"); + mkdirSync(binDir, { recursive: true }); + const binPath = join(binDir, "minime-bot"); + writeFileSync( + binPath, + [ + "#!/bin/sh", + `exec ${shellQuote(process.execPath)} --import ${shellQuote(TSX_LOADER)} ${shellQuote(CLI_TS)} "$@"`, + "", + ].join("\n"), + ); + chmodSync(binPath, 0o755); + + try { + const result = spawnSync(binPath, ["--help"], { + cwd: temp, + encoding: "utf8", + env: { PATH: process.env.PATH ?? "" }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); + + it("declares the package bin target and keeps a shebang in the source entrypoint", () => { + const packageJson = JSON.parse(readFileSync(join(BOT_ROOT, "package.json"), "utf8")) as { + bin?: Record; + }; + assert.equal(packageJson.bin?.["minime-bot"], "./dist/cli.js"); + assert.match(readFileSync(CLI_TS, "utf8"), /^#!\/usr\/bin\/env node/); + }); +}); diff --git a/bot/src/__tests__/config-defaults.test.ts b/bot/src/__tests__/config-defaults.test.ts index e0980673..a2bd1613 100644 --- a/bot/src/__tests__/config-defaults.test.ts +++ b/bot/src/__tests__/config-defaults.test.ts @@ -4,9 +4,35 @@ import { writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { validateSessionDefaults, validateAgent, loadConfig } from "../config.js"; import { DEFAULT_MAX_MEDIA_BYTES } from "../media-store.js"; +import { MINIME_CONFIG_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; const TEST_DIR = join("/tmp", "config-defaults-test-" + Date.now()); +function withEnv(overrides: Record, fn: () => T): T { + const previous: Record = {}; + for (const key of Object.keys(overrides)) { + previous[key] = process.env[key]; + const value = overrides[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + describe("validateSessionDefaults", () => { it("returns production defaults when input is null", () => { const defaults = validateSessionDefaults(null); @@ -232,6 +258,155 @@ describe("validateAgent model validation", () => { }); }); +describe("loadConfig workspace contract defaults", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }); + }); + + it("uses MINIME_WORKSPACE_ROOT config.yaml when no config path is passed", () => { + const workspaceRoot = join(TEST_DIR, "workspace-default"); + mkdirSync(workspaceRoot, { recursive: true }); + writeFileSync( + join(workspaceRoot, "config.yaml"), + ` +agents: + main: + workspaceCwd: /tmp/x + model: gpt-5.5 +telegramTokenEnv: TEST_UNSET_TELEGRAM_TOKEN +bindings: + - chatId: 111 + agentId: main + kind: dm +`, + ); + + const config = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: undefined, + }, + () => loadConfig(undefined, { resolveSecrets: false }), + ); + + assert.strictEqual(config.agents.main.workspaceCwd, "/tmp/x"); + assert.strictEqual(config.telegramToken, "[configured]"); + assert.strictEqual(config.bindings.length, 1); + }); + + it("resolves relative agent workspaceCwd against MINIME_WORKSPACE_ROOT", () => { + const workspaceRoot = join(TEST_DIR, "workspace-agent-cwd"); + mkdirSync(join(workspaceRoot, "agent-workspace"), { recursive: true }); + writeFileSync( + join(workspaceRoot, "config.yaml"), + ` +agents: + main: + workspaceCwd: ./agent-workspace + model: gpt-5.5 +telegramTokenEnv: TEST_UNSET_TELEGRAM_TOKEN +bindings: + - chatId: 111 + agentId: main + kind: dm +`, + ); + + const config = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: undefined, + }, + () => loadConfig(undefined, { resolveSecrets: false }), + ); + + assert.strictEqual(config.agents.main.workspaceCwd, join(workspaceRoot, "agent-workspace")); + }); + + it("uses MINIME_CONFIG_PATH relative to workspace root and keeps SOPS paths relative to that config file", () => { + const workspaceRoot = join(TEST_DIR, "workspace-config-override"); + const configDir = join(workspaceRoot, "settings"); + const sopsPath = join(configDir, "secrets.sops.yaml"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(sopsPath, "telegram:\n bot_token: encrypted-placeholder\n"); + writeFileSync( + join(configDir, "bot.yaml"), + ` +agents: + main: + workspaceCwd: /tmp/x + model: gpt-5.5 +secrets: + sopsFile: secrets.sops.yaml +telegramTokenSopsKey: telegram.bot_token +bindings: + - chatId: 111 + agentId: main + kind: dm +`, + ); + const calls: Array<{ file: string; args: readonly string[] }> = []; + + const config = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: "settings/bot.yaml", + }, + () => loadConfig(undefined, { + secretExecFileSync: (file, args) => { + calls.push({ file, args }); + return "tg-token-from-sops\n"; + }, + }), + ); + + assert.strictEqual(config.telegramToken, "tg-token-from-sops"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].file, "sops"); + assert.deepStrictEqual(calls[0].args, [ + "-d", + "--extract", + '["telegram"]["bot_token"]', + sopsPath, + ]); + }); + + it("keeps relative agent workspaceCwd rooted at workspace when MINIME_CONFIG_PATH points to a subdirectory", () => { + const workspaceRoot = join(TEST_DIR, "workspace-config-agent-cwd"); + const configDir = join(workspaceRoot, "settings"); + mkdirSync(join(workspaceRoot, "agent-workspace"), { recursive: true }); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "bot.yaml"), + ` +agents: + main: + workspaceCwd: ./agent-workspace + model: gpt-5.5 +telegramTokenEnv: TEST_UNSET_TELEGRAM_TOKEN +bindings: + - chatId: 111 + agentId: main + kind: dm +`, + ); + + const config = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: "settings/bot.yaml", + }, + () => loadConfig(undefined, { resolveSecrets: false }), + ); + + assert.strictEqual(config.agents.main.workspaceCwd, join(workspaceRoot, "agent-workspace")); + }); +}); + describe("loadConfig top-level defaultModel validation", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); diff --git a/bot/src/__tests__/cron-merge.test.ts b/bot/src/__tests__/cron-merge.test.ts index efdbca22..8f083210 100644 --- a/bot/src/__tests__/cron-merge.test.ts +++ b/bot/src/__tests__/cron-merge.test.ts @@ -3,9 +3,35 @@ import assert from "node:assert/strict"; import { writeFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { loadMergedCrons, loadCronTask } from "../cron-runner.js"; +import { MINIME_CRONS_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; const TEST_DIR = join("/tmp", "cron-merge-test-" + Date.now()); +function withEnv(overrides: Record, fn: () => T): T { + const previous: Record = {}; + for (const key of Object.keys(overrides)) { + previous[key] = process.env[key]; + const value = overrides[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return fn(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + describe("loadMergedCrons", () => { beforeEach(() => { mkdirSync(TEST_DIR, { recursive: true }); @@ -29,6 +55,53 @@ describe("loadMergedCrons", () => { assert.strictEqual(crons[0].name, "base-task"); }); + it("uses MINIME_WORKSPACE_ROOT crons.yaml when no crons path is passed", () => { + const workspaceRoot = join(TEST_DIR, "workspace-default"); + mkdirSync(workspaceRoot, { recursive: true }); + writeFileSync(join(workspaceRoot, "crons.yaml"), `crons: + - name: workspace-task + schedule: "0 9 * * *" + prompt: "workspace" + agentId: main + deliveryChatId: 111111111 +`); + + const crons = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CRONS_PATH_ENV]: undefined, + }, + () => loadMergedCrons(), + ); + + assert.strictEqual(crons.length, 1); + assert.strictEqual(crons[0].name, "workspace-task"); + }); + + it("uses MINIME_CRONS_PATH relative to workspace root", () => { + const workspaceRoot = join(TEST_DIR, "workspace-crons-override"); + const cronsDir = join(workspaceRoot, "settings"); + mkdirSync(cronsDir, { recursive: true }); + writeFileSync(join(cronsDir, "scheduled.yaml"), `crons: + - name: override-task + schedule: "0 9 * * *" + prompt: "override" + agentId: main + deliveryChatId: 222222222 +`); + + const cron = withEnv( + { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CRONS_PATH_ENV]: "settings/scheduled.yaml", + }, + () => loadCronTask("override-task"), + ); + + assert.strictEqual(cron.name, "override-task"); + assert.strictEqual(cron.deliveryChatId, 222222222); + }); + it("appends local crons to base when names differ", () => { const cronsPath = join(TEST_DIR, "crons.yaml"); const localPath = join(TEST_DIR, "crons.local.yaml"); diff --git a/bot/src/__tests__/cron-runner-pi.test.ts b/bot/src/__tests__/cron-runner-pi.test.ts index 6a2868af..3104bde9 100644 --- a/bot/src/__tests__/cron-runner-pi.test.ts +++ b/bot/src/__tests__/cron-runner-pi.test.ts @@ -18,8 +18,10 @@ import { buildPiSpawnEnv, PI_CRON_WRAPPER_RELPATHS, PI_EXTENSIONS_DISABLED_ENV, + PI_GUARD_WORKSPACE_ROOT_ENV, } from "../pi-rpc-protocol.js"; import type { AgentConfig, CronJob } from "../types.js"; +import { MINIME_SCHEMA_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; interface SpawnCapture { command: string; @@ -94,7 +96,7 @@ function makeDeps( return { spawnSync: capturingSpawn(captures), buildAgentConfig: (_cron, cwd) => makeAgent(cwd), - buildEnv: buildPiSpawnEnv, + buildEnv: () => ({}), assembleContext: () => null, resolveExtensionArgs: () => [...GUARD_EXTENSION_ARGS], ...overrides, @@ -305,6 +307,38 @@ describe("cron-runner runPi", () => { assert.ok(args.includes("--no-context-files")); }); + it("builds the guarded env before assembling cron context", () => { + const workspaceRoot = makeWorkspace(); + const externalWorkspace = makeWorkspace(); + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + let assembled = false; + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; + const captures: SpawnCapture[] = []; + const deps = makeDeps(captures, { + buildEnv: buildPiSpawnEnv, + assembleContext: () => { + assembled = true; + return null; + }, + }); + + assert.throws( + () => runPi(makeCron(), externalWorkspace, deps), + /workspaceCwd must be inside the resolved workspace root/, + ); + assert.strictEqual(assembled, false); + assert.strictEqual(captures.length, 0); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + it("uses a scrubbed Pi env and sets HOME when the parent environment lacks it", () => { const oldHome = process.env.HOME; const oldClaudeToken = process.env.CLAUDE_CODE_OAUTH_TOKEN; @@ -324,6 +358,7 @@ describe("cron-runner runPi", () => { const oldSessionSecret = process.env[sessionSecretEnv]; const oldGithubToken = process.env[githubTokenEnv]; const oldAwsSecret = process.env[awsSecretEnv]; + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; try { delete process.env.HOME; @@ -339,8 +374,9 @@ describe("cron-runner runPi", () => { process.env[awsSecretEnv] = "fixture"; const ws = makeWorkspace(); + process.env[MINIME_WORKSPACE_ROOT_ENV] = ws; const captures: SpawnCapture[] = []; - const deps = makeDeps(captures); + const deps = makeDeps(captures, { buildEnv: buildPiSpawnEnv }); runPi(makeCron(), ws, deps); @@ -414,6 +450,47 @@ describe("cron-runner runPi", () => { } else { process.env[awsSecretEnv] = oldAwsSecret; } + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + + it("keeps the resolved guard workspace root and schema path in the hardened cron env", () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const oldSchema = process.env[MINIME_SCHEMA_PATH_ENV]; + const workspace = makeWorkspace(); + const agentWorkspace = join(workspace, "agent-workspace"); + mkdirSync(agentWorkspace, { recursive: true }); + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspace; + process.env[MINIME_SCHEMA_PATH_ENV] = "schemas/override.md"; + const captures: SpawnCapture[] = []; + const deps = makeDeps(captures, { + buildEnv: buildPiSpawnEnv, + buildAgentConfig: (_cron, cwd) => makeAgent(cwd), + }); + + runPi(makeCron(), agentWorkspace, deps); + + const env = captures[0].options.env ?? {}; + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], workspace); + assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], join(workspace, "schemas", "override.md")); + assert.strictEqual(captures[0].options.cwd, agentWorkspace); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + if (oldSchema === undefined) { + delete process.env[MINIME_SCHEMA_PATH_ENV]; + } else { + process.env[MINIME_SCHEMA_PATH_ENV] = oldSchema; + } } }); diff --git a/bot/src/__tests__/cron-runner.test.ts b/bot/src/__tests__/cron-runner.test.ts index 58afa7db..3a3b2dd7 100644 --- a/bot/src/__tests__/cron-runner.test.ts +++ b/bot/src/__tests__/cron-runner.test.ts @@ -927,7 +927,7 @@ bindings: [] } }); - it("getAgentWorkspace uses the same raw config resolution", () => { + it("getAgentWorkspace uses the same agent config resolution", () => { writeFileSync(CONFIG_FILE, `agents: worker: workspaceCwd: /tmp/worker-workspace @@ -942,6 +942,25 @@ bindings: [] }); }); + it("resolves relative workspaceCwd against the config workspace root", () => { + const workspace = join(CONFIG_DIR, "workspace"); + const agentWorkspace = join(workspace, "agent-workspace"); + const configFile = join(workspace, "config.yaml"); + mkdirSync(agentWorkspace, { recursive: true }); + writeFileSync(configFile, `agents: + main: + workspaceCwd: ./agent-workspace + model: openai-codex/gpt-5.5 +bindings: [] +`); + + assert.strictEqual(getAgentWorkspace("main", configFile), agentWorkspace); + assert.deepStrictEqual(resolveCronAgentData("main", configFile), { + id: "main", + workspaceCwd: agentWorkspace, + }); + }); + it("throws before spawn when the cron agent is missing", () => { writeFileSync(CONFIG_FILE, `agents: main: diff --git a/bot/src/__tests__/message-content-index.test.ts b/bot/src/__tests__/message-content-index.test.ts index 1f62bbe4..d567b5e0 100644 --- a/bot/src/__tests__/message-content-index.test.ts +++ b/bot/src/__tests__/message-content-index.test.ts @@ -1,8 +1,9 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; import { recordMessage, lookupMessage, @@ -10,7 +11,12 @@ import { messageIndexSize, saveMessageIndex, restoreMessageIndex, + defaultMessageIndexPath, } from "../message-content-index.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); describe("message-content-index", () => { beforeEach(() => { @@ -195,6 +201,43 @@ describe("message-content-index persistence", () => { assert.strictEqual(data.length, 1); }); + it("default path preserves the source-checkout bot data location", () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + + try { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + assert.strictEqual(defaultMessageIndexPath(), join(BOT_ROOT, "data", "message-content-index.json")); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + + it("default path saves under the resolved workspace data directory when a workspace is explicit", () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const workspace = join(tmpDir, "workspace"); + mkdirSync(workspace, { recursive: true }); + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspace; + recordMessage(-100, 1, "alice", "Hello", "in"); + saveMessageIndex(); + + const expectedPath = join(workspace, "data", "message-content-index.json"); + assert.ok(existsSync(expectedPath)); + assert.strictEqual(JSON.parse(readFileSync(expectedPath, "utf8")).length, 1); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + it("preserves all fields through save/restore", () => { recordMessage(-100, 1, "alice", "Hello world", "in"); saveMessageIndex(indexPath); diff --git a/bot/src/__tests__/message-thread-cache.test.ts b/bot/src/__tests__/message-thread-cache.test.ts index 16d386c9..2954f51e 100644 --- a/bot/src/__tests__/message-thread-cache.test.ts +++ b/bot/src/__tests__/message-thread-cache.test.ts @@ -1,9 +1,22 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; -import { setThread, getThread, clearThreadCache, threadCacheSize, saveThreadCache, restoreThreadCache } from "../message-thread-cache.js"; +import { fileURLToPath } from "node:url"; +import { + setThread, + getThread, + clearThreadCache, + threadCacheSize, + saveThreadCache, + restoreThreadCache, + defaultThreadCachePath, +} from "../message-thread-cache.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); describe("message-thread-cache", () => { beforeEach(() => { @@ -169,6 +182,43 @@ describe("message-thread-cache persistence", () => { assert.strictEqual(data.length, 1); }); + it("default path preserves the source-checkout bot data location", () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + + try { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + assert.strictEqual(defaultThreadCachePath(), join(BOT_ROOT, "data", "thread-cache.json")); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + + it("default path saves under the resolved workspace data directory when a workspace is explicit", () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const workspace = join(tmpDir, "workspace"); + mkdirSync(workspace, { recursive: true }); + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspace; + setThread(-100, 1, 10); + saveThreadCache(); + + const expectedPath = join(workspace, "data", "thread-cache.json"); + assert.ok(existsSync(expectedPath)); + assert.strictEqual(JSON.parse(readFileSync(expectedPath, "utf8")).length, 1); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + it("preserves topicId 0 through save/restore", () => { setThread(-100, 1, 0); saveThreadCache(cachePath); diff --git a/bot/src/__tests__/package-install.test.ts b/bot/src/__tests__/package-install.test.ts new file mode 100644 index 00000000..610c182e --- /dev/null +++ b/bot/src/__tests__/package-install.test.ts @@ -0,0 +1,376 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); + +interface PackedFile { + path: string; +} + +interface PackResult { + filename: string; + files: PackedFile[]; +} + +function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, + npm_config_loglevel: "error", + ...extra, + }; +} + +function parseNpmPackJson(stdout: string): PackResult { + const trimmed = stdout.trim(); + const jsonStart = trimmed.lastIndexOf("\n["); + const jsonText = jsonStart === -1 ? trimmed : trimmed.slice(jsonStart + 1); + const parsed = JSON.parse(jsonText) as PackResult[]; + assert.equal(parsed.length, 1); + return parsed[0]; +} + +function runNpmPack(args: readonly string[], cwd = BOT_ROOT): PackResult { + const result = spawnSync("npm", ["pack", "--json", ...args], { + cwd, + encoding: "utf8", + env: commandEnv(), + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return parseNpmPackJson(result.stdout); +} + +function createWorkspace(root: string): string { + const workspace = join(root, "workspace"); + mkdirSync(join(workspace, "agent-workspace"), { recursive: true }); + mkdirSync(join(workspace, "schemas"), { recursive: true }); + writeFileSync( + join(workspace, "config.yaml"), + [ + "agents:", + " main:", + " workspaceCwd: ./agent-workspace", + " model: gpt-5.5", + "secrets:", + " sopsFile: missing.sops.yaml", + "telegramTokenSopsKey: telegram.bot_token", + "bindings:", + " - chatId: 111", + " agentId: main", + " kind: dm", + "", + ].join("\n"), + ); + writeFileSync( + join(workspace, "crons.yaml"), + [ + "crons:", + " - name: smoke", + " schedule: \"0 9 * * *\"", + " prompt: smoke", + " agentId: main", + " deliveryChatId: 111", + "", + ].join("\n"), + ); + writeFileSync( + join(workspace, "schema.md"), + [ + "# Default schema", + "", + "```write-allowlist", + "agent-workspace/", + "*.md", + "schema.md", + "```", + "", + ].join("\n"), + ); + writeFileSync( + join(workspace, "schemas", "override.md"), + [ + "# Override schema", + "", + "```write-allowlist", + "agent-workspace/", + "override-only/", + "schema.md", + "```", + "", + ].join("\n"), + ); + return workspace; +} + +function runInstalledBin(projectDir: string, args: readonly string[], workspace: string): SpawnSyncReturns { + return spawnSync(join(projectDir, "node_modules", ".bin", "minime-bot"), args, { + cwd: projectDir, + encoding: "utf8", + env: commandEnv({ + MINIME_WORKSPACE_ROOT: workspace, + }), + }); +} + +function assertPackFiles(files: readonly string[]): void { + for (const expected of [ + "dist/cli.js", + "dist/config.js", + "dist/cron-runner.js", + "dist/pi-rpc-protocol.js", + "dist/workspace-contract.js", + "dist/workspace-validator.js", + "dist/pi-extensions/guard.js", + "dist/pi-extensions/subagent-args.js", + "dist/pi-extensions/tavily.js", + "dist/pi-extensions/tavily-secret.js", + "dist/pi-extensions/write-allowlist-schema.js", + "dist/extensions/pi/guardian-protect-files.js", + "dist/extensions/pi/web-tools.js", + "dist/extensions/pi/subagent/agents.js", + "dist/extensions/pi/subagent/index.js", + "dist/extensions/pi/subagent/agents/worker.md", + "dist/extensions/pi/subagent/prompts/implement.md", + "scripts/deliver.sh", + ]) { + assert.ok(files.includes(expected), `expected npm pack to include ${expected}`); + } + + assert.ok(!files.some((file) => file.startsWith("src/")), "source TS should not be packed"); + assert.ok(!files.some((file) => file.startsWith(".claude/")), "source extension wrappers should not be packed"); + assert.ok(!files.some((file) => file.startsWith("test-fixtures/")), "workspace fixtures should not be packed"); + assert.ok(!files.some((file) => file.startsWith("dist/__tests__/")), "compiled tests should not be packed"); +} + +describe("package artifact install", () => { + it("npm pack --dry-run includes runtime artifacts and excludes source workspace files", { timeout: 120_000 }, () => { + const staleFiles = [ + join(BOT_ROOT, "dist", "stale-pack-artifact.js"), + join(BOT_ROOT, "dist", "stale-pack-artifact.d.ts"), + ]; + mkdirSync(join(BOT_ROOT, "dist"), { recursive: true }); + for (const staleFile of staleFiles) { + writeFileSync(staleFile, "throw new Error('stale artifact should not be packed');\n", "utf8"); + } + + try { + const dryRun = runNpmPack(["--dry-run"]); + const files = dryRun.files.map((file) => file.path); + assertPackFiles(files); + assert.ok(!files.includes("dist/stale-pack-artifact.js"), "stale dist JS should not be packed"); + assert.ok(!files.includes("dist/stale-pack-artifact.d.ts"), "stale dist declarations should not be packed"); + } finally { + for (const staleFile of staleFiles) { + try { + unlinkSync(staleFile); + } catch { + // npm prepack removes these when the clean build path is working. + } + } + } + }); + + it("installs the packed package and runs CLI plus generated Pi wrappers", { timeout: 180_000 }, () => { + const temp = mkdtempSync(join(tmpdir(), "minime-package-install-")); + const packDir = join(temp, "pack"); + const projectDir = join(temp, "project"); + mkdirSync(packDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + const workspace = createWorkspace(temp); + + try { + const pack = runNpmPack(["--pack-destination", packDir]); + assertPackFiles(pack.files.map((file) => file.path)); + const tarball = join(packDir, pack.filename); + assert.ok(existsSync(tarball), `expected tarball at ${tarball}`); + + writeFileSync(join(projectDir, "package.json"), "{\"type\":\"module\"}\n"); + const install = spawnSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { + cwd: projectDir, + encoding: "utf8", + env: commandEnv(), + }); + assert.equal(install.status, 0, install.stderr || install.stdout); + + const help = runInstalledBin(projectDir, ["--help"], workspace); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /minime-bot workspace validate --workspace /); + + const configValidate = runInstalledBin(projectDir, ["config", "validate", "--workspace", workspace], workspace); + assert.equal(configValidate.status, 0, configValidate.stderr); + assert.match(configValidate.stdout, /Config valid\./); + assert.doesNotMatch(configValidate.stdout, /telegram\.bot_token/); + + const workspaceValidate = runInstalledBin(projectDir, ["workspace", "validate", "--workspace", workspace], workspace); + assert.equal(workspaceValidate.status, 0, workspaceValidate.stderr); + assert.match(workspaceValidate.stdout, /Workspace valid\./); + assert.match(workspaceValidate.stdout, /Pi extension dir: .*node_modules\/minime\/dist\/extensions\/pi/); + + const artifactCheck = spawnSync( + process.execPath, + ["--input-type=module", "-e", INSTALLED_ARTIFACT_CHECK], + { + cwd: projectDir, + encoding: "utf8", + env: commandEnv({ + FIXTURE_WORKSPACE: workspace, + MINIME_WORKSPACE_ROOT: workspace, + SOURCE_BOT_ROOT: BOT_ROOT, + }), + }, + ); + assert.equal(artifactCheck.status, 0, artifactCheck.stderr || artifactCheck.stdout); + } finally { + rmSync(temp, { recursive: true, force: true }); + } + }); +}); + +const INSTALLED_ARTIFACT_CHECK = String.raw` +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { join, relative } from "node:path"; +import { pathToFileURL } from "node:url"; + +const workspace = process.env.FIXTURE_WORKSPACE; +const sourceBotRoot = process.env.SOURCE_BOT_ROOT; +assert.ok(workspace, "FIXTURE_WORKSPACE is required"); +assert.ok(sourceBotRoot, "SOURCE_BOT_ROOT is required"); + +const projectDir = process.cwd(); +const packageDir = join(projectDir, "node_modules", "minime"); +const artifactDir = join(packageDir, "dist", "extensions", "pi"); +const agentWorkspace = join(workspace, "agent-workspace"); +const importFile = (path) => import(pathToFileURL(path).href); +const importPackageFile = (relpath) => importFile(join(packageDir, relpath)); + +const piRpc = await importPackageFile("dist/pi-rpc-protocol.js"); +const extensionArgs = piRpc.resolvePiExtensionArgs({ env: {} }); +const extensionPaths = extensionArgs.filter((arg) => arg !== "--extension"); +assert.deepEqual( + extensionPaths.map((path) => relative(artifactDir, path)), + ["guardian-protect-files.js", "web-tools.js", "subagent/index.js"], +); + +for (const extensionPath of extensionPaths) { + const mod = await importFile(extensionPath); + assert.equal(typeof mod.default, "function", extensionPath); +} + +const configMod = await importPackageFile("dist/config.js"); +const loadedConfig = configMod.loadConfig(join(workspace, "config.yaml"), { + resolveSecrets: false, + workspaceRoot: workspace, +}); +assert.equal(loadedConfig.agents.main.workspaceCwd, agentWorkspace); +const childEnv = piRpc.buildPiSpawnEnv(loadedConfig.agents.main); +assert.equal(childEnv.PI_GUARD_WORKSPACE_ROOT, workspace); + +const workspaceContract = await importPackageFile("dist/workspace-contract.js"); +const validator = await importPackageFile("dist/workspace-validator.js"); +const schema = await importPackageFile("dist/pi-extensions/write-allowlist-schema.js"); + +const defaultContract = workspaceContract.resolveWorkspaceContract({ + workspace, + cwd: projectDir, + env: {}, +}); +const defaultResult = validator.validateWorkspaceContract(defaultContract, { env: {} }); +assert.equal(validator.workspaceValidationErrors(defaultResult).length, 0); +assert.equal(defaultResult.schema.entries.includes("override-only/"), false); + +const overrideSchemaPath = join(workspace, "schemas", "override.md"); +const overrideEnv = { [workspaceContract.MINIME_SCHEMA_PATH_ENV]: overrideSchemaPath }; +const overrideContract = workspaceContract.resolveWorkspaceContract({ + workspace, + cwd: projectDir, + env: overrideEnv, +}); +const overrideResult = validator.validateWorkspaceContract(overrideContract, { env: overrideEnv }); +assert.equal(validator.workspaceValidationErrors(overrideResult).length, 0); +assert.deepEqual( + overrideResult.schema.entries, + schema.readWriteAllowlistEntriesForGuard(overrideContract.paths.schemaPath), +); +assert.equal(overrideResult.schema.entries.includes("override-only/"), true); + +const guardian = await importFile(join(artifactDir, "guardian-protect-files.js")); +let toolCallHandler; +const guardianPi = { + on(event, handler) { + if (event === "tool_call") { + toolCallHandler = handler; + } + }, +}; +guardian.default(guardianPi); +assert.equal(typeof toolCallHandler, "function"); + +delete process.env.MINIME_SCHEMA_PATH; +process.env.PI_GUARD_WORKSPACE_ROOT = workspace; +const defaultVerdict = await toolCallHandler( + { toolName: "write", input: { path: "note.txt" } }, + { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, +); +assert.equal(defaultVerdict, undefined); + +const protectedVerdict = await toolCallHandler( + { toolName: "write", input: { path: join(workspace, "README.md") } }, + { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, +); +assert.equal(protectedVerdict.block, true); + +process.env.MINIME_SCHEMA_PATH = overrideSchemaPath; +const overrideVerdict = await toolCallHandler( + { toolName: "write", input: { path: join(workspace, "override-only", "file.txt") } }, + { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, +); +assert.equal(overrideVerdict, undefined); + +const registeredTools = []; +const resourceHandlers = []; +const fakePi = { + on(event, handler) { + if (event === "resources_discover") { + resourceHandlers.push(handler); + } + }, + registerTool(tool) { + registeredTools.push(tool.name); + }, +}; + +const webTools = await importFile(join(artifactDir, "web-tools.js")); +webTools.default(fakePi); +const subagent = await importFile(join(artifactDir, "subagent", "index.js")); +subagent.default(fakePi); +assert.deepEqual( + registeredTools.filter((name) => ["web_search", "web_fetch", "subagent"].includes(name)).sort(), + ["subagent", "web_fetch", "web_search"], +); + +const agentsMod = await importFile(join(artifactDir, "subagent", "agents.js")); +const discovery = agentsMod.discoverAgents(workspace, "project"); +const bundledWorker = discovery.agents.find((agent) => agent.name === "worker" && agent.source === "bundled"); +assert.ok(bundledWorker, "expected bundled worker agent from installed artifact"); +assert.ok(bundledWorker.filePath.startsWith(join(artifactDir, "subagent", "agents"))); +assert.equal(bundledWorker.filePath.startsWith(sourceBotRoot), false); + +assert.equal(resourceHandlers.length, 1); +const resources = resourceHandlers[0](); +assert.deepEqual(resources.promptPaths, [join(artifactDir, "subagent", "prompts")]); +assert.ok(existsSync(join(resources.promptPaths[0], "implement.md"))); +assert.ok(existsSync(join(artifactDir, "subagent", "agents", "worker.md"))); +`; diff --git a/bot/src/__tests__/pi-rpc-protocol.test.ts b/bot/src/__tests__/pi-rpc-protocol.test.ts index 027d76a9..e97f52db 100644 --- a/bot/src/__tests__/pi-rpc-protocol.test.ts +++ b/bot/src/__tests__/pi-rpc-protocol.test.ts @@ -19,7 +19,10 @@ import { _resetPiContextCache } from "../pi-context-assembler.js"; import { NewlineOnlyJsonlSplitter, PI_CRON_WRAPPER_RELPATHS, + PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS, PI_EXTENSION_WRAPPER_RELPATHS, + PI_GUARD_WORKSPACE_ROOT_ENV, + PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS, PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, buildGetStateCommand, buildPiPromptCommand, @@ -30,14 +33,17 @@ import { extractPiTextDelta, isPiAlreadyProcessingRejection, parsePiEvent, + piExtensionRelpathForDir, readPiStream, resolvePiExtensionArgs, sendPiGetState, sendPiPrompt, sendPiSteer, + spawnPiRpcSession, type PiExtensionResolveOptions, } from "../pi-rpc-protocol.js"; import type { AgentConfig, StreamLine } from "../types.js"; +import { MINIME_SCHEMA_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; const testAgent: AgentConfig = { id: "main", @@ -416,6 +422,10 @@ describe("Pi extension loading (--extension)", () => { [...PI_EXTENSION_WRAPPER_RELPATHS], ["guardian-protect-files.ts", "web-tools.ts", "subagent/index.ts"], ); + assert.deepStrictEqual( + [...PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS], + ["guardian-protect-files.js", "web-tools.js", "subagent/index.js"], + ); }); it("the subagent-child wrapper subset is A1 guard + A2 web-tools, without A3 subagent recursion", () => { @@ -423,6 +433,10 @@ describe("Pi extension loading (--extension)", () => { "guardian-protect-files.ts", "web-tools.ts", ]); + assert.deepStrictEqual([...PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS], [ + "guardian-protect-files.js", + "web-tools.js", + ]); }); it("the Pi cron wrapper subset is A1 guard only", () => { @@ -444,6 +458,35 @@ describe("Pi extension loading (--extension)", () => { ]); }); + it("maps source wrapper relpaths to built JS relpaths for package artifact dirs", () => { + const artifactDir = resolve("/tmp/project/node_modules/minime/dist/extensions/pi"); + + assert.equal( + piExtensionRelpathForDir(artifactDir, "guardian-protect-files.ts"), + "guardian-protect-files.js", + ); + assert.equal( + piExtensionRelpathForDir(artifactDir, "subagent/index.ts"), + "subagent/index.js", + ); + assert.equal(piExtensionRelpathForDir(FAKE_DIR, "subagent/index.ts"), "subagent/index.ts"); + }); + + it("resolves JS wrappers from a package artifact extension dir", () => { + const artifactDir = resolve("/tmp/project/node_modules/minime/dist/extensions/pi"); + const args = resolvePiExtensionArgs({ + extensionsDir: artifactDir, + env: {}, + exists: () => true, + }); + + assert.deepStrictEqual(args, [ + "--extension", resolve(artifactDir, "guardian-protect-files.js"), + "--extension", resolve(artifactDir, "web-tools.js"), + "--extension", resolve(artifactDir, "subagent/index.js"), + ]); + }); + it("the subset still honors the kill-switch (subagent child spawns bare when disabled)", () => { const args = resolvePiExtensionArgs({ extensionsDir: FAKE_DIR, @@ -576,6 +619,24 @@ describe("Pi extension loading (--extension)", () => { }); describe("buildPiSpawnEnv", () => { + beforeEach(() => { + mkdirSync(testAgent.workspaceCwd, { recursive: true }); + }); + + function withWorkspaceRoot(workspaceRoot: string, fn: () => T): T { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; + return fn(); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + } + it("allows only Pi runtime env and removes ambient credentials", () => { const envKeys = [ "ANTHROPIC_API_KEY", @@ -597,6 +658,9 @@ describe("buildPiSpawnEnv", () => { "TAVILY_API_KEY", "TELEGRAM_BOT_TOKEN", "MINIME_SESSION_SECRET", + "MINIME_SCHEMA_PATH", + "MINIME_WORKSPACE_ROOT", + "PI_GUARD_WORKSPACE_ROOT", ]; const oldValues = new Map(envKeys.map((key) => [key, process.env[key]])); @@ -620,6 +684,8 @@ describe("buildPiSpawnEnv", () => { process.env.TAVILY_API_KEY = "fixture"; process.env.TELEGRAM_BOT_TOKEN = "fixture"; process.env.MINIME_SESSION_SECRET = "fixture"; + process.env[MINIME_WORKSPACE_ROOT_ENV] = "/tmp"; + process.env[PI_GUARD_WORKSPACE_ROOT_ENV] = "/wrong-root"; const env = buildPiSpawnEnv(testAgent); @@ -632,6 +698,9 @@ describe("buildPiSpawnEnv", () => { assert.strictEqual(env.TAVILY_API_KEY, undefined); assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); assert.strictEqual(env.MINIME_SESSION_SECRET, undefined); + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], undefined); + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], "/tmp"); + assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], "/tmp/schema.md"); assert.strictEqual(env.ANTHROPIC_OAUTH_TOKEN, undefined); assert.strictEqual(env.AWS_ACCESS_KEY_ID, undefined); assert.strictEqual(env.AWS_SECRET_ACCESS_KEY, undefined); @@ -655,7 +724,7 @@ describe("buildPiSpawnEnv", () => { }); it("includes /opt/homebrew/bin in PATH", () => { - const env = buildPiSpawnEnv(testAgent); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); assert.ok(env.PATH?.includes("/opt/homebrew/bin")); }); @@ -665,7 +734,7 @@ describe("buildPiSpawnEnv", () => { try { process.env.PATH = "/opt/homebrew/bin:/usr/bin"; - const env = buildPiSpawnEnv(testAgent); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); } finally { @@ -682,10 +751,10 @@ describe("buildPiSpawnEnv", () => { try { process.env.PATH = ""; - assert.strictEqual(buildPiSpawnEnv(testAgent).PATH, "/opt/homebrew/bin"); + assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)).PATH, "/opt/homebrew/bin"); process.env.PATH = ":/usr/bin::/bin:"; - assert.strictEqual(buildPiSpawnEnv(testAgent).PATH, "/opt/homebrew/bin:/usr/bin:/bin"); + assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)).PATH, "/opt/homebrew/bin:/usr/bin:/bin"); } finally { if (oldPath === undefined) { delete process.env.PATH; @@ -700,7 +769,7 @@ describe("buildPiSpawnEnv", () => { try { process.env.CLAUDECODE = "1"; - const env = buildPiSpawnEnv(testAgent); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); assert.strictEqual(env.CLAUDECODE, undefined); } finally { @@ -712,22 +781,128 @@ describe("buildPiSpawnEnv", () => { } }); - it("scrubs a stray PI_GUARD_WORKSPACE_ROOT so the parent guard anchors on its own ctx.cwd", () => { + it("fails closed when the agent workspace is outside the resolved workspace root", () => { + assert.throws( + () => withWorkspaceRoot("/tmp/minime-workspace", () => buildPiSpawnEnv(testAgent)), + /workspaceCwd must be inside the resolved workspace root/, + ); + }); + + it("fails closed when the agent workspace symlink resolves outside the workspace root", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-env-root-")); + const externalWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-env-external-")); + const symlinkWorkspace = join(workspaceRoot, "agent-workspace"); + symlinkSync(externalWorkspace, symlinkWorkspace, "dir"); + + try { + assert.throws( + () => withWorkspaceRoot(workspaceRoot, () => buildPiSpawnEnv({ ...testAgent, workspaceCwd: symlinkWorkspace })), + /workspaceCwd must be inside the resolved workspace root/, + ); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(externalWorkspace, { recursive: true, force: true }); + } + }); + + it("replaces a stray PI_GUARD_WORKSPACE_ROOT with the resolved workspace root", () => { const oldRoot = process.env.PI_GUARD_WORKSPACE_ROOT; + const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; try { - // Only the subagent CHILD spawn may set this; a top-level parent must never - // inherit it, else its A1 guard would anchor on the wrong workspace root. + process.env[MINIME_WORKSPACE_ROOT_ENV] = "/tmp"; process.env.PI_GUARD_WORKSPACE_ROOT = "/somewhere/else"; const env = buildPiSpawnEnv(testAgent); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, undefined); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); } finally { if (oldRoot === undefined) { delete process.env.PI_GUARD_WORKSPACE_ROOT; } else { process.env.PI_GUARD_WORKSPACE_ROOT = oldRoot; } + if (oldWorkspace === undefined) { + delete process.env.MINIME_WORKSPACE_ROOT; + } else { + process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; + } + } + }); + + it("propagates an absolute MINIME_SCHEMA_PATH override to Pi guard processes", () => { + const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; + const oldSchema = process.env.MINIME_SCHEMA_PATH; + + try { + process.env.MINIME_WORKSPACE_ROOT = "/tmp"; + process.env.MINIME_SCHEMA_PATH = "/tmp/override-schema.md"; + const env = buildPiSpawnEnv(testAgent); + + assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/override-schema.md"); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); + } finally { + if (oldWorkspace === undefined) { + delete process.env.MINIME_WORKSPACE_ROOT; + } else { + process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; + } + if (oldSchema === undefined) { + delete process.env.MINIME_SCHEMA_PATH; + } else { + process.env.MINIME_SCHEMA_PATH = oldSchema; + } + } + }); + + it("resolves a relative MINIME_SCHEMA_PATH override before passing it to Pi guard processes", () => { + const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; + const oldSchema = process.env.MINIME_SCHEMA_PATH; + + try { + process.env.MINIME_WORKSPACE_ROOT = "/tmp"; + process.env.MINIME_SCHEMA_PATH = "schemas/override.md"; + const env = buildPiSpawnEnv(testAgent); + + assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/schemas/override.md"); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); + } finally { + if (oldWorkspace === undefined) { + delete process.env.MINIME_WORKSPACE_ROOT; + } else { + process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; + } + if (oldSchema === undefined) { + delete process.env.MINIME_SCHEMA_PATH; + } else { + process.env.MINIME_SCHEMA_PATH = oldSchema; + } + } + }); +}); + +describe("spawnPiRpcSession workspace validation", () => { + it("validates containment before assembling context artifacts", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-root-")); + const externalWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-external-")); + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + writeFileSync(join(externalWorkspace, "CLAUDE.md"), "# External\n\nBODY", "utf8"); + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; + + assert.throws( + () => spawnPiRpcSession({ ...testAgent, id: "external", workspaceCwd: externalWorkspace }), + /workspaceCwd must be inside the resolved workspace root/, + ); + assert.ok(!existsSync(join(externalWorkspace, ".tmp")), "context artifacts must not be written before validation"); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(externalWorkspace, { recursive: true, force: true }); } }); }); diff --git a/bot/src/__tests__/project-naming.test.ts b/bot/src/__tests__/project-naming.test.ts index f31e2181..0dd23a88 100644 --- a/bot/src/__tests__/project-naming.test.ts +++ b/bot/src/__tests__/project-naming.test.ts @@ -145,12 +145,13 @@ describe("project naming", () => { ); }); - it("config.ts resolves config.yaml from workspace root", () => { + it("config.ts resolves config.yaml through workspace contract defaults", () => { const configTs = readRepoFile("bot/src/config.ts"); - // Should resolve 2 levels up from __dirname (bot/src/ -> bot/ -> workspace root) + // Default path ownership lives in workspace-contract.ts so package-installed + // and source-checkout modes share one resolver. assert.ok( - configTs.includes('resolve(__dirname, "..", "..", "config.yaml")'), - "config.ts should resolve config.yaml from workspace root (2 levels up)" + configTs.includes("resolveWorkspaceContract().paths.configPath"), + "config.ts should use the workspace contract for its default config path" ); // Should check for and load config.local.yaml when present assert.ok( diff --git a/bot/src/__tests__/session-store.test.ts b/bot/src/__tests__/session-store.test.ts index 4c4385e0..cee03a20 100644 --- a/bot/src/__tests__/session-store.test.ts +++ b/bot/src/__tests__/session-store.test.ts @@ -155,12 +155,10 @@ describe("SessionStore", () => { assert.ok(existsSync(deepPath)); }); - it("default path resolves relative to project dir (not hardcoded)", () => { - // Verify the default path is derived from module location, ending with data/sessions.json + it("default path resolves through the workspace contract", () => { const store = new SessionStore(); - const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); - const expectedPath = resolve(repoRoot, "data", "sessions.json"); - // Access internal path to verify it matches the dynamically-resolved project path + const botRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + const expectedPath = resolve(botRoot, "data", "sessions.json"); assert.strictEqual((store as any).path, expectedPath); assert.ok(expectedPath.endsWith("/data/sessions.json"), "Default path must end with data/sessions.json"); }); diff --git a/bot/src/__tests__/workspace-contract.test.ts b/bot/src/__tests__/workspace-contract.test.ts new file mode 100644 index 00000000..0b913ba5 --- /dev/null +++ b/bot/src/__tests__/workspace-contract.test.ts @@ -0,0 +1,187 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_SCHEMA_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, + resolveWorkspaceContract, + workspaceContractDiagnostics, + type WorkspaceContractPaths, +} from "../workspace-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); +const REPO_ROOT = resolve(BOT_ROOT, ".."); + +function assertAbsolutePaths(paths: WorkspaceContractPaths): void { + for (const [name, path] of Object.entries(paths)) { + assert.ok(isAbsolute(path), `${name} should be absolute: ${path}`); + assert.strictEqual(path, normalize(path), `${name} should be normalized`); + } +} + +describe("workspace contract resolver", () => { + it("preserves the current source checkout path layout by default", () => { + const contract = resolveWorkspaceContract({ + cwd: "/tmp/ignored-cwd", + env: {}, + homeDir: "/tmp/minime-home", + pid: 12345, + }); + + assertAbsolutePaths(contract.paths); + assert.strictEqual(contract.paths.packageRoot, BOT_ROOT); + assert.strictEqual(contract.paths.botRoot, BOT_ROOT); + assert.strictEqual(contract.paths.workspaceRoot, REPO_ROOT); + assert.strictEqual(contract.paths.configPath, resolve(REPO_ROOT, "config.yaml")); + assert.strictEqual(contract.paths.cronsPath, resolve(REPO_ROOT, "crons.yaml")); + assert.strictEqual(contract.paths.schemaPath, resolve(REPO_ROOT, "schema.md")); + assert.strictEqual(contract.paths.piExtensionDir, resolve(BOT_ROOT, ".claude", "extensions")); + assert.strictEqual(contract.paths.dataDir, resolve(REPO_ROOT, "data")); + assert.strictEqual(contract.paths.sessionStorePath, resolve(BOT_ROOT, "data", "sessions.json")); + assert.strictEqual(contract.effectivePaths.sessionStorePath.source, "current-repo-fallback"); + assert.strictEqual(contract.paths.logDir, "/tmp/minime-home/.minime/logs"); + assert.strictEqual(contract.paths.mediaBaseDir, "/tmp/bot-media"); + assert.strictEqual(contract.paths.runtimeDir, resolve(REPO_ROOT, ".tmp")); + assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "current-repo-fallback"); + assert.deepStrictEqual(contract.warnings, []); + }); + + it("uses package artifact Pi wrappers for a built source checkout", () => { + const builtSourceModuleUrl = pathToFileURL( + join(BOT_ROOT, "dist", "workspace-contract.js"), + ).href; + const contract = resolveWorkspaceContract({ + cwd: "/tmp/ignored-cwd", + env: {}, + moduleUrl: builtSourceModuleUrl, + homeDir: "/tmp/minime-home", + }); + + assert.strictEqual(contract.paths.packageRoot, BOT_ROOT); + assert.strictEqual(contract.paths.piExtensionDir, resolve(BOT_ROOT, "dist", "extensions", "pi")); + }); + + it("uses package artifact Pi wrappers for an installed package", () => { + const installedModuleUrl = pathToFileURL( + join(tmpdir(), "project", "node_modules", "minime", "dist", "workspace-contract.js"), + ).href; + const contract = resolveWorkspaceContract({ + cwd: "/tmp/install-cwd", + env: {}, + moduleUrl: installedModuleUrl, + homeDir: "/tmp/minime-home", + }); + + assert.strictEqual(basename(contract.paths.packageRoot), "minime"); + assert.strictEqual( + contract.paths.piExtensionDir, + normalize(join(tmpdir(), "project", "node_modules", "minime", "dist", "extensions", "pi")), + ); + }); + + it("uses an explicit CLI workspace before MINIME_WORKSPACE_ROOT", () => { + const cwd = mkdtempSync(join(tmpdir(), "minime-contract-cwd-")); + const cliWorkspace = "cli-workspace"; + const envWorkspace = join(tmpdir(), "ignored-env-workspace"); + const contract = resolveWorkspaceContract({ + cwd, + workspace: cliWorkspace, + env: { [MINIME_WORKSPACE_ROOT_ENV]: envWorkspace }, + homeDir: "/tmp/minime-home", + }); + + assert.strictEqual(contract.paths.workspaceRoot, resolve(cwd, cliWorkspace)); + assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "cli"); + assert.strictEqual(contract.paths.configPath, resolve(cwd, cliWorkspace, "config.yaml")); + }); + + it("uses MINIME_WORKSPACE_ROOT for workspace-relative defaults", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "minime-contract-workspace-")); + const contract = resolveWorkspaceContract({ + cwd: "/tmp/ignored-cwd", + env: { [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot }, + homeDir: "/tmp/minime-home", + }); + + assertAbsolutePaths(contract.paths); + assert.strictEqual(contract.paths.workspaceRoot, workspaceRoot); + assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "env"); + assert.strictEqual(contract.paths.configPath, join(workspaceRoot, "config.yaml")); + assert.strictEqual(contract.paths.cronsPath, join(workspaceRoot, "crons.yaml")); + assert.strictEqual(contract.paths.schemaPath, join(workspaceRoot, "schema.md")); + assert.strictEqual(contract.paths.dataDir, join(workspaceRoot, "data")); + assert.strictEqual(contract.paths.runtimeDir, join(workspaceRoot, ".tmp")); + }); + + it("uses explicit config, crons, and schema path overrides", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "minime-contract-overrides-")); + const absoluteCronsPath = join(workspaceRoot, "absolute", "crons.yaml"); + const contract = resolveWorkspaceContract({ + cwd: "/tmp/ignored-cwd", + env: { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: "custom/config.yaml", + [MINIME_CRONS_PATH_ENV]: absoluteCronsPath, + [MINIME_SCHEMA_PATH_ENV]: "schemas/write-allowlist.md", + LOG_DIR: "/tmp/minime-logs", + MINIME_TEST_MEDIA_BASE: "/tmp/minime-media", + }, + homeDir: "/tmp/minime-home", + pid: 6789, + }); + + assert.strictEqual(contract.paths.configPath, join(workspaceRoot, "custom", "config.yaml")); + assert.strictEqual(contract.paths.cronsPath, absoluteCronsPath); + assert.strictEqual(contract.paths.schemaPath, join(workspaceRoot, "schemas", "write-allowlist.md")); + assert.strictEqual(contract.paths.logDir, "/tmp/minime-logs"); + assert.strictEqual(contract.paths.mediaBaseDir, "/tmp/minime-media/6789"); + assert.strictEqual(contract.effectivePaths.configPath.source, "env"); + assert.strictEqual(contract.effectivePaths.cronsPath.source, "env"); + assert.strictEqual(contract.effectivePaths.schemaPath.source, "env"); + }); + + it("does not guess a package install parent directory as the workspace", () => { + const cwd = mkdtempSync(join(tmpdir(), "minime-contract-install-cwd-")); + const installedModuleUrl = pathToFileURL( + join(tmpdir(), "project", "node_modules", "minime", "dist", "workspace-contract.js"), + ).href; + const contract = resolveWorkspaceContract({ + cwd, + env: {}, + moduleUrl: installedModuleUrl, + homeDir: "/tmp/minime-home", + }); + + assert.strictEqual(basename(contract.paths.packageRoot), "minime"); + assert.strictEqual(contract.paths.workspaceRoot, cwd); + assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "cwd-fallback"); + assert.match(contract.warnings.join("\n"), /Pass --workspace or MINIME_WORKSPACE_ROOT/); + }); + + it("returns structured diagnostics without reading or echoing secret env values", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "minime-contract-diagnostics-")); + const contract = resolveWorkspaceContract({ + cwd: "/tmp/ignored-cwd", + env: { + [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, + [MINIME_CONFIG_PATH_ENV]: "missing-config.yaml", + [MINIME_SCHEMA_PATH_ENV]: "missing-schema.md", + TELEGRAM_TOKEN: "do-not-print-me", + SOPS_AGE_KEY: "do-not-print-me-either", + }, + homeDir: "/tmp/minime-home", + }); + const diagnostics = workspaceContractDiagnostics(contract); + const serialized = JSON.stringify({ diagnostics, warnings: contract.warnings }); + + assert.strictEqual(diagnostics.configPath.path, join(workspaceRoot, "missing-config.yaml")); + assert.strictEqual(diagnostics.schemaPath.path, join(workspaceRoot, "missing-schema.md")); + assert.doesNotMatch(serialized, /do-not-print-me/); + }); +}); diff --git a/bot/src/__tests__/workspace-validator.test.ts b/bot/src/__tests__/workspace-validator.test.ts new file mode 100644 index 00000000..8c128aa0 --- /dev/null +++ b/bot/src/__tests__/workspace-validator.test.ts @@ -0,0 +1,224 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { classifyToolCall } from "../pi-extensions/guard.js"; +import { + readWriteAllowlistEntriesForGuard, + resolveWriteAllowlistSchemaPath, +} from "../pi-extensions/write-allowlist-schema.js"; +import { validateWorkspaceContract, workspaceValidationErrors } from "../workspace-validator.js"; +import { + MINIME_SCHEMA_PATH_ENV, + resolveWorkspaceContract, +} from "../workspace-contract.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); +const MINIMAL_WORKSPACE_FIXTURE = join(BOT_ROOT, "test-fixtures", "minimal-workspace"); + +const fixtures: string[] = []; + +after(() => { + for (const fixture of fixtures) { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +function createWorkspace(options: { + schema?: string | null; + extraFiles?: Record; + workspaceCwd?: string; +} = {}): string { + const workspace = mkdtempSync(join(tmpdir(), "minime-validator-workspace-")); + fixtures.push(workspace); + mkdirSync(join(workspace, "agent-workspace"), { recursive: true }); + writeFileSync( + join(workspace, "config.yaml"), + [ + "agents:", + " main:", + ` workspaceCwd: ${options.workspaceCwd ?? "./agent-workspace"}`, + " model: gpt-5.5", + "telegramTokenEnv: MINIME_FIXTURE_TELEGRAM_TOKEN", + "bindings:", + " - chatId: 111", + " agentId: main", + " kind: dm", + "", + ].join("\n"), + ); + writeFileSync( + join(workspace, "crons.yaml"), + [ + "crons:", + " - name: smoke", + " schedule: \"0 9 * * *\"", + " prompt: smoke", + " agentId: main", + " deliveryChatId: 111", + "", + ].join("\n"), + ); + + if (options.schema !== null) { + writeFileSync(join(workspace, "schema.md"), options.schema ?? validSchema(["agent-workspace/", "*.md", "schema.md"])); + } + + for (const [rel, content] of Object.entries(options.extraFiles ?? {})) { + const path = join(workspace, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + + return workspace; +} + +function validSchema(entries: readonly string[]): string { + return [ + "# Workspace schema", + "", + "```write-allowlist", + ...entries, + "```", + "", + ].join("\n"); +} + +function validate(workspace: string, env: NodeJS.ProcessEnv = {}) { + const contract = resolveWorkspaceContract({ workspace, cwd: workspace, env }); + return validateWorkspaceContract(contract, { env }); +} + +function guardBlocks(workspaceRoot: string, schemaPath: string, relPath: string): boolean { + const entries = readWriteAllowlistEntriesForGuard(schemaPath); + return classifyToolCall( + { toolName: "write", input: { path: relPath } }, + { workspaceRoot, writeAllowlist: entries }, + ).block; +} + +describe("workspace validator schema parity with the live guard parser", () => { + it("validates the tracked fixture from a package-installed-like layout", () => { + const projectDir = mkdtempSync(join(tmpdir(), "minime-validator-installed-")); + fixtures.push(projectDir); + const packageRoot = join(projectDir, "node_modules", "minime"); + const artifactExtensionDir = join(packageRoot, "dist", "extensions", "pi"); + mkdirSync(artifactExtensionDir, { recursive: true }); + const moduleUrl = pathToFileURL(join(packageRoot, "dist", "workspace-contract.js")).href; + const contract = resolveWorkspaceContract({ + workspace: MINIMAL_WORKSPACE_FIXTURE, + cwd: projectDir, + moduleUrl, + env: {}, + }); + + const result = validateWorkspaceContract(contract, { env: {} }); + + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.strictEqual(result.contract.effectivePaths.workspaceRoot.source, "cli"); + assert.strictEqual(result.contract.paths.piExtensionDir, artifactExtensionDir); + assert.deepStrictEqual(result.schema?.entries, ["agent-workspace/", "*.md", "schema.md"]); + assert.strictEqual(result.crons?.length, 1); + }); + + it("valid schema: validator entries match guard entries and guard verdicts", () => { + const workspace = createWorkspace(); + const result = validate(workspace); + const guardEntries = readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath); + + assert.deepStrictEqual(result.schema?.entries, guardEntries); + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), false); + assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "unregistered/file.txt"), true); + }); + + it("rejects agent workspaceCwd outside the resolved workspace root", () => { + const externalWorkspace = mkdtempSync(join(tmpdir(), "minime-validator-external-agent-")); + fixtures.push(externalWorkspace); + const workspace = createWorkspace({ workspaceCwd: externalWorkspace }); + const result = validate(workspace); + + assert.match( + workspaceValidationErrors(result).map((item) => item.message).join("\n"), + /workspaceCwd must be inside the resolved workspace root/, + ); + }); + + it("rejects symlinked agent workspaceCwd that resolves outside the workspace root", () => { + const externalWorkspace = mkdtempSync(join(tmpdir(), "minime-validator-external-agent-")); + fixtures.push(externalWorkspace); + const workspace = createWorkspace(); + const agentWorkspace = join(workspace, "agent-workspace"); + rmSync(agentWorkspace, { recursive: true, force: true }); + symlinkSync(externalWorkspace, agentWorkspace, "dir"); + + const result = validate(workspace); + + assert.match( + workspaceValidationErrors(result).map((item) => item.message).join("\n"), + /workspaceCwd must be inside the resolved workspace root/, + ); + }); + + it("missing schema: validator fails hard and the guard parser fails closed", () => { + const workspace = createWorkspace({ schema: null }); + const result = validate(workspace); + + assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /schema file does not exist/); + assert.deepStrictEqual(result.schema?.entries, []); + assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); + assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + }); + + it("empty schema block: validator fails hard and guard parser fails closed", () => { + const workspace = createWorkspace({ schema: validSchema(["# only a comment"]) }); + const result = validate(workspace); + + assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /empty/); + assert.deepStrictEqual(result.schema?.entries, []); + assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); + assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + }); + + it("malformed schema block: validator fails hard and guard parser fails closed", () => { + const workspace = createWorkspace({ + schema: [ + "# Workspace schema", + "", + "```write-allowlist", + "agent-workspace/", + "", + ].join("\n"), + }); + const result = validate(workspace); + + assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /closing fence/); + assert.deepStrictEqual(result.schema?.entries, []); + assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); + assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + }); + + it("schema override: validator and guard use the same MINIME_SCHEMA_PATH", () => { + const workspace = createWorkspace({ + schema: validSchema(["default-only/"]), + extraFiles: { + "schemas/override.md": validSchema(["agent-workspace/", "schema.md"]), + }, + }); + const env = { [MINIME_SCHEMA_PATH_ENV]: "schemas/override.md" }; + const result = validate(workspace, env); + const guardSchemaPath = resolveWriteAllowlistSchemaPath(workspace, env); + + assert.equal(result.contract.paths.schemaPath, guardSchemaPath); + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.deepStrictEqual( + result.schema?.entries, + readWriteAllowlistEntriesForGuard(guardSchemaPath), + ); + assert.equal(guardBlocks(workspace, guardSchemaPath, "agent-workspace/note.md"), false); + assert.equal(guardBlocks(workspace, guardSchemaPath, "default-only/file.txt"), true); + }); +}); diff --git a/bot/src/cli.ts b/bot/src/cli.ts new file mode 100644 index 00000000..7ba24a5e --- /dev/null +++ b/bot/src/cli.ts @@ -0,0 +1,232 @@ +#!/usr/bin/env node +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadConfig } from "./config.js"; +import { + validateWorkspaceContract, + workspaceValidationErrors, + workspaceValidationWarnings, + type WorkspaceValidationResult, +} from "./workspace-validator.js"; +import { + resolveWorkspaceContract, + workspaceContractDiagnostics, + type ResolvedWorkspaceContract, +} from "./workspace-contract.js"; + +type WriteFn = (text: string) => void; + +export interface CliRunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + stdout?: WriteFn; + stderr?: WriteFn; +} + +interface ParsedArgs { + command: string[]; + help: boolean; + workspace?: string; +} + +class CliUsageError extends Error { + constructor(message: string) { + super(message); + this.name = "CliUsageError"; + } +} + +class WorkspaceValidationError extends Error { + constructor() { + super("Workspace validation failed."); + this.name = "WorkspaceValidationError"; + } +} + +const HELP_TEXT = `Usage: + minime-bot --help + minime-bot config validate --workspace + minime-bot workspace validate --workspace + +Options: + --workspace Workspace root. Defaults to MINIME_WORKSPACE_ROOT, then the current repo layout. + -h, --help Show this help text. +`; + +function writeLine(write: WriteFn, text = ""): void { + write(`${text}\n`); +} + +function parseArgs(argv: readonly string[]): ParsedArgs { + let help = false; + let workspace: string | undefined; + const command: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (arg === "--workspace") { + const value = argv[i + 1]; + if (!value || value.startsWith("-")) { + throw new CliUsageError("--workspace requires a path"); + } + workspace = value; + i += 1; + continue; + } + if (arg.startsWith("--workspace=")) { + const value = arg.slice("--workspace=".length).trim(); + if (!value) { + throw new CliUsageError("--workspace requires a path"); + } + workspace = value; + continue; + } + command.push(arg); + } + + return { command, help, workspace }; +} + +function resolveForCli(parsed: ParsedArgs, options: CliRunOptions): ResolvedWorkspaceContract { + return resolveWorkspaceContract({ + workspace: parsed.workspace, + env: options.env ?? process.env, + cwd: options.cwd ?? process.cwd(), + }); +} + +function formatEffectivePaths(contract: ResolvedWorkspaceContract): string[] { + const diagnostics = workspaceContractDiagnostics(contract); + return [ + ` workspace root: ${diagnostics.workspaceRoot.path} (${diagnostics.workspaceRoot.source})`, + ` config path: ${diagnostics.configPath.path} (${diagnostics.configPath.source})`, + ` crons path: ${diagnostics.cronsPath.path} (${diagnostics.cronsPath.source})`, + ` schema path: ${diagnostics.schemaPath.path} (${diagnostics.schemaPath.source})`, + ` Pi extension dir: ${diagnostics.piExtensionDir.path} (${diagnostics.piExtensionDir.source})`, + ]; +} + +function runConfigValidate(contract: ResolvedWorkspaceContract, stdout: WriteFn): void { + const config = loadConfig(contract.paths.configPath, { + resolveSecrets: false, + workspaceRoot: contract.paths.workspaceRoot, + }); + writeLine(stdout, "Config valid."); + writeLine(stdout, `Config path: ${contract.paths.configPath}`); + writeLine(stdout, `Agents: ${Object.keys(config.agents).join(", ")}`); + writeLine(stdout, `Telegram bindings: ${config.bindings.length}`); + if (config.discord) { + writeLine(stdout, `Discord bindings: ${config.discord.bindings.length}`); + } +} + +function writeWorkspaceValidationReport( + result: WorkspaceValidationResult, + stdout: WriteFn, +): void { + const errors = workspaceValidationErrors(result); + const warnings = workspaceValidationWarnings(result); + writeLine(stdout, errors.length === 0 ? "Workspace valid." : "Workspace invalid."); + writeLine(stdout, "Effective paths:"); + for (const line of formatEffectivePaths(result.contract)) { + writeLine(stdout, line); + } + if (result.config) { + writeLine(stdout, `Agents: ${Object.keys(result.config.agents).join(", ")}`); + } + writeLine(stdout, `Crons: ${result.crons === undefined ? "not present" : result.crons.length}`); + if (result.schema) { + writeLine(stdout, `Schema allow-list entries: ${result.schema.entries.length}`); + } + if (errors.length > 0) { + writeLine(stdout, "Hard failures:"); + for (const error of errors) { + writeLine(stdout, ` - ${error.message}`); + } + } + if (warnings.length > 0) { + writeLine(stdout, "Warnings:"); + for (const warning of warnings) { + writeLine(stdout, ` - ${warning.message}`); + } + } +} + +function runWorkspaceValidate( + contract: ResolvedWorkspaceContract, + stdout: WriteFn, + env: NodeJS.ProcessEnv, +): void { + const result = validateWorkspaceContract(contract, { env }); + writeWorkspaceValidationReport(result, stdout); + if (workspaceValidationErrors(result).length > 0) { + throw new WorkspaceValidationError(); + } +} + +export function runCli(argv: readonly string[] = process.argv.slice(2), options: CliRunOptions = {}): number { + const stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + + let parsed: ParsedArgs; + try { + parsed = parseArgs(argv); + } catch (err) { + writeLine(stderr, `Error: ${(err as Error).message}`); + return 2; + } + + if (parsed.help || parsed.command.length === 0) { + writeLine(stdout, HELP_TEXT.trimEnd()); + return 0; + } + + const [scope, action, ...rest] = parsed.command; + if (rest.length > 0) { + writeLine(stderr, `Error: unexpected argument: ${rest[0]}`); + return 2; + } + + try { + const contract = resolveForCli(parsed, options); + if (scope === "config" && action === "validate") { + runConfigValidate(contract, stdout); + return 0; + } + if (scope === "workspace" && action === "validate") { + runWorkspaceValidate(contract, stdout, options.env ?? process.env); + return 0; + } + } catch (err) { + writeLine(stderr, `Error: ${(err as Error).message}`); + return 1; + } + + writeLine(stderr, `Error: unknown command: ${parsed.command.join(" ")}`); + return 2; +} + +function realpathOrResolve(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +export function isDirectCliEntrypoint( + moduleUrl = import.meta.url, + entrypoint = process.argv[1], +): boolean { + return entrypoint !== undefined + && realpathOrResolve(entrypoint) === realpathOrResolve(fileURLToPath(moduleUrl)); +} + +if (isDirectCliEntrypoint()) { + process.exitCode = runCli(); +} diff --git a/bot/src/config.ts b/bot/src/config.ts index 87bcba71..abd6cb81 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -1,4 +1,4 @@ -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync, existsSync, realpathSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml } from "yaml"; @@ -6,14 +6,19 @@ import type { BotConfig, AgentConfig, TelegramBinding, TopicOverride, SessionDef import { log, parseLogLevel } from "./logger.js"; import { DEFAULT_MAX_MEDIA_BYTES } from "./media-store.js"; import { resolveSecret, sopsExtractExpression, type ExecFileSyncLike } from "./secrets.js"; +import { resolveAgentWorkspaceCwd, resolveWorkspaceContract } from "./workspace-contract.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_CONFIG_PATH = resolve(__dirname, "..", "..", "config.yaml"); const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; const CONFIGURED_SECRET_PLACEHOLDER = "[configured]"; const LEGACY_TELEGRAM_SERVICE_KEY_RE = /^telegramToken[Ss]ervice$/; const LEGACY_DISCORD_SERVICE_KEY_RE = /^token[Ss]ervice$/; +export function resolveConfigPath(configPath?: string): string { + return configPath === undefined + ? resolveWorkspaceContract().paths.configPath + : resolve(configPath); +} + // Derive the .local counterpart path: config.yaml → config.local.yaml function deriveLocalConfigPath(configPath: string): string { return configPath.replace(/\.yaml$/, ".local.yaml"); @@ -54,7 +59,7 @@ export function mergeDeep( // Load config.yaml and merge config.local.yaml on top if it exists. // Exported for use by cron-runner.ts and tests. export function loadRawMergedConfig(configPath?: string): Record { - const path = configPath ?? DEFAULT_CONFIG_PATH; + const path = resolveConfigPath(configPath); const base = (parseYaml(readFileSync(path, "utf8")) as Record) ?? {}; const localPath = deriveLocalConfigPath(path); if (existsSync(localPath)) { @@ -91,12 +96,25 @@ interface RawConfig { interface LoadConfigOptions { resolveSecrets?: boolean; secretExecFileSync?: ExecFileSyncLike; + workspaceRoot?: string; +} + +export function resolveConfigWorkspaceRoot(configPath?: string, workspaceRoot?: string): string { + if (workspaceRoot) { + return resolve(workspaceRoot); + } + const contract = resolveWorkspaceContract(); + if (configPath === undefined || resolveConfigPath(configPath) === contract.paths.configPath) { + return contract.paths.workspaceRoot; + } + return dirname(resolveConfigPath(configPath)); } export function validateAgent( raw: unknown, id: string, defaultModel?: string, + workspaceRoot?: string, ): AgentConfig { if (typeof raw !== "object" || raw === null) { throw new Error(`Agent "${id}" must be an object`); @@ -153,7 +171,9 @@ export function validateAgent( } return { id: String(obj.id ?? id), - workspaceCwd: obj.workspaceCwd, + workspaceCwd: workspaceRoot === undefined + ? obj.workspaceCwd + : resolveAgentWorkspaceCwd(workspaceRoot, obj.workspaceCwd), model, systemPrompt: typeof obj.systemPrompt === "string" ? obj.systemPrompt : undefined, thinking: obj.thinking as AgentConfig["thinking"] | undefined, @@ -387,7 +407,7 @@ function resolveConfiguredSopsFile(raw: RawConfig, configPath?: string): string } const sopsFile = optionalConfigString(raw.secrets.sopsFile, "secrets.sopsFile"); if (!sopsFile) return undefined; - return resolve(dirname(resolve(configPath ?? DEFAULT_CONFIG_PATH)), sopsFile); + return resolve(dirname(resolveConfigPath(configPath)), sopsFile); } function validateConfiguredSopsSource( @@ -439,6 +459,7 @@ export function loadTelegramToken(configPath?: string, options: LoadConfigOption export function loadConfig(configPath?: string, options: LoadConfigOptions = {}): BotConfig { const raw: RawConfig = loadRawMergedConfig(configPath) as RawConfig; const resolveSecrets = options.resolveSecrets !== false; + const workspaceRoot = resolveConfigWorkspaceRoot(configPath, options.workspaceRoot); if (!raw || typeof raw !== "object") { throw new Error("Config file is empty or invalid"); @@ -461,7 +482,7 @@ export function loadConfig(configPath?: string, options: LoadConfigOptions = {}) } const agents: Record = {}; for (const [id, agentRaw] of Object.entries(raw.agents)) { - agents[id] = validateAgent(agentRaw, id, defaultModel); + agents[id] = validateAgent(agentRaw, id, defaultModel, workspaceRoot); } // Resolve Telegram token from configured non-interactive secret sources. @@ -577,9 +598,23 @@ export function loadConfig(configPath?: string, options: LoadConfigOptions = {}) return { telegramToken, agents, bindings, sessionDefaults, logLevel, metricsPort, metricsHost, discord, adminChatId, defaultDeliveryChatId, defaultDeliveryThreadId }; } +function realpathOrResolve(path: string): string { + try { + return realpathSync(path); + } catch { + return resolve(path); + } +} + +function isDirectEntrypoint(): boolean { + const entrypoint = process.argv[1]; + return entrypoint !== undefined + && realpathOrResolve(entrypoint) === realpathOrResolve(fileURLToPath(import.meta.url)); +} + // CLI: validate config const validateWithoutSecrets = process.argv.includes("--validate-structure") || process.argv.includes("--no-resolve-secrets"); -if (process.argv.includes("--validate") || validateWithoutSecrets) { +if (isDirectEntrypoint() && (process.argv.includes("--validate") || validateWithoutSecrets)) { try { const config = loadConfig(undefined, { resolveSecrets: !validateWithoutSecrets }); log.info("config", "Config valid."); diff --git a/bot/src/cron-runner.ts b/bot/src/cron-runner.ts index d133976a..a08b704d 100644 --- a/bot/src/cron-runner.ts +++ b/bot/src/cron-runner.ts @@ -3,7 +3,12 @@ // Loads cron definition from crons.yaml, runs a Pi print-mode one-shot, delivers output to Telegram import { readFileSync, appendFileSync, mkdirSync, existsSync, writeFileSync, renameSync } from "node:fs"; -import { loadRawMergedConfig, loadTelegramToken, validateAgent } from "./config.js"; +import { + loadRawMergedConfig, + loadTelegramToken, + resolveConfigWorkspaceRoot, + validateAgent, +} from "./config.js"; import { execSync, spawnSync, @@ -22,14 +27,14 @@ import { PI_CRON_WRAPPER_RELPATHS, resolvePiExtensionArgs, PI_EXTENSIONS_DISABLED_ENV, + PI_GUARD_WORKSPACE_ROOT_ENV, shouldIncludePiChildEnvKey, } from "./pi-rpc-protocol.js"; import { assemblePiContext } from "./pi-context-assembler.js"; +import { MINIME_SCHEMA_PATH_ENV, resolveWorkspaceContract } from "./workspace-contract.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BOT_DIR = resolve(__dirname, ".."); -const REPO_ROOT = resolve(BOT_DIR, ".."); -const CRONS_PATH = resolve(REPO_ROOT, "crons.yaml"); const LOG_DIR = process.env.LOG_DIR ?? join(homedir(), ".minime", "logs"); const DELIVER_SCRIPT = resolve(BOT_DIR, "scripts", "deliver.sh"); @@ -167,10 +172,16 @@ function deriveCronsLocalPath(cronsPath: string): string { return cronsPath.replace(/\.yaml$/, ".local.yaml"); } +export function resolveCronsPath(cronsPath?: string): string { + return cronsPath === undefined + ? resolveWorkspaceContract().paths.cronsPath + : resolve(cronsPath); +} + // Load crons.yaml and merge crons.local.yaml on top if it exists. // Local crons win on duplicate name. Exported for tests. export function loadMergedCrons(cronsPath?: string): Array> { - const path = cronsPath ?? CRONS_PATH; + const path = resolveCronsPath(cronsPath); const raw: CronsYaml = parseYaml(readFileSync(path, "utf8")); if (!raw?.crons || !Array.isArray(raw.crons)) { throw new Error("crons.yaml missing 'crons' array"); @@ -291,6 +302,7 @@ function isCronPiThinking(value: unknown): value is PiThinkingLevel { } function resolveCronAgentData(agentId: string, configPath?: string): CronAgentData { + const workspaceRoot = resolveConfigWorkspaceRoot(configPath); const raw = loadRawMergedConfig(configPath) as { agents?: Record; defaultModel?: unknown; @@ -315,7 +327,7 @@ function resolveCronAgentData(agentId: string, configPath?: string): CronAgentDa } const defaultModel = typeof raw.defaultModel === "string" ? raw.defaultModel : undefined; - const agent = validateAgent(rawAgent, agentId, defaultModel); + const agent = validateAgent(rawAgent, agentId, defaultModel, workspaceRoot); if (!agent.workspaceCwd.trim()) { throw new Error(`Agent "${agentId}" missing workspaceCwd`); } @@ -612,7 +624,11 @@ function resolvePiCronExtensionArgs(resolveExtensionArgs: typeof resolvePiExtens function hardenPiCronEnv(rawEnv: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(rawEnv)) { - if (shouldIncludePiChildEnvKey(key)) { + if ( + shouldIncludePiChildEnvKey(key) || + key === MINIME_SCHEMA_PATH_ENV || + key === PI_GUARD_WORKSPACE_ROOT_ENV + ) { env[key] = value; } } @@ -637,6 +653,9 @@ function runPi( const agent = deps.buildAgentConfig(cron, workspaceCwd, agentData); const thinking = isCronPiThinking(agent.thinking) ? agent.thinking : "medium"; const systemInstruction = buildCronSystemInstruction(); + const env = hardenPiCronEnv(deps.buildEnv(agent)); + // Pi authenticates via ~/.pi/agent/auth.json, not legacy OAuth credentials. + env.HOME ||= homedir(); const args: string[] = [ "-p", buildPiCronPromptArg(cron.prompt), @@ -665,10 +684,6 @@ function runPi( args.push("--append-system-prompt", systemInstruction); args.push(...resolvePiCronExtensionArgs(deps.resolveExtensionArgs)); - const env = hardenPiCronEnv(deps.buildEnv(agent)); - // Pi authenticates via ~/.pi/agent/auth.json, not legacy OAuth credentials. - env.HOME ||= homedir(); - const result = deps.spawnSync(PI_BIN, args, { cwd: workspaceCwd, timeout: cron.timeout ?? DEFAULT_TIMEOUT_MS, diff --git a/bot/src/message-content-index.ts b/bot/src/message-content-index.ts index 413f1619..8519b21a 100644 --- a/bot/src/message-content-index.ts +++ b/bot/src/message-content-index.ts @@ -14,15 +14,12 @@ */ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import { log } from "./logger.js"; +import { resolveWorkspaceContract } from "./workspace-contract.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BOT_DIR = resolve(__dirname, ".."); const MAX_CACHE_SIZE = 10_000; const MAX_PREVIEW_LENGTH = 150; -const DEFAULT_INDEX_PATH = join(BOT_DIR, "data", "message-content-index.json"); export interface MessageRecord { from: string; @@ -33,6 +30,15 @@ export interface MessageRecord { const index = new Map(); +export function defaultMessageIndexPath(): string { + const contract = resolveWorkspaceContract(); + const dataDir = + contract.effectivePaths.workspaceRoot.source === "current-repo-fallback" + ? join(contract.paths.botRoot, "data") + : contract.paths.dataDir; + return join(dataDir, "message-content-index.json"); +} + function indexKey(chatId: number, messageId: number): string { return `${chatId}:${messageId}`; } @@ -87,7 +93,7 @@ export function messageIndexSize(): number { * Save the index to disk as JSON. Called on graceful shutdown. * Format: array of [key, value] pairs (Map serialization). */ -export function saveMessageIndex(path: string = DEFAULT_INDEX_PATH): void { +export function saveMessageIndex(path: string = defaultMessageIndexPath()): void { const tmpPath = path + ".tmp"; try { const dir = dirname(path); @@ -107,7 +113,7 @@ export function saveMessageIndex(path: string = DEFAULT_INDEX_PATH): void { * Missing or corrupt files result in an empty index (no crash). * Respects MAX_CACHE_SIZE — only loads up to 10K entries. */ -export function restoreMessageIndex(path: string = DEFAULT_INDEX_PATH): void { +export function restoreMessageIndex(path: string = defaultMessageIndexPath()): void { try { const data = readFileSync(path, "utf8"); index.clear(); diff --git a/bot/src/message-thread-cache.ts b/bot/src/message-thread-cache.ts index c11472ce..f709b435 100644 --- a/bot/src/message-thread-cache.ts +++ b/bot/src/message-thread-cache.ts @@ -13,17 +13,23 @@ */ import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; import { log } from "./logger.js"; +import { resolveWorkspaceContract } from "./workspace-contract.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const BOT_DIR = resolve(__dirname, ".."); const MAX_CACHE_SIZE = 10_000; -const DEFAULT_CACHE_PATH = join(BOT_DIR, "data", "thread-cache.json"); const cache = new Map(); +export function defaultThreadCachePath(): string { + const contract = resolveWorkspaceContract(); + const dataDir = + contract.effectivePaths.workspaceRoot.source === "current-repo-fallback" + ? join(contract.paths.botRoot, "data") + : contract.paths.dataDir; + return join(dataDir, "thread-cache.json"); +} + function cacheKey(chatId: number, messageId: number): string { return `${chatId}:${messageId}`; } @@ -61,7 +67,7 @@ export function threadCacheSize(): number { * Save the cache to disk as JSON. Called on graceful shutdown. * Format: array of [key, value] pairs (Map serialization). */ -export function saveThreadCache(path: string = DEFAULT_CACHE_PATH): void { +export function saveThreadCache(path: string = defaultThreadCachePath()): void { const tmpPath = path + ".tmp"; try { const dir = dirname(path); @@ -81,7 +87,7 @@ export function saveThreadCache(path: string = DEFAULT_CACHE_PATH): void { * Missing or corrupt files result in an empty cache (no crash). * Respects MAX_CACHE_SIZE — only loads up to 10K entries. */ -export function restoreThreadCache(path: string = DEFAULT_CACHE_PATH): void { +export function restoreThreadCache(path: string = defaultThreadCachePath()): void { try { const data = readFileSync(path, "utf8"); cache.clear(); diff --git a/bot/src/pi-extensions/README.md b/bot/src/pi-extensions/README.md index 0be8b0c1..95ceb40e 100644 --- a/bot/src/pi-extensions/README.md +++ b/bot/src/pi-extensions/README.md @@ -30,13 +30,22 @@ There are TWO kinds of files in this feature, deliberately split: 2. **Thin wrappers — `bot/.claude/extensions/.ts`** (or `/index.ts` for A3). Each is a minimal `export default function (pi) { ... }` that wires a Pi `pi.on(...)` / `pi.registerTool(...)` call to the pure helpers above. They - are jiti-loaded by Pi at spawn via `--extension `. + are jiti-loaded by Pi at spawn via `--extension ` in source + development. + +3. **Package artifacts — `bot/dist/extensions/pi/`**. `npm run build` runs + `scripts/build-package-artifacts.mjs`, which clears this generated directory, + transpiles the first-party wrappers to `.js` with imports rewritten to compiled + `dist/` helpers, and copies A3 bundled `agents/*.md` and `prompts/*.md` + resources. Built and installed package runs load wrappers from this artifact + directory. ## Lint-coverage decision for the wrappers (Task 0) -**Decision: the `bot/.claude/extensions/` wrappers are jiti-only — intentionally +**Decision: the source `bot/.claude/extensions/` wrappers are intentionally EXCLUDED from `tsc --noEmit` and from the `npm test` glob. No second tsconfig or -test glob is added for them.** +test glob is added for them; package builds transpile them into generated runtime +artifacts.** Rationale: - They live OUTSIDE `bot/src/`, so the existing tsconfig `include` @@ -46,11 +55,12 @@ Rationale: type-checking and unit-testing is factored into the `src/pi-extensions/*.ts` helpers, which ARE covered. A wrapper should contain no logic a test would want to assert on. -- Adding a second tsconfig/glob to type-check the wrappers would pull Pi's +- Adding a second tsconfig/glob to type-check the source wrappers would pull Pi's runtime extension API types into the bot's `tsc` graph and couple the bot build to the `@earendil-works/pi-coding-agent` extension surface. jiti loads - and validates them at actual spawn time instead; a broken wrapper fails loudly - at load (fail-closed loading is handled in Task 1's `buildPiSpawnArgs`). + and validates them at source-checkout spawn time instead; built/package mode + loads the generated JS wrappers from `dist/extensions/pi`. A broken wrapper + fails loudly at load (fail-closed loading is handled by `resolvePiExtensionArgs`). If a wrapper ever grows logic worth testing, move that logic into a `src/pi-extensions/*.ts` helper rather than adding a tsconfig for the wrapper. diff --git a/bot/src/pi-extensions/write-allowlist-schema.ts b/bot/src/pi-extensions/write-allowlist-schema.ts new file mode 100644 index 00000000..24d31dab --- /dev/null +++ b/bot/src/pi-extensions/write-allowlist-schema.ts @@ -0,0 +1,136 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute, normalize, resolve } from "node:path"; +import { MINIME_SCHEMA_PATH_ENV } from "../workspace-contract.js"; + +export const WRITE_ALLOWLIST_FENCE = "```write-allowlist"; + +export type WriteAllowlistSchemaIssueKind = + | "missing-file" + | "unreadable-file" + | "missing-block" + | "malformed-block" + | "empty-block"; + +export interface WriteAllowlistSchemaIssue { + kind: WriteAllowlistSchemaIssueKind; + message: string; +} + +export interface WriteAllowlistSchemaResult { + schemaPath: string; + entries: string[]; + issue?: WriteAllowlistSchemaIssue; +} + +export type ReadSchemaFile = (schemaPath: string) => string; + +function defaultReadSchemaFile(schemaPath: string): string { + return readFileSync(schemaPath, "utf8"); +} + +function issue( + schemaPath: string, + kind: WriteAllowlistSchemaIssueKind, + message: string, +): WriteAllowlistSchemaResult { + return { + schemaPath, + entries: [], + issue: { kind, message }, + }; +} + +export function resolveWriteAllowlistSchemaPath( + workspaceRoot: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const override = env[MINIME_SCHEMA_PATH_ENV]?.trim(); + if (override) { + return normalize(isAbsolute(override) ? override : resolve(workspaceRoot, override)); + } + return normalize(resolve(workspaceRoot, "schema.md")); +} + +export function parseWriteAllowlistSchemaContent( + content: string, + schemaPath = "schema.md", +): WriteAllowlistSchemaResult { + const entries: string[] = []; + let inBlock = false; + let foundBlock = false; + let closedBlock = false; + + for (const rawLine of content.split("\n")) { + if (!inBlock) { + if (rawLine === WRITE_ALLOWLIST_FENCE) { + inBlock = true; + foundBlock = true; + } + continue; + } + + if (rawLine.startsWith("```")) { + closedBlock = true; + break; + } + + const line = rawLine.replace(/#.*$/, "").trim(); + if (line) { + entries.push(line); + } + } + + if (!foundBlock) { + return issue( + schemaPath, + "missing-block", + `schema does not contain an exact ${WRITE_ALLOWLIST_FENCE} fenced block`, + ); + } + + if (!closedBlock) { + return issue( + schemaPath, + "malformed-block", + "schema write-allowlist block is missing a closing fence", + ); + } + + if (entries.length === 0) { + return issue( + schemaPath, + "empty-block", + "schema write-allowlist block is empty after comments and blank lines are removed", + ); + } + + return { schemaPath, entries }; +} + +export function readWriteAllowlistSchema( + schemaPath: string, + readSchemaFile: ReadSchemaFile = defaultReadSchemaFile, +): WriteAllowlistSchemaResult { + let content: string; + try { + content = readSchemaFile(schemaPath); + } catch (err) { + const code = typeof err === "object" && err !== null && "code" in err + ? String((err as NodeJS.ErrnoException).code) + : ""; + if (code === "ENOENT") { + return issue(schemaPath, "missing-file", `schema file does not exist: ${schemaPath}`); + } + return issue(schemaPath, "unreadable-file", `schema file cannot be read: ${schemaPath}`); + } + + return parseWriteAllowlistSchemaContent(content, schemaPath); +} + +export function readWriteAllowlistEntriesForGuard( + schemaPath: string, + readSchemaFile?: ReadSchemaFile, +): string[] { + const result = readWriteAllowlistSchema(schemaPath, readSchemaFile); + return result.issue ? [] : result.entries; +} diff --git a/bot/src/pi-rpc-protocol.ts b/bot/src/pi-rpc-protocol.ts index 36da1086..469da135 100644 --- a/bot/src/pi-rpc-protocol.ts +++ b/bot/src/pi-rpc-protocol.ts @@ -1,8 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; -import { existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { existsSync, statSync } from "node:fs"; +import { normalize, resolve } from "node:path"; import type { AgentConfig, StreamLine, @@ -13,21 +12,18 @@ import type { } from "./types.js"; import { log } from "./logger.js"; import { assemblePiContext } from "./pi-context-assembler.js"; +import { + MINIME_SCHEMA_PATH_ENV, + realPathIsInsideOrEqual, + resolveAgentWorkspaceCwd, + resolveWorkspaceContract, + type ResolvedWorkspaceContract, +} from "./workspace-contract.js"; const PI_BIN = "pi"; const PI_PROVIDER = "openai-codex"; const DEFAULT_PI_MODEL = "openai-codex/gpt-5.5"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -/** - * Absolute dir holding the jiti-loaded Pi extension wrappers. They live OUTSIDE - * `bot/src` (outside the tsc graph + the `npm test` glob — see - * `pi-extensions/README.md`) at `bot/.claude/extensions`, so resolve relative to - * this module: `bot/src` → `bot` → `.claude/extensions`. - */ -const DEFAULT_PI_EXTENSIONS_DIR = resolve(__dirname, "..", ".claude", "extensions"); - /** * Wrapper entrypoints loaded into EVERY Pi spawn, in load order: * A1 guardian+protect-files write guard, @@ -42,6 +38,12 @@ export const PI_EXTENSION_WRAPPER_RELPATHS = [ "subagent/index.ts", ] as const; +export const PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS = [ + "guardian-protect-files.js", + "web-tools.js", + "subagent/index.js", +] as const; + /** * Wrappers a subagent CHILD `pi` spawn must load. The subagent tool spawns an * isolated `pi -p` child to run a delegated task; without the A1 write guard a @@ -60,6 +62,8 @@ export const PI_SUBAGENT_CHILD_WRAPPER_RELPATHS = ["guardian-protect-files.ts", */ export const PI_CRON_WRAPPER_RELPATHS = ["guardian-protect-files.ts"] as const; +export const PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS = ["guardian-protect-files.js", "web-tools.js"] as const; + /** * Kill-switch env var: set to exactly `"1"` to spawn Pi with no explicit * first-party extensions. Spawns still pass `--no-extensions` so Pi's ambient @@ -75,14 +79,14 @@ export const PI_EXTENSIONS_DISABLED_ENV = "PI_EXTENSIONS_DISABLED"; * protected dir (`/bot/x`), which resolves OUTSIDE `/tmp` and would be allowed * — bypassing A1 entirely. The subagent spawn sets this to the parent workspace * root; the guard wrapper prefers it over `ctx.cwd` for protection while still - * resolving RELATIVE paths against the child's real cwd. NEVER set for a top-level - * parent spawn (there `ctx.cwd` IS the workspace root) — `buildPiSpawnEnv` scrubs - * any stray inherited value so the parent always anchors on its own `ctx.cwd`. + * resolving RELATIVE paths against the child's real cwd. Top-level parent spawns + * also set this from the workspace contract because an agent's cwd may be a child + * directory inside the workspace. */ export const PI_GUARD_WORKSPACE_ROOT_ENV = "PI_GUARD_WORKSPACE_ROOT"; export interface PiExtensionResolveOptions { - /** Override the wrapper base dir (default: `bot/.claude/extensions`). */ + /** Override the wrapper base dir (default: resolved workspace/package contract). */ extensionsDir?: string; /** Override env lookup for the kill-switch (default: `process.env`). */ env?: NodeJS.ProcessEnv; @@ -160,13 +164,13 @@ export function resolvePiExtensionArgs(options?: PiExtensionResolveOptions): str return []; } - const baseDir = options?.extensionsDir ?? DEFAULT_PI_EXTENSIONS_DIR; + const baseDir = options?.extensionsDir ?? resolveWorkspaceContract().paths.piExtensionDir; const fileExists = options?.exists ?? existsSync; const relpaths = options?.relpaths ?? PI_EXTENSION_WRAPPER_RELPATHS; const args: string[] = []; for (const rel of relpaths) { - const abs = resolve(baseDir, rel); + const abs = resolve(baseDir, piExtensionRelpathForDir(baseDir, rel)); if (!fileExists(abs)) { throw new Error( `Pi extension wrapper not found: ${abs}. Refusing to spawn an unguarded ` + @@ -179,6 +183,17 @@ export function resolvePiExtensionArgs(options?: PiExtensionResolveOptions): str return args; } +export function piExtensionRelpathForDir(baseDir: string, relpath: string): string { + const normalizedBase = normalize(baseDir); + if (!normalizedBase.endsWith(`${normalize("dist/extensions/pi")}`)) { + return relpath; + } + if (relpath.endsWith(".ts")) { + return `${relpath.slice(0, -".ts".length)}.js`; + } + return relpath; +} + export interface PiPromptCommand { type: "prompt"; message: string; @@ -282,6 +297,37 @@ export function normalizePiModel(model: string | undefined): string { return trimmed.includes("/") ? trimmed : `${PI_PROVIDER}/${trimmed}`; } +function validateAgentWorkspaceCwd( + agent: AgentConfig, + contract: ResolvedWorkspaceContract, +): string { + const agentWorkspace = resolveAgentWorkspaceCwd(contract.paths.workspaceRoot, agent.workspaceCwd); + if (!existsSync(agentWorkspace)) { + throw new Error( + `Agent "${agent.id}" workspaceCwd does not exist: ${agentWorkspace}. ` + + `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or create the agent workspace under it.`, + ); + } + if (!statSync(agentWorkspace).isDirectory()) { + throw new Error( + `Agent "${agent.id}" workspaceCwd is not a directory: ${agentWorkspace}. ` + + `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or move the agent workspace under it.`, + ); + } + if (!realPathIsInsideOrEqual(contract.paths.workspaceRoot, agentWorkspace)) { + throw new Error( + `Agent "${agent.id}" workspaceCwd must be inside the resolved workspace root for Pi guard enforcement: ` + + `workspaceCwd=${agentWorkspace} workspaceRoot=${contract.paths.workspaceRoot}. ` + + `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or move the agent workspace under it.`, + ); + } + return agentWorkspace; +} + +export function resolveValidatedPiAgentWorkspaceCwd(agent: AgentConfig): string { + return validateAgentWorkspaceCwd(agent, resolveWorkspaceContract()); +} + export function buildPiSpawnArgs( agent: AgentConfig, resumeSessionId?: string, @@ -346,15 +392,12 @@ export function buildPiSpawnArgs( } export function buildPiSpawnEnv(agent: AgentConfig): Record { - void agent; + const contract = resolveWorkspaceContract(); + validateAgentWorkspaceCwd(agent, contract); const env = buildAllowedPiChildEnv(); - - // A top-level parent must anchor its A1 guard on its OWN ctx.cwd. Scrub any - // stray PI_GUARD_WORKSPACE_ROOT so an inherited value can never mis-anchor the - // parent guard — only the subagent child spawn sets it (see the constant doc). - delete env[PI_GUARD_WORKSPACE_ROOT_ENV]; - + env[PI_GUARD_WORKSPACE_ROOT_ENV] = contract.paths.workspaceRoot; + env[MINIME_SCHEMA_PATH_ENV] = contract.paths.schemaPath; return env; } @@ -405,9 +448,12 @@ export interface PiStartupDiagnostics { const PI_STARTUP_STDERR_CAP = 64 * 1024; export function spawnPiRpcSession(agent: AgentConfig, resumeSessionId?: string): ChildProcess { - const child = spawn(PI_BIN, buildPiSpawnArgs(agent, resumeSessionId), { - env: buildPiSpawnEnv(agent), - cwd: agent.workspaceCwd, + const workspaceCwd = resolveValidatedPiAgentWorkspaceCwd(agent); + const spawnAgent = { ...agent, workspaceCwd }; + const env = buildPiSpawnEnv(spawnAgent); + const child = spawn(PI_BIN, buildPiSpawnArgs(spawnAgent, resumeSessionId), { + env, + cwd: workspaceCwd, stdio: ["pipe", "pipe", "pipe"], }); diff --git a/bot/src/session-store.ts b/bot/src/session-store.ts index b94cd116..926fdade 100644 --- a/bot/src/session-store.ts +++ b/bot/src/session-store.ts @@ -1,10 +1,11 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; import type { SessionState } from "./types.js"; +import { resolveWorkspaceContract } from "./workspace-contract.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_STORE_PATH = resolve(__dirname, "..", "..", "data", "sessions.json"); +function defaultStorePath(): string { + return resolveWorkspaceContract().paths.sessionStorePath; +} export type SessionStoreData = Record; @@ -13,7 +14,7 @@ export class SessionStore { private readonly path: string; constructor(path?: string) { - this.path = path ?? DEFAULT_STORE_PATH; + this.path = path ?? defaultStorePath(); this.load(); } diff --git a/bot/src/workspace-contract.ts b/bot/src/workspace-contract.ts new file mode 100644 index 00000000..3369d033 --- /dev/null +++ b/bot/src/workspace-contract.ts @@ -0,0 +1,285 @@ +import { realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const MINIME_WORKSPACE_ROOT_ENV = "MINIME_WORKSPACE_ROOT"; +export const MINIME_CONFIG_PATH_ENV = "MINIME_CONFIG_PATH"; +export const MINIME_CRONS_PATH_ENV = "MINIME_CRONS_PATH"; +export const MINIME_SCHEMA_PATH_ENV = "MINIME_SCHEMA_PATH"; + +export type WorkspacePathSource = + | "cli" + | "env" + | "current-repo-fallback" + | "cwd-fallback" + | "workspace-default" + | "package-default" + | "runtime-default"; + +export interface WorkspacePathDiagnostic { + path: string; + source: WorkspacePathSource; +} + +export interface WorkspaceContractPaths { + packageRoot: string; + botRoot: string; + workspaceRoot: string; + configPath: string; + cronsPath: string; + schemaPath: string; + piExtensionDir: string; + dataDir: string; + sessionStorePath: string; + logDir: string; + mediaBaseDir: string; + runtimeDir: string; +} + +export type WorkspaceContractPathName = keyof WorkspaceContractPaths; + +export type WorkspaceContractEffectivePaths = { + [K in WorkspaceContractPathName]: WorkspacePathDiagnostic; +}; + +export interface ResolvedWorkspaceContract { + paths: WorkspaceContractPaths; + effectivePaths: WorkspaceContractEffectivePaths; + warnings: string[]; +} + +export interface ResolveWorkspaceContractOptions { + /** Explicit CLI --workspace value. Relative paths resolve against cwd. */ + workspace?: string; + /** Env overrides. Defaults to process.env. */ + env?: NodeJS.ProcessEnv; + /** Process cwd used for relative CLI/env paths. Defaults to process.cwd(). */ + cwd?: string; + /** Module URL used to infer package root. Defaults to this module URL. */ + moduleUrl?: string; + /** Home directory used for runtime defaults. Defaults to os.homedir(). */ + homeDir?: string; + /** Process id used for the test media dir convention. Defaults to process.pid. */ + pid?: number; +} + +const thisModuleUrl = import.meta.url; + +function absolutePath(value: string, base: string): string { + return normalize(resolve(base, value)); +} + +function optionalEnvPath(env: NodeJS.ProcessEnv, key: string): string | undefined { + const value = env[key]; + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function inferPackageRoot(moduleUrl: string): string { + const moduleDir = dirname(fileURLToPath(moduleUrl)); + const dirName = basename(moduleDir); + if (dirName === "src" || dirName === "dist") { + return normalize(resolve(moduleDir, "..")); + } + return normalize(moduleDir); +} + +function inferPiExtensionDir(packageRoot: string, moduleUrl: string): WorkspacePathDiagnostic { + const moduleDir = dirname(fileURLToPath(moduleUrl)); + if (basename(moduleDir) === "dist") { + return { + path: normalize(resolve(packageRoot, "dist", "extensions", "pi")), + source: "package-default", + }; + } + return { + path: normalize(resolve(packageRoot, ".claude", "extensions")), + source: "package-default", + }; +} + +function workspaceRootFromOptions( + options: Required> & { + packageRoot: string; + workspace?: string; + }, +): { diagnostic: WorkspacePathDiagnostic; warnings: string[] } { + const cliWorkspace = options.workspace?.trim(); + if (cliWorkspace) { + return { + diagnostic: { path: absolutePath(cliWorkspace, options.cwd), source: "cli" }, + warnings: [], + }; + } + + const envWorkspace = optionalEnvPath(options.env, MINIME_WORKSPACE_ROOT_ENV); + if (envWorkspace) { + return { + diagnostic: { path: absolutePath(envWorkspace, options.cwd), source: "env" }, + warnings: [], + }; + } + + if (basename(options.packageRoot) !== "bot") { + return { + diagnostic: { path: options.cwd, source: "cwd-fallback" }, + warnings: [ + `No workspace root was supplied; using cwd (${options.cwd}). Pass --workspace or ` + + `${MINIME_WORKSPACE_ROOT_ENV} when running from a package install.`, + ], + }; + } + + return { + diagnostic: { + path: normalize(resolve(options.packageRoot, "..")), + source: "current-repo-fallback", + }, + warnings: [], + }; +} + +function pathOverrideOrWorkspaceDefault( + env: NodeJS.ProcessEnv, + envKey: string, + workspaceRoot: string, + defaultFileName: string, +): WorkspacePathDiagnostic { + const override = optionalEnvPath(env, envKey); + if (override) { + return { path: absolutePath(override, workspaceRoot), source: "env" }; + } + return { + path: normalize(resolve(workspaceRoot, defaultFileName)), + source: "workspace-default", + }; +} + +function runtimePath(value: string, base: string, source: WorkspacePathSource): WorkspacePathDiagnostic { + return { path: absolutePath(value, base), source }; +} + +export function resolveWorkspaceContract( + options: ResolveWorkspaceContractOptions = {}, +): ResolvedWorkspaceContract { + const env = options.env ?? process.env; + const cwd = normalize(options.cwd ? resolve(options.cwd) : process.cwd()); + const moduleUrl = options.moduleUrl ?? thisModuleUrl; + const homeDir = normalize(options.homeDir ?? homedir()); + const pid = options.pid ?? process.pid; + const packageRoot = inferPackageRoot(moduleUrl); + const botRoot = packageRoot; + + const packageRootDiag: WorkspacePathDiagnostic = { + path: packageRoot, + source: "package-default", + }; + const botRootDiag: WorkspacePathDiagnostic = { + path: botRoot, + source: "package-default", + }; + const workspaceRootResult = workspaceRootFromOptions({ + cwd, + env, + packageRoot, + workspace: options.workspace, + }); + const workspaceRootDiag = workspaceRootResult.diagnostic; + const configPathDiag = pathOverrideOrWorkspaceDefault( + env, + MINIME_CONFIG_PATH_ENV, + workspaceRootDiag.path, + "config.yaml", + ); + const cronsPathDiag = pathOverrideOrWorkspaceDefault( + env, + MINIME_CRONS_PATH_ENV, + workspaceRootDiag.path, + "crons.yaml", + ); + const schemaPathDiag = pathOverrideOrWorkspaceDefault( + env, + MINIME_SCHEMA_PATH_ENV, + workspaceRootDiag.path, + "schema.md", + ); + const piExtensionDirDiag = inferPiExtensionDir(packageRoot, moduleUrl); + const dataDirDiag: WorkspacePathDiagnostic = { + path: normalize(resolve(workspaceRootDiag.path, "data")), + source: "workspace-default", + }; + const sessionStoreDataDirDiag = + workspaceRootDiag.source === "current-repo-fallback" + ? { + path: normalize(resolve(botRoot, "data")), + source: "current-repo-fallback" as const, + } + : dataDirDiag; + const sessionStorePathDiag: WorkspacePathDiagnostic = { + path: normalize(resolve(sessionStoreDataDirDiag.path, "sessions.json")), + source: sessionStoreDataDirDiag.source, + }; + const logDirOverride = env.LOG_DIR?.trim(); + const logDirDiag = runtimePath( + logDirOverride || join(homeDir, ".minime", "logs"), + cwd, + logDirOverride ? "env" : "runtime-default", + ); + const mediaBaseOverride = env.MINIME_TEST_MEDIA_BASE?.trim(); + const mediaBaseDirDiag = runtimePath( + mediaBaseOverride ? join(mediaBaseOverride, String(pid)) : "/tmp/bot-media", + cwd, + mediaBaseOverride ? "env" : "runtime-default", + ); + const runtimeDirDiag: WorkspacePathDiagnostic = { + path: normalize(resolve(workspaceRootDiag.path, ".tmp")), + source: "workspace-default", + }; + + const effectivePaths: WorkspaceContractEffectivePaths = { + packageRoot: packageRootDiag, + botRoot: botRootDiag, + workspaceRoot: workspaceRootDiag, + configPath: configPathDiag, + cronsPath: cronsPathDiag, + schemaPath: schemaPathDiag, + piExtensionDir: piExtensionDirDiag, + dataDir: dataDirDiag, + sessionStorePath: sessionStorePathDiag, + logDir: logDirDiag, + mediaBaseDir: mediaBaseDirDiag, + runtimeDir: runtimeDirDiag, + }; + + return { + paths: Object.fromEntries( + Object.entries(effectivePaths).map(([key, value]) => [key, value.path]), + ) as unknown as WorkspaceContractPaths, + effectivePaths, + warnings: workspaceRootResult.warnings, + }; +} + +export function workspaceContractDiagnostics( + contract: ResolvedWorkspaceContract, +): WorkspaceContractEffectivePaths { + return contract.effectivePaths; +} + +export function resolveAgentWorkspaceCwd(workspaceRoot: string, workspaceCwd: string): string { + return normalize(isAbsolute(workspaceCwd) ? workspaceCwd : resolve(workspaceRoot, workspaceCwd)); +} + +export function pathIsInsideOrEqual(parent: string, candidate: string): boolean { + const rel = relative(normalize(parent), normalize(candidate)); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +export function realPathIsInsideOrEqual(parent: string, candidate: string): boolean { + try { + return pathIsInsideOrEqual(realpathSync.native(parent), realpathSync.native(candidate)); + } catch { + return false; + } +} diff --git a/bot/src/workspace-validator.ts b/bot/src/workspace-validator.ts new file mode 100644 index 00000000..ae6c9fbb --- /dev/null +++ b/bot/src/workspace-validator.ts @@ -0,0 +1,192 @@ +import { existsSync, statSync } from "node:fs"; +import { normalize, resolve } from "node:path"; +import { loadConfig } from "./config.js"; +import { loadMergedCrons } from "./cron-runner.js"; +import { PI_EXTENSIONS_DISABLED_ENV } from "./pi-rpc-protocol.js"; +import { + readWriteAllowlistSchema, + resolveWriteAllowlistSchemaPath, + type WriteAllowlistSchemaResult, +} from "./pi-extensions/write-allowlist-schema.js"; +import type { BotConfig } from "./types.js"; +import { + realPathIsInsideOrEqual, + resolveAgentWorkspaceCwd, + type ResolvedWorkspaceContract, +} from "./workspace-contract.js"; + +export type WorkspaceValidationSeverity = "error" | "warning"; + +export interface WorkspaceValidationIssue { + severity: WorkspaceValidationSeverity; + message: string; +} + +export interface WorkspaceValidationResult { + contract: ResolvedWorkspaceContract; + config?: BotConfig; + crons?: Array>; + schema?: WriteAllowlistSchemaResult; + issues: WorkspaceValidationIssue[]; +} + +export interface ValidateWorkspaceOptions { + env?: NodeJS.ProcessEnv; + guardEnforcementEnabled?: boolean; +} + +function issue( + issues: WorkspaceValidationIssue[], + severity: WorkspaceValidationSeverity, + message: string, +): void { + issues.push({ severity, message }); +} + +function safeStat(path: string): ReturnType | undefined { + try { + return statSync(path); + } catch { + return undefined; + } +} + +function existsAsDirectory(path: string): boolean { + return safeStat(path)?.isDirectory() === true; +} + +function existsAsFile(path: string): boolean { + return safeStat(path)?.isFile() === true; +} + +function describePathKind(path: string): string { + if (!existsSync(path)) { + return "does not exist"; + } + const stat = safeStat(path); + if (!stat) { + return "cannot be accessed"; + } + if (stat.isDirectory()) { + return "is a directory"; + } + if (stat.isFile()) { + return "is a regular file"; + } + return "is not a regular file"; +} + +function schemaGuardEnabled(options: ValidateWorkspaceOptions, env: NodeJS.ProcessEnv): boolean { + return options.guardEnforcementEnabled ?? env[PI_EXTENSIONS_DISABLED_ENV] !== "1"; +} + +export function workspaceValidationErrors( + result: WorkspaceValidationResult, +): WorkspaceValidationIssue[] { + return result.issues.filter((item) => item.severity === "error"); +} + +export function workspaceValidationWarnings( + result: WorkspaceValidationResult, +): WorkspaceValidationIssue[] { + return result.issues.filter((item) => item.severity === "warning"); +} + +export function validateWorkspaceContract( + contract: ResolvedWorkspaceContract, + options: ValidateWorkspaceOptions = {}, +): WorkspaceValidationResult { + const env = options.env ?? process.env; + const issues: WorkspaceValidationIssue[] = []; + let config: BotConfig | undefined; + let crons: Array> | undefined; + + if (!existsSync(contract.paths.workspaceRoot)) { + issue(issues, "error", `workspace root does not exist: ${contract.paths.workspaceRoot}`); + } else if (!existsAsDirectory(contract.paths.workspaceRoot)) { + issue(issues, "error", `workspace root is not a directory: ${contract.paths.workspaceRoot}`); + } + + if (!existsAsFile(contract.paths.configPath)) { + issue(issues, "error", `config path ${describePathKind(contract.paths.configPath)}: ${contract.paths.configPath}`); + } else { + try { + config = loadConfig(contract.paths.configPath, { + resolveSecrets: false, + workspaceRoot: contract.paths.workspaceRoot, + }); + } catch (err) { + issue(issues, "error", `config does not parse with secret resolution disabled: ${(err as Error).message}`); + } + } + + if (!existsSync(contract.paths.cronsPath)) { + issue(issues, "warning", `crons file is not present: ${contract.paths.cronsPath}`); + } else if (!existsAsFile(contract.paths.cronsPath)) { + issue(issues, "error", `crons path ${describePathKind(contract.paths.cronsPath)}: ${contract.paths.cronsPath}`); + } else { + try { + crons = loadMergedCrons(contract.paths.cronsPath); + } catch (err) { + issue(issues, "error", `crons file does not parse: ${(err as Error).message}`); + } + } + + const defaultSchemaPath = normalize(resolve(contract.paths.workspaceRoot, "schema.md")); + if (contract.effectivePaths.schemaPath.source !== "env" && contract.paths.schemaPath !== defaultSchemaPath) { + issue( + issues, + "error", + `schema path must default to workspace root schema.md when no override is set: ${contract.paths.schemaPath}`, + ); + } + + const guardSchemaPath = resolveWriteAllowlistSchemaPath(contract.paths.workspaceRoot, env); + if (guardSchemaPath !== contract.paths.schemaPath) { + issue( + issues, + "error", + `live guard schema path does not match validator schema path: guard=${guardSchemaPath} validator=${contract.paths.schemaPath}`, + ); + } + + let schema: WriteAllowlistSchemaResult | undefined; + if (schemaGuardEnabled(options, env)) { + schema = readWriteAllowlistSchema(contract.paths.schemaPath); + if (schema.issue) { + issue(issues, "error", `schema validation failed: ${schema.issue.message}`); + } + } else { + issue(issues, "warning", `Pi guard enforcement is disabled by ${PI_EXTENSIONS_DISABLED_ENV}=1; schema allow-list was not enforced`); + } + + if (config) { + for (const [agentId, agent] of Object.entries(config.agents)) { + const agentWorkspace = resolveAgentWorkspaceCwd(contract.paths.workspaceRoot, agent.workspaceCwd); + if (!existsSync(agentWorkspace)) { + issue(issues, "error", `agent "${agentId}" workspaceCwd does not exist: ${agentWorkspace}`); + } else if (!existsAsDirectory(agentWorkspace)) { + issue(issues, "error", `agent "${agentId}" workspaceCwd is not a directory: ${agentWorkspace}`); + } else if (!realPathIsInsideOrEqual(contract.paths.workspaceRoot, agentWorkspace)) { + issue( + issues, + "error", + `agent "${agentId}" workspaceCwd must be inside the resolved workspace root for Pi guard enforcement: ` + + `workspaceCwd=${agentWorkspace} workspaceRoot=${contract.paths.workspaceRoot}`, + ); + } + } + } + + if (!existsSync(contract.paths.piExtensionDir)) { + issue(issues, "error", `Pi extension dir does not exist: ${contract.paths.piExtensionDir}`); + } else if (!existsAsDirectory(contract.paths.piExtensionDir)) { + issue(issues, "error", `Pi extension dir is not a directory: ${contract.paths.piExtensionDir}`); + } + + for (const warning of contract.warnings) { + issue(issues, "warning", warning); + } + + return { contract, config, crons, schema, issues }; +} diff --git a/bot/test-fixtures/minimal-workspace/agent-workspace/.gitkeep b/bot/test-fixtures/minimal-workspace/agent-workspace/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/bot/test-fixtures/minimal-workspace/agent-workspace/.gitkeep @@ -0,0 +1 @@ + diff --git a/bot/test-fixtures/minimal-workspace/config.yaml b/bot/test-fixtures/minimal-workspace/config.yaml new file mode 100644 index 00000000..1f32f4da --- /dev/null +++ b/bot/test-fixtures/minimal-workspace/config.yaml @@ -0,0 +1,9 @@ +agents: + main: + workspaceCwd: ./agent-workspace + model: gpt-5.5 +telegramTokenEnv: MINIME_FIXTURE_TELEGRAM_TOKEN +bindings: + - chatId: 111 + agentId: main + kind: dm diff --git a/bot/test-fixtures/minimal-workspace/crons.yaml b/bot/test-fixtures/minimal-workspace/crons.yaml new file mode 100644 index 00000000..7892c015 --- /dev/null +++ b/bot/test-fixtures/minimal-workspace/crons.yaml @@ -0,0 +1,6 @@ +crons: + - name: smoke + schedule: "0 9 * * *" + prompt: "smoke" + agentId: main + deliveryChatId: 111 diff --git a/bot/test-fixtures/minimal-workspace/schema.md b/bot/test-fixtures/minimal-workspace/schema.md new file mode 100644 index 00000000..10129325 --- /dev/null +++ b/bot/test-fixtures/minimal-workspace/schema.md @@ -0,0 +1,7 @@ +# Minimal Workspace Schema + +```write-allowlist +agent-workspace/ +*.md +schema.md +``` diff --git a/config.local.yaml.example b/config.local.yaml.example index 621b1455..71a26393 100644 --- a/config.local.yaml.example +++ b/config.local.yaml.example @@ -20,7 +20,7 @@ agents: main: - workspaceCwd: /absolute/path/to/agent/workspace # absolute path to your agent workspace (~ not expanded) + workspaceCwd: /absolute/path/to/agent/workspace # must be inside the resolved workspace root (~ not expanded) model: gpt-5.5 # provider: pi # optional compatibility field; omit or set to "pi" # thinking: xhigh # optional Pi/Codex thinking level: off, minimal, low, medium, high, xhigh diff --git a/docs/plans/completed/2026-06-06-issue-148-package-cli-workspace-contract.md b/docs/plans/completed/2026-06-06-issue-148-package-cli-workspace-contract.md new file mode 100644 index 00000000..50086c21 --- /dev/null +++ b/docs/plans/completed/2026-06-06-issue-148-package-cli-workspace-contract.md @@ -0,0 +1,241 @@ +# Plan: Issue #148 — Package CLI + workspace contract groundwork + +GitHub issue: #148 +Target repo: `fitz123/claude-code-bot` + +## Goal + +Prepare the current bot runtime for a future clean `minime-bot` package repo without moving repositories yet. + +This run is the first implementation slice only: + +- add a central workspace/path resolver; +- add a package CLI entrypoint usable from `node_modules/.bin/minime-bot` after build/install; +- add package artifact scripts (`build`, `prepare`, `prepack`) without committing `dist/`; +- add a workspace contract validator skeleton with useful effective-path diagnostics; +- update tests for package-installed/path-independent behavior. + +## Non-goals + +- Do not create or rename any GitHub repository. +- Do not migrate any production workspace. +- Do not remove the existing `bot/` directory layout. +- Do not change launchd labels. +- Do not edit private workspace files. +- Do not implement the final write-guard v2 cutover in private hooks; only add package-side resolver/validator groundwork needed for later runs. +- Do not print or decrypt secret values. + +## Context + +The future architecture is a bot-only package consumed by a separate private workspace through `package.json` / `package-lock.json`. + +The public package must eventually expose commands like: + +```bash +minime-bot workspace validate --workspace /path/to/workspace +minime-bot config validate --workspace /path/to/workspace +minime-bot start --workspace /path/to/workspace +minime-bot cron run --task --workspace /path/to/workspace +``` + +This first slice should make those boundaries real enough for tests and later phases, but it should preserve current runtime behavior. + +## Architectural invariants + +The important outcome is correct separation of concepts while preserving both development workflows: + +1. **Bot runtime package** — transport/session/config/cron/launchd/application logic. It must be developable from the source checkout and runnable from a package install. +2. **Harness extensions** — Pi extension wrappers plus pure helper modules. They may live in the same repo/package, but their boundary must stay explicit: thin wrappers call tested helpers, package artifacts include valid wrapper/helper imports, and extension loading must not depend on private workspace cwd or source-only paths. +3. **Workspace contract** — config/crons/schema/secrets/agent cwd resolution. The contract is consumed by the bot and extensions through resolver/config, not by ad-hoc `process.cwd()` assumptions. + +Compatibility matrix required for this slice: + +| Mode | Must keep working | +|---|---| +| Current source checkout (`bot/`, `npx tsx`, existing scripts) | normal development and current production behavior | +| Built checkout (`dist/cli.js`) | CLI help/config validate/workspace validate | +| Packed + installed package (`node_modules/.bin/minime-bot`) | CLI help/config validate/workspace validate + Pi extension wrapper resolution/loading | + +Do not trade one mode for another. If a change makes package mode work but breaks source development, or keeps source mode while leaving extension artifacts broken, the implementation is incomplete. + +Current important paths/code areas to inspect: + +- `bot/package.json` +- `bot/tsconfig.json` +- `bot/src/config.ts` +- `bot/src/cron-runner.ts` +- `bot/src/pi-rpc-protocol.ts` +- `bot/src/pi-extensions/*` +- `bot/scripts/start-bot.sh` +- `bot/scripts/run-cron.sh` +- `bot/scripts/generate-plists.ts` +- existing tests under `bot/src/__tests__/` + +## Required design decisions for this slice + +### Workspace resolution + +Add a central resolver module, for example `bot/src/workspace-contract.ts` or `bot/src/workspace-root.ts`. + +It should resolve and expose at least: + +- package/bot root directory; +- workspace root; +- config path; +- crons path; +- schema path; +- Pi extension directory; +- data/log/runtime dirs where already known. + +Resolution order: + +1. CLI `--workspace ` when available. +2. `MINIME_WORKSPACE_ROOT` when set. +3. Existing behavior-compatible fallback for the current repo layout. + +Do not silently guess a parent workspace in package-installed mode unless the fallback is explicitly documented and tested. + +### Config/crons/schema overrides + +Support these env overrides in the resolver layer: + +- `MINIME_WORKSPACE_ROOT` +- `MINIME_CONFIG_PATH` +- `MINIME_CRONS_PATH` +- `MINIME_SCHEMA_PATH` + +Use absolute normalized paths in diagnostics. The resolved schema path is not validator-only: it must be propagated to live Pi extension processes and consumed by the guard wrapper/helper. Updating `buildPiSpawnEnv` allowlists or adding an explicit extension contract is part of this slice. A `MINIME_SCHEMA_PATH` override must affect both validator results and live guard verdicts in source, built, and package-installed modes. + +### Package artifact strategy + +For now: + +- `dist/` is generated, not committed. +- package `bin` points to built CLI output and the built CLI has a working shebang for `node_modules/.bin/minime-bot`. +- `prepare` and `prepack` build `dist/` so GitHub dependency install and `npm pack` produce runnable artifacts. +- first-party Pi wrappers for package-installed runtime live in a built package artifact path (for example `dist/extensions/pi/`), not only in the current source-only `bot/.claude/extensions/` path. +- build must either compile/copy wrapper JS with imports rewritten to compiled helper JS under `dist/`, or otherwise prove the packed artifact contains every helper import the wrappers need. Do not rely on wrappers importing absent `src/pi-extensions/*.js` files. +- resolver/`resolvePiExtensionArgs` must choose the package artifact extension directory in built/package-installed mode while preserving the current source wrapper directory for dev/current-layout mode. +- package artifact must include non-code resources required by directory extensions, especially subagent bundled `agents/*.md` and `prompts/*.md` resources. +- tests must verify package metadata and installed-bin behavior enough to prevent a broken `node_modules/.bin/minime-bot` path. + +### Secrets safety + +Validator and CLI config validation are structural/no-decrypt by default. + +- `minime-bot config validate` and `minime-bot workspace validate` must call config loading with `resolveSecrets: false` or equivalent unless an explicit future flag requests secret resolution. +- Do not invoke `sops -d` from validators in this slice. +- If SOPS pointers are checked, use structural checks or no-output checks only, and add a test/fake resolver that fails if secret resolution is attempted. + +## Tasks + +### Task 1: Add workspace/path resolver + +- [x] Add a central resolver module. +- [x] Cover current repo layout with tests so existing `npm test` behavior is preserved. +- [x] Cover explicit `MINIME_WORKSPACE_ROOT`, `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` overrides. +- [x] Return structured diagnostics/effective paths without reading secrets. + +### Task 2: Wire resolver into config and cron loading + +- [x] Update `config.ts` to use the resolver for config path defaults and config validation. +- [x] Update SOPS file path resolution so relative paths are resolved against the documented workspace/config base, not accidental process cwd. +- [x] Update `cron-runner.ts` to use the resolver for crons path defaults. +- [x] Preserve current default behavior when no new env vars are set. + +### Task 3: Add package CLI entrypoint + +- [x] Add `src/cli.ts` or equivalent. +- [x] Add `bin` mapping in `bot/package.json` for `minime-bot`. +- [x] Ensure the built CLI has a shebang and works through an installed package bin shim. +- [x] Implement at minimum: + - `minime-bot --help` + - `minime-bot config validate --workspace ` (structural/no-decrypt by default) + - `minime-bot workspace validate --workspace ` (structural/no-decrypt by default) +- [x] Keep existing script entrypoints working. +- [x] Add tests for help/argument parsing and installed-bin execution where practical. + +### Task 4: Add workspace contract validator skeleton + +- [x] Implement validator command used by `minime-bot workspace validate`. +- [x] It must print effective resolved paths: + - workspace root; + - config path; + - crons path; + - schema path; + - Pi extension dir. +- [x] It must verify: + - workspace root exists; + - config exists and parses with secret resolution disabled; + - crons file parses when present; + - schema path defaults to `$WORKSPACE_ROOT/schema.md` unless `MINIME_SCHEMA_PATH` overrides it; + - schema validation reuses/shares the same write-allowlist parser and match semantics as the live guard; + - live Pi guard consumes the same resolved schema path as the validator, including `MINIME_SCHEMA_PATH` override propagation into Pi extension processes; + - missing, empty, malformed, or unparseable schema is a hard failure when guard enforcement is enabled (no optional/schema-warning mode in this slice); + - configured agent workspace dirs exist; + - Pi extension dir exists; + - SOPS pointers are structurally valid without printing or decrypting secret values. +- [x] It should distinguish hard failures from warnings. +- [x] Add validator-vs-guard parser parity tests for valid, missing, empty, malformed, and override schema cases. + +### Task 5: Package build/artifact checks + +- [x] Update `package.json` scripts for build/prepare/prepack as needed. +- [x] Ensure `npm run build` succeeds from `bot/`. +- [x] Build/package first-party Pi wrappers into the chosen artifact extension directory and make wrapper helper imports valid in the packed artifact. +- [x] Copy/package directory-extension non-code resources required at runtime, especially subagent bundled `agents/*.md` and `prompts/*.md` resources. +- [x] Keep extension architecture separated: wrappers stay thin, helper logic remains in tested modules, and both source-development and package-installed extension loading paths are explicitly tested. +- [x] Add/adjust `files`/ignore policy so package artifacts include runtime JS, built Pi wrappers, required helper JS, and required extension resources, not private/workspace templates. +- [x] Add an npm-pack/install fixture test that: + - packs the bot package; + - installs it into a clean temp project; + - runs `node_modules/.bin/minime-bot --help`; + - runs `node_modules/.bin/minime-bot config validate --workspace ` without invoking SOPS/secret resolution; + - runs `node_modules/.bin/minime-bot workspace validate --workspace `; + - calls `resolvePiExtensionArgs` (or equivalent public/test seam) against the installed artifact and imports/loads each configured first-party wrapper; + - verifies `MINIME_SCHEMA_PATH` override changes the installed-package guard verdict exactly like the validator; + - verifies subagent bundled agent discovery and `resources_discover` prompt paths exist from the installed artifact, not from the source checkout. +- [x] Add a test or documented validation that `npm pack --dry-run` includes expected runtime files. + +### Task 6: Tests and docs + +- [x] Update tests that assume old path behavior only if this slice changes their assumptions. +- [x] Add fixture tests for explicit workspace root and package-installed-like layout. +- [x] Update README or package docs minimally for new CLI/validator commands. +- [x] Do not rewrite broad public setup docs in this slice beyond what is needed for the new commands. + +## Validation commands + +Run from `bot/` unless stated otherwise: + +```bash +npm test +npm run build +npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace +npm pack --dry-run +node dist/cli.js --help +node dist/cli.js config validate --workspace ./test-fixtures/minimal-workspace +node dist/cli.js workspace validate --workspace ./test-fixtures/minimal-workspace +# package-installed fixture must also run: +# node_modules/.bin/minime-bot --help +# node_modules/.bin/minime-bot config validate --workspace +# node_modules/.bin/minime-bot workspace validate --workspace +``` + +If the implementation uses a different fixture path, update the commands in the final result and tests accordingly. + +## Acceptance criteria + +- Existing tests pass. +- Build passes. +- CLI help works from built `dist/` and from a package-installed `node_modules/.bin/minime-bot`. +- Workspace validator prints effective paths and validates a minimal fixture workspace. +- Config/workspace validation is no-decrypt by default; tests fail if validators invoke SOPS/secret resolution. +- Validator and guard schema parser semantics have parity tests. +- Package-installed Pi extension wrappers resolve and load from the packed artifact. +- Package-installed guard uses the same resolved schema path as validator, including `MINIME_SCHEMA_PATH` overrides. +- Package-installed subagent extension includes and discovers required non-code resources (`agents/*.md`, `prompts/*.md`). +- No secret values are printed by tests or validator. +- Current source-development and runtime behavior remain backward-compatible when no new env vars are set. +- Bot runtime package and harness extension boundaries stay explicit and compatible across source, built, and installed modes. +- No production workspace migration or launchd reload is performed. From 6357efcb52a382e62dd6478cf33b74e4fc093ed4 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:18:21 +0000 Subject: [PATCH 2/8] fix: handle trailing package extension dirs --- bot/src/__tests__/pi-rpc-protocol.test.ts | 4 ++++ bot/src/pi-rpc-protocol.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/src/__tests__/pi-rpc-protocol.test.ts b/bot/src/__tests__/pi-rpc-protocol.test.ts index e97f52db..d29af411 100644 --- a/bot/src/__tests__/pi-rpc-protocol.test.ts +++ b/bot/src/__tests__/pi-rpc-protocol.test.ts @@ -469,6 +469,10 @@ describe("Pi extension loading (--extension)", () => { piExtensionRelpathForDir(artifactDir, "subagent/index.ts"), "subagent/index.js", ); + assert.equal( + piExtensionRelpathForDir(`${artifactDir}/`, "web-tools.ts"), + "web-tools.js", + ); assert.equal(piExtensionRelpathForDir(FAKE_DIR, "subagent/index.ts"), "subagent/index.ts"); }); diff --git a/bot/src/pi-rpc-protocol.ts b/bot/src/pi-rpc-protocol.ts index 469da135..19b4ccf1 100644 --- a/bot/src/pi-rpc-protocol.ts +++ b/bot/src/pi-rpc-protocol.ts @@ -184,7 +184,7 @@ export function resolvePiExtensionArgs(options?: PiExtensionResolveOptions): str } export function piExtensionRelpathForDir(baseDir: string, relpath: string): string { - const normalizedBase = normalize(baseDir); + const normalizedBase = normalize(baseDir).replace(/[\\/]+$/, ""); if (!normalizedBase.endsWith(`${normalize("dist/extensions/pi")}`)) { return relpath; } From c68cb58a8945f52b2592bdd9b76379dfaa79e162 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:25:00 +0000 Subject: [PATCH 3/8] fix: address PR review findings --- README.md | 4 +- bot/package-lock.json | 3 + bot/package.json | 3 + bot/src/__tests__/pi-rpc-protocol.test.ts | 80 ++++++++++++++++++----- bot/src/pi-rpc-protocol.ts | 6 +- 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0c7eead5..5aca3b26 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Both platforms share one Session Manager and use the same stream-relay logic via ### Prerequisites - macOS (launchd required for bot service management) -- Node.js 20+ and npm +- Node.js 22.19+ and npm (Pi package dependencies require Node >=22.19.0) - `jq` — required by hook scripts (`brew install jq`) - `sops` and `age`, with an age identity available to the launchd user unless you configure only explicit token environment variables - The `pi` binary on launchd `PATH` and Pi auth initialized for the launchd user with `pi /login` @@ -375,7 +375,7 @@ The bot maintains persistent context across sessions through a memory system roo ### CLI validation -The package exposes a built CLI as `minime-bot` after `npm run build`, `npm pack`, or package installation. From a source checkout, the same workspace validator is available through npm scripts: +The package exposes a built CLI as `minime-bot` after `npm run build`, `npm pack`, or package installation. Package-installed Pi dependencies require Node >=22.19.0, matching `bot/package.json` `engines.node`. From a source checkout, the same workspace validator is available through npm scripts: ```bash cd bot diff --git a/bot/package-lock.json b/bot/package-lock.json index 65469ad9..19fce4f0 100644 --- a/bot/package-lock.json +++ b/bot/package-lock.json @@ -28,6 +28,9 @@ "@types/node": "^25.4.0", "tsx": "^4.21.0", "typescript": "5.5" + }, + "engines": { + "node": ">=22.19.0" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/bot/package.json b/bot/package.json index 91a6c4b8..1735748c 100644 --- a/bot/package.json +++ b/bot/package.json @@ -43,6 +43,9 @@ "author": "Nico Bailon", "license": "MIT", "type": "module", + "engines": { + "node": ">=22.19.0" + }, "dependencies": { "@earendil-works/pi-agent-core": "^0.75.3", "@earendil-works/pi-ai": "^0.75.3", diff --git a/bot/src/__tests__/pi-rpc-protocol.test.ts b/bot/src/__tests__/pi-rpc-protocol.test.ts index d29af411..4de58626 100644 --- a/bot/src/__tests__/pi-rpc-protocol.test.ts +++ b/bot/src/__tests__/pi-rpc-protocol.test.ts @@ -11,6 +11,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, symlinkSync, writeFileSync, @@ -703,7 +704,7 @@ describe("buildPiSpawnEnv", () => { assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); assert.strictEqual(env.MINIME_SESSION_SECRET, undefined); assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], undefined); - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], "/tmp"); + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync("/tmp")); assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], "/tmp/schema.md"); assert.strictEqual(env.ANTHROPIC_OAUTH_TOKEN, undefined); assert.strictEqual(env.AWS_ACCESS_KEY_ID, undefined); @@ -818,7 +819,7 @@ describe("buildPiSpawnEnv", () => { process.env.PI_GUARD_WORKSPACE_ROOT = "/somewhere/else"; const env = buildPiSpawnEnv(testAgent); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); } finally { if (oldRoot === undefined) { delete process.env.PI_GUARD_WORKSPACE_ROOT; @@ -833,6 +834,46 @@ describe("buildPiSpawnEnv", () => { } }); + it("canonicalizes PI_GUARD_WORKSPACE_ROOT to the workspace realpath", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-env-root-")); + const parentDir = mkdtempSync(join(tmpdir(), "pi-spawn-env-link-parent-")); + const workspaceLink = join(parentDir, "workspace-link"); + symlinkSync(workspaceRoot, workspaceLink, "dir"); + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + + try { + mkdirSync(join(workspaceRoot, "agent-workspace"), { recursive: true }); + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceLink; + const env = buildPiSpawnEnv({ ...testAgent, workspaceCwd: "./agent-workspace" }); + + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspaceRoot)); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(parentDir, { recursive: true, force: true }); + } + }); + + it("canonicalizes subagent child guard roots to realpaths", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-subagent-env-root-")); + const parentDir = mkdtempSync(join(tmpdir(), "pi-subagent-env-link-parent-")); + const workspaceLink = join(parentDir, "workspace-link"); + symlinkSync(workspaceRoot, workspaceLink, "dir"); + + try { + const env = buildPiSubagentChildSpawnEnv(workspaceLink); + + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspaceRoot)); + } finally { + rmSync(workspaceRoot, { recursive: true, force: true }); + rmSync(parentDir, { recursive: true, force: true }); + } + }); + it("propagates an absolute MINIME_SCHEMA_PATH override to Pi guard processes", () => { const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; const oldSchema = process.env.MINIME_SCHEMA_PATH; @@ -843,7 +884,7 @@ describe("buildPiSpawnEnv", () => { const env = buildPiSpawnEnv(testAgent); assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/override-schema.md"); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); } finally { if (oldWorkspace === undefined) { delete process.env.MINIME_WORKSPACE_ROOT; @@ -868,7 +909,7 @@ describe("buildPiSpawnEnv", () => { const env = buildPiSpawnEnv(testAgent); assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/schemas/override.md"); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/tmp"); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); } finally { if (oldWorkspace === undefined) { delete process.env.MINIME_WORKSPACE_ROOT; @@ -938,19 +979,24 @@ describe("buildPiSubagentChildSpawnEnv", () => { process.env.SSH_AUTH_SOCK = "/tmp/ssh-agent.sock"; process.env.TAVILY_API_KEY = "fixture"; process.env.TELEGRAM_BOT_TOKEN = "fixture"; - - const env = buildPiSubagentChildSpawnEnv("/workspace/root"); - - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, "/workspace/root"); - assert.strictEqual(env.PI_CODING_AGENT_SESSION_DIR, "/tmp/pi-sessions"); - assert.strictEqual(env.LC_CTYPE, "UTF-8"); - assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); - assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); - assert.strictEqual(env.GITHUB_TOKEN, undefined); - assert.strictEqual(env.OPENAI_API_KEY, undefined); - assert.strictEqual(env.SSH_AUTH_SOCK, undefined); - assert.strictEqual(env.TAVILY_API_KEY, undefined); - assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); + const guardRoot = mkdtempSync(join(tmpdir(), "pi-subagent-guard-root-")); + + try { + const env = buildPiSubagentChildSpawnEnv(guardRoot); + + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync(guardRoot)); + assert.strictEqual(env.PI_CODING_AGENT_SESSION_DIR, "/tmp/pi-sessions"); + assert.strictEqual(env.LC_CTYPE, "UTF-8"); + assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); + assert.strictEqual(env.GITHUB_TOKEN, undefined); + assert.strictEqual(env.OPENAI_API_KEY, undefined); + assert.strictEqual(env.SSH_AUTH_SOCK, undefined); + assert.strictEqual(env.TAVILY_API_KEY, undefined); + assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); + } finally { + rmSync(guardRoot, { recursive: true, force: true }); + } } finally { for (const key of envKeys) { const oldValue = oldValues.get(key); diff --git a/bot/src/pi-rpc-protocol.ts b/bot/src/pi-rpc-protocol.ts index 19b4ccf1..f8d6b570 100644 --- a/bot/src/pi-rpc-protocol.ts +++ b/bot/src/pi-rpc-protocol.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; -import { existsSync, statSync } from "node:fs"; +import { existsSync, realpathSync, statSync } from "node:fs"; import { normalize, resolve } from "node:path"; import type { AgentConfig, @@ -396,7 +396,7 @@ export function buildPiSpawnEnv(agent: AgentConfig): Record { validateAgentWorkspaceCwd(agent, contract); const env = buildAllowedPiChildEnv(); - env[PI_GUARD_WORKSPACE_ROOT_ENV] = contract.paths.workspaceRoot; + env[PI_GUARD_WORKSPACE_ROOT_ENV] = realpathSync(contract.paths.workspaceRoot); env[MINIME_SCHEMA_PATH_ENV] = contract.paths.schemaPath; return env; } @@ -404,7 +404,7 @@ export function buildPiSpawnEnv(agent: AgentConfig): Record { export function buildPiSubagentChildSpawnEnv(guardWorkspaceRoot: string): Record { return { ...buildAllowedPiChildEnv(), - [PI_GUARD_WORKSPACE_ROOT_ENV]: guardWorkspaceRoot, + [PI_GUARD_WORKSPACE_ROOT_ENV]: realpathSync(guardWorkspaceRoot), }; } From 3b75df4dcdf25482850f84311c35c04967d6b804 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:26:05 +0000 Subject: [PATCH 4/8] test: expect canonical guard root in package install --- bot/src/__tests__/package-install.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/src/__tests__/package-install.test.ts b/bot/src/__tests__/package-install.test.ts index 610c182e..6dd2591b 100644 --- a/bot/src/__tests__/package-install.test.ts +++ b/bot/src/__tests__/package-install.test.ts @@ -240,7 +240,7 @@ describe("package artifact install", () => { const INSTALLED_ARTIFACT_CHECK = String.raw` import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; @@ -276,7 +276,7 @@ const loadedConfig = configMod.loadConfig(join(workspace, "config.yaml"), { }); assert.equal(loadedConfig.agents.main.workspaceCwd, agentWorkspace); const childEnv = piRpc.buildPiSpawnEnv(loadedConfig.agents.main); -assert.equal(childEnv.PI_GUARD_WORKSPACE_ROOT, workspace); +assert.equal(childEnv.PI_GUARD_WORKSPACE_ROOT, realpathSync(workspace)); const workspaceContract = await importPackageFile("dist/workspace-contract.js"); const validator = await importPackageFile("dist/workspace-validator.js"); From baa901e3e23f3637669b1b54d2a82b83be6a39b9 Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:32:30 +0000 Subject: [PATCH 5/8] test: expect canonical cron guard root --- bot/src/__tests__/cron-runner-pi.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/src/__tests__/cron-runner-pi.test.ts b/bot/src/__tests__/cron-runner-pi.test.ts index 3104bde9..dd29d0d0 100644 --- a/bot/src/__tests__/cron-runner-pi.test.ts +++ b/bot/src/__tests__/cron-runner-pi.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -477,7 +478,7 @@ describe("cron-runner runPi", () => { runPi(makeCron(), agentWorkspace, deps); const env = captures[0].options.env ?? {}; - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], workspace); + assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspace)); assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], join(workspace, "schemas", "override.md")); assert.strictEqual(captures[0].options.cwd, agentWorkspace); } finally { From 9c696cf585b129356dd6387d71a0ee5837af9b2f Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:33:16 +0000 Subject: [PATCH 6/8] fix: clarify package CLI workspace fallback --- bot/src/__tests__/cli.test.ts | 2 ++ bot/src/cli.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/src/__tests__/cli.test.ts b/bot/src/__tests__/cli.test.ts index d82449d4..9e17a0db 100644 --- a/bot/src/__tests__/cli.test.ts +++ b/bot/src/__tests__/cli.test.ts @@ -98,6 +98,8 @@ describe("minime-bot CLI", () => { assert.equal(result.code, 0); assert.match(result.stdout, /minime-bot config validate --workspace /); assert.match(result.stdout, /minime-bot workspace validate --workspace /); + assert.match(result.stdout, /Defaults to MINIME_WORKSPACE_ROOT, then source repo root or package cwd\./); + assert.doesNotMatch(result.stdout, /current repo layout/); assert.equal(result.stderr, ""); }); diff --git a/bot/src/cli.ts b/bot/src/cli.ts index 7ba24a5e..12b6f5cc 100644 --- a/bot/src/cli.ts +++ b/bot/src/cli.ts @@ -50,7 +50,7 @@ const HELP_TEXT = `Usage: minime-bot workspace validate --workspace Options: - --workspace Workspace root. Defaults to MINIME_WORKSPACE_ROOT, then the current repo layout. + --workspace Workspace root. Defaults to MINIME_WORKSPACE_ROOT, then source repo root or package cwd. -h, --help Show this help text. `; From 58943835032cfe2400fd486d28ad2d312396a03a Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:37:07 +0000 Subject: [PATCH 7/8] fix: normalize hook paths with sed --- .claude/hooks/protect-files.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh index aceb7ab6..fd7bf304 100755 --- a/.claude/hooks/protect-files.sh +++ b/.claude/hooks/protect-files.sh @@ -24,14 +24,12 @@ if [ -z "$FILE_PATH" ]; then exit 0 fi -# Normalize path: prevent bypass via non-canonical paths -# Collapse multiple slashes: // → / -while [[ "$FILE_PATH" == *//* ]]; do - FILE_PATH="${FILE_PATH//\/\///}" -done -# Collapse /./ → / +# Normalize path: prevent bypass via non-canonical paths. +# Use sed rather than Bash replacement syntax so the replacement stays literal +# and cannot accidentally introduce backslashes while collapsing `//` or `/./`. +FILE_PATH=$(printf '%s' "$FILE_PATH" | sed -E 's#/+#/#g') while [[ "$FILE_PATH" == *"/./"* ]]; do - FILE_PATH="${FILE_PATH//\/.\///}" + FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's#/\./#/#g') done # Resolve /component/.. sequences while [[ "$FILE_PATH" == *"/.."* ]]; do From 6ece37cc682495965407feed7f0ac346ece05dfb Mon Sep 17 00:00:00 2001 From: fitz123 Date: Sat, 6 Jun 2026 17:47:29 +0000 Subject: [PATCH 8/8] fix: propagate schema path to subagent children --- bot/src/__tests__/pi-rpc-protocol.test.ts | 3 +++ bot/src/pi-rpc-protocol.ts | 32 ++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/bot/src/__tests__/pi-rpc-protocol.test.ts b/bot/src/__tests__/pi-rpc-protocol.test.ts index 4de58626..33e07da5 100644 --- a/bot/src/__tests__/pi-rpc-protocol.test.ts +++ b/bot/src/__tests__/pi-rpc-protocol.test.ts @@ -962,6 +962,7 @@ describe("buildPiSubagentChildSpawnEnv", () => { "PATH", "PI_CODING_AGENT_SESSION_DIR", "PI_GUARD_WORKSPACE_ROOT", + "MINIME_SCHEMA_PATH", "SSH_AUTH_SOCK", "TAVILY_API_KEY", "TELEGRAM_BOT_TOKEN", @@ -976,6 +977,7 @@ describe("buildPiSubagentChildSpawnEnv", () => { process.env.PATH = "/usr/bin"; process.env.PI_CODING_AGENT_SESSION_DIR = "/tmp/pi-sessions"; process.env.PI_GUARD_WORKSPACE_ROOT = "/wrong/root"; + process.env[MINIME_SCHEMA_PATH_ENV] = "schemas/child-schema.md"; process.env.SSH_AUTH_SOCK = "/tmp/ssh-agent.sock"; process.env.TAVILY_API_KEY = "fixture"; process.env.TELEGRAM_BOT_TOKEN = "fixture"; @@ -985,6 +987,7 @@ describe("buildPiSubagentChildSpawnEnv", () => { const env = buildPiSubagentChildSpawnEnv(guardRoot); assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync(guardRoot)); + assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], join(realpathSync(guardRoot), "schemas", "child-schema.md")); assert.strictEqual(env.PI_CODING_AGENT_SESSION_DIR, "/tmp/pi-sessions"); assert.strictEqual(env.LC_CTYPE, "UTF-8"); assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); diff --git a/bot/src/pi-rpc-protocol.ts b/bot/src/pi-rpc-protocol.ts index f8d6b570..61a4932a 100644 --- a/bot/src/pi-rpc-protocol.ts +++ b/bot/src/pi-rpc-protocol.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; import { existsSync, realpathSync, statSync } from "node:fs"; -import { normalize, resolve } from "node:path"; +import { isAbsolute, normalize, resolve } from "node:path"; import type { AgentConfig, StreamLine, @@ -401,11 +401,31 @@ export function buildPiSpawnEnv(agent: AgentConfig): Record { return env; } -export function buildPiSubagentChildSpawnEnv(guardWorkspaceRoot: string): Record { - return { - ...buildAllowedPiChildEnv(), - [PI_GUARD_WORKSPACE_ROOT_ENV]: realpathSync(guardWorkspaceRoot), - }; +function resolveOptionalChildSchemaPath( + guardWorkspaceRoot: string, + schemaPath: string | undefined, +): string | undefined { + const trimmed = schemaPath?.trim(); + if (!trimmed) { + return undefined; + } + return normalize(isAbsolute(trimmed) ? trimmed : resolve(guardWorkspaceRoot, trimmed)); +} + +export function buildPiSubagentChildSpawnEnv( + guardWorkspaceRoot: string, + schemaPath = process.env[MINIME_SCHEMA_PATH_ENV], +): Record { + const realGuardWorkspaceRoot = realpathSync(guardWorkspaceRoot); + const env = buildAllowedPiChildEnv(); + env[PI_GUARD_WORKSPACE_ROOT_ENV] = realGuardWorkspaceRoot; + + const resolvedSchemaPath = resolveOptionalChildSchemaPath(realGuardWorkspaceRoot, schemaPath); + if (resolvedSchemaPath) { + env[MINIME_SCHEMA_PATH_ENV] = resolvedSchemaPath; + } + + return env; } function buildAllowedPiChildEnv(): Record {