From 2db98b35f281a9b478082d915f451a326ce8ee5c Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 21 Oct 2026 03:45:07 +0100 Subject: [PATCH 1/4] feat: select work tools per headless run --- packages/cli/README.md | 91 +- packages/cli/src/cli/cmd/generate.ts | 3 + packages/cli/src/cli/cmd/run.ts | 236 +- packages/cli/src/config/config.ts | 141 +- packages/cli/src/config/paths.ts | 95 +- packages/cli/src/plugin/index.ts | 1 - packages/cli/src/project/project.ts | 15 +- packages/cli/src/session/compaction.ts | 7 + packages/cli/src/session/llm.ts | 33 +- packages/cli/src/session/message-v2.ts | 2 + packages/cli/src/session/prompt.ts | 192 +- packages/cli/src/session/tool-catalog.ts | 56 + packages/cli/src/tool/batch.ts | 56 +- packages/cli/src/tool/guidance.ts | 11 +- packages/cli/src/tool/registry.ts | 11 +- packages/cli/src/tool/selection.ts | 55 + packages/cli/src/tool/task.ts | 21 +- packages/cli/src/tool/tool.ts | 16 + .../cli/run-tool-selection-attach.test.ts | 524 +++ .../cli/test/cli/run-tool-selection.test.ts | 1659 +++++++++ .../test/fixture/tool-selection-runtime.ts | 487 +++ packages/plugin/src/tool.ts | 9 + packages/sdk/script/build.ts | 5 +- packages/sdk/src/gen/types.gen.ts | 231 +- packages/sdk/src/v2/gen/types.gen.ts | 3291 +++++++++++++++++ 25 files changed, 6770 insertions(+), 478 deletions(-) create mode 100644 packages/cli/src/session/tool-catalog.ts create mode 100644 packages/cli/src/tool/selection.ts create mode 100644 packages/cli/test/cli/run-tool-selection-attach.test.ts create mode 100644 packages/cli/test/cli/run-tool-selection.test.ts create mode 100644 packages/cli/test/fixture/tool-selection-runtime.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 1feb60f7..812bb0f9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,6 +14,87 @@ The headless agent harness for CI, cron jobs, and container workflows. - **Declarative Permissions:** Per-tool allow/deny rules (`permission` config); tools denied everywhere are stripped from the model request to save tokens. - **Skills & MCP:** Progressive skill loading (`--skill`) and first-class [Model Context Protocol](https://modelcontextprotocol.io) support. +## Select work tools for one run + +Use exact registered names to choose an independent work-tool allowlist: + +```bash +aictrl run --tools read,glob,grep "Find the configuration entry points" +aictrl run --tools '' "Answer using only the supplied prompt" +``` + +Omitting `--tools` uses the final config's optional `tool_allowlist` preference; +without either input, normal tools remain available. For example, +`"tool_allowlist": ["read", "grep"]` sets a default, and `[]` selects no work +tools. Later config sources replace the whole array, including an empty array. +The CLI replaces that preference for the current invocation. It does not change +the next invocation's default, including on reused or forked sessions. + +Names are case-sensitive and exact: `read`, `write`, and `edit` are independent; +no aliases or automatic dependencies are added. Exact duplicates are removed in +first-seen order. Whitespace-only names, surrounding whitespace, empty comma +elements and unknown or model/flag-unavailable names fail before model inference. +A selected tool that permissions deny remains denied. Existing permission +aliases, path checks and last-match rules still apply. + +Start with a toolset that matches the task and the model's available catalog: + +| Task | `--tools` value | +| ------------------------------------- | --------------------------------- | +| Review code | `read,glob,grep` | +| Explain supplied code or a known file | `read` | +| Fix code with an edit/write catalog | `read,glob,grep,edit,write,bash` | +| Fix code with an apply_patch catalog | `read,glob,grep,apply_patch,bash` | + +Use the fix variant supported by the selected model; unavailable names fail. +Include `bash` only when the task needs shell commands such as tests. The review +and explain examples select registered work tools; existing permission checks +still apply. + +The saved user invocation retains its resolved selection for continuation and +compaction. When selection is active, delegated tasks (including resumed task +sessions) inherit a ceiling from the parent's effective tools; child config, +model and permissions can narrow it. Batch validates its whole group against the +same request catalog before executing any call. Recursive Batch and MCP tools +remain unsupported inside Batch. Custom tools retain their actual identity when +replacing a built-in name. Explicit caller-directed command subtasks keep their +existing Task authorization exception; an active selection still requires the +eligible native Task tool to be selected, without adding permission allow rules. + +`StructuredOutput`, invalid-call repair and LiteLLM's compatibility placeholder +are protocol/control tools, outside the work allowlist. Schema mode still needs +permission to call `StructuredOutput`; selecting work tools never grants that +permission. An empty work allowlist can therefore coexist with an explicitly +permitted StructuredOutput protocol tool. Custom tools with those names are still +work tools; protocol identity belongs to the harness-created definition. + +Custom tools can use the optional typed `context.selection` facade to inspect +`names`/`restricted`, assert availability, or call +`await context.selection.execute("read", { filePath: "/path/to/file" })`. +Dispatch is bound to the actual caller context, checks the same catalog and +permissions before hooks, and validates captured registry parameters. Omitting a +tool or denying it in permissions also prevents this supported nested dispatch. +The facade and names are immutable; arbitrary plugin host code remains trusted. + +Selection applies to harness-dispatched model work. Caller `--file`/`@file` +attachments and configured command-template shell expansion are input preparation +and keep their existing behavior. This is not an operating-system sandbox for +selected Bash commands or arbitrary plugin host code. Independent debug-tool +commands do not inherit `run` selection. + +**Attach:** this headless checkout has no owned server or verified server-side +selection capability. Any explicit or locally configured selection, including an +empty list, fails with `--attach` before receiver API calls, remote session +creation/fork/prompt/command or model inference. An explicit CLI selector fails +before configuration discovery. With the CLI selector omitted, read-only +configuration discovery preserves normal config precedence and can fetch +well-known configuration; this configuration HTTP is distinct from attach +receiver calls. It performs no plugin/dependency setup, config rewrites, legacy +TOML migration or token refresh. Invalid local configuration can therefore fail +before attach. Valid configuration without a selector retains the existing +attach transport and contents. Successful remote selection requires a future +supporting receiver and is not provided by this option. + ## Automation Usage Aictrl is built to be used in non-interactive environments. @@ -143,12 +224,12 @@ Aictrl reads config from `.aictrl/` (project) or `~/.config/aictrl/` (global). ### Models -| Provider | Env Var | -|----------|---------| -| Anthropic | `ANTHROPIC_API_KEY` | -| OpenAI | `OPENAI_API_KEY` | +| Provider | Env Var | +| ---------------- | -------------------------------------------------------------------- | +| Anthropic | `ANTHROPIC_API_KEY` | +| OpenAI | `OPENAI_API_KEY` | | Google AI Studio | `GOOGLE_GENERATIVE_AI_API_KEY`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` | -| OpenRouter | `OPENROUTER_API_KEY` | +| OpenRouter | `OPENROUTER_API_KEY` | Use `--variant` to select provider-specific reasoning effort. For OpenAI-compatible Ollama models, `--variant none` sends `reasoning_effort: "none"` to disable diff --git a/packages/cli/src/cli/cmd/generate.ts b/packages/cli/src/cli/cmd/generate.ts index cf9b83bb..9a9063ab 100644 --- a/packages/cli/src/cli/cmd/generate.ts +++ b/packages/cli/src/cli/cmd/generate.ts @@ -1,5 +1,6 @@ import type { CommandModule } from "yargs" import z from "zod" +import { Config } from "@/config/config" import { MessageV2 } from "@/session/message-v2" import { Session } from "@/session" import { ProviderTermination } from "@/provider/termination" @@ -13,6 +14,8 @@ export const GenerateCommand = { registry.add(ProviderTermination.Info, { id: "ProviderTermination" }) registry.add(MessageV2.Format, { id: "OutputFormat" }) registry.add(MessageV2.Assistant, { id: "AssistantMessage" }) + registry.add(MessageV2.User, { id: "UserMessage" }) + registry.add(Config.Info, { id: "Config" }) registry.add( z.object({ type: z.literal(Session.Event.StructuredOutput.type), diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 0d23ac9d..319d7b85 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -1,4 +1,6 @@ import { outputSchema, outputResult, OUTPUT_SCHEMA_CONFIG, OUTPUT_CONFIG_EXIT, OUTPUT_FAILED_EXIT } from "./run.output" +import { ToolSelection } from "../../tool/selection" +import { Config } from "../../config/config" import { OutputSchema } from "@/session/output-schema" import type { Argv } from "yargs" import path from "path" @@ -365,99 +367,107 @@ export const RunCommand = cmd({ command: "run [message..]", describe: "run aictrl with a message", builder: (yargs: Argv) => { - return yargs - .positional("message", { - describe: "message to send", - type: "string", - array: true, - default: [], - }) - .option("command", { - describe: "the command to run, use message for args", - type: "string", - }) - .option("continue", { - alias: ["c"], - describe: "continue the last session", - type: "boolean", - }) - .option("session", { - alias: ["s"], - describe: "session id to continue", - type: "string", - }) - .option("fork", { - describe: "fork the session before continuing (requires --continue or --session)", - type: "boolean", - }) - // Removed (#189); still parsed so the run can fail with a clear error. - .option("share", { - type: "boolean", - hidden: true, - }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) - .option("agent", { - type: "string", - describe: "agent to use", - }) - .option("format", { - type: "string", - choices: ["default", "json"], - default: "default", - describe: "format: default (formatted) or json (raw JSON events)", - }) - .option("output-schema", { - type: "string", - describe: - "JSON Schema file for the final result (object root); the agent writes the result to a JSON file in the working directory and passes its path to StructuredOutput", - }) - .option("output-schema-retries", { type: "number", describe: "additional corrective turns (0-10, default: 2)" }) - .option("output-result", { - type: "string", - describe: "atomically write only the validated JSON result to this file", - }) - .option("file", { - alias: ["f"], - type: "string", - array: true, - describe: "file(s) to attach to message", - }) - .option("title", { - type: "string", - describe: "title for the session (defaults to the truncated prompt, or / for command runs)", - }) - .option("attach", { - type: "string", - describe: "attach to a running aictrl server (e.g., http://localhost:4096)", - }) - .option("dir", { - type: "string", - describe: "directory to run in, path on remote server if attaching", - }) - .option("port", { - type: "number", - describe: "port for the local server (defaults to random port if no value provided)", - }) - .option("variant", { - type: "string", - describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", - }) - .option("thinking", { - type: "boolean", - describe: "show thinking blocks", - default: false, - }) - .option("skill", { - type: "string", - array: true, - // One value per flag, so a trailing message is not taken as a skill name. - nargs: 1, - describe: "only list and load this skill (repeatable); overrides config skills.allow", - }) + return ( + yargs + .positional("message", { + describe: "message to send", + type: "string", + array: true, + default: [], + }) + .option("tools", { + type: "string", + nargs: 1, + describe: + "exact comma-separated work-tool names; use an empty string for no work tools (permissions still apply)", + }) + .option("command", { + describe: "the command to run, use message for args", + type: "string", + }) + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + describe: "session id to continue", + type: "string", + }) + .option("fork", { + describe: "fork the session before continuing (requires --continue or --session)", + type: "boolean", + }) + // Removed (#189); still parsed so the run can fail with a clear error. + .option("share", { + type: "boolean", + hidden: true, + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }) + .option("format", { + type: "string", + choices: ["default", "json"], + default: "default", + describe: "format: default (formatted) or json (raw JSON events)", + }) + .option("output-schema", { + type: "string", + describe: + "JSON Schema file for the final result (object root); the agent writes the result to a JSON file in the working directory and passes its path to StructuredOutput", + }) + .option("output-schema-retries", { type: "number", describe: "additional corrective turns (0-10, default: 2)" }) + .option("output-result", { + type: "string", + describe: "atomically write only the validated JSON result to this file", + }) + .option("file", { + alias: ["f"], + type: "string", + array: true, + describe: "file(s) to attach to message", + }) + .option("title", { + type: "string", + describe: "title for the session (defaults to the truncated prompt, or / for command runs)", + }) + .option("attach", { + type: "string", + describe: "attach to a running aictrl server (e.g., http://localhost:4096)", + }) + .option("dir", { + type: "string", + describe: "directory to run in, path on remote server if attaching", + }) + .option("port", { + type: "number", + describe: "port for the local server (defaults to random port if no value provided)", + }) + .option("variant", { + type: "string", + describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", + }) + .option("thinking", { + type: "boolean", + describe: "show thinking blocks", + default: false, + }) + .option("skill", { + type: "string", + array: true, + // One value per flag, so a trailing message is not taken as a skill name. + nargs: 1, + describe: "only list and load this skill (repeatable); overrides config skills.allow", + }) + ) }, handler: async (args) => { const invocation = createRunInvocation(args.format === "json") @@ -468,6 +478,27 @@ export const RunCommand = cmd({ process.exit(exit) } + const requested = args.tools + if (requested !== undefined && typeof requested !== "string") { + await fail( + "Use --tools once with one comma-separated argument (for example, --tools read,bash).", + "INVOCATION_INVALID_ARGUMENTS", + ) + } + const toolAllowlist = + requested === undefined + ? undefined + : await Promise.resolve() + .then(() => ToolSelection.parse(requested)) + .catch((error) => fail(error.message, "INVOCATION_INVALID_ARGUMENTS")) + + if (args.attach && requested !== undefined) { + await fail( + "Work-tool selection is not supported with --attach: this client cannot verify server-side enforcement. Remove --tools or run locally.", + "INVOCATION_INVALID_ARGUMENTS", + ) + } + if (args.share === true) { await fail("--share was removed: session sharing is no longer supported", "INVOCATION_INVALID_ARGUMENTS") } @@ -794,7 +825,12 @@ export const RunCommand = cmd({ const message = await completedMessage(info) // message_complete stays primary-session only; child turns get their own event (#213) if (own) emit("message_complete", message) - else emit("subagent_message_complete", { subagentSessionID: info.sessionID, parentSessionID: sessionID, ...message }) + else + emit("subagent_message_complete", { + subagentSessionID: info.sessionID, + parentSessionID: sessionID, + ...message, + }) } } else if (toggles.get("start") !== true) { UI.empty() @@ -1120,6 +1156,7 @@ export const RunCommand = cmd({ if (args.command) { const opts = { agent, + toolAllowlist, model: args.model, command: args.command, arguments: message, @@ -1132,6 +1169,7 @@ export const RunCommand = cmd({ const model = args.model ? Provider.parseModel(args.model) : undefined const opts = { agent, + toolAllowlist, model, variant: args.variant, parts: [...files, { type: "text", text: message }], @@ -1174,6 +1212,14 @@ export const RunCommand = cmd({ invocation.phase("bootstrap") if (args.attach) { + // This checkout has no receiver/capability contract that can enforce selection. + const configured = await Config.readToolSelection(process.cwd()) + if (configured !== undefined) { + await fail( + "Work-tool selection is not supported with --attach: this client cannot verify server-side enforcement. Remove --tools and local tool_allowlist or run locally.", + "INVOCATION_INVALID_ARGUMENTS", + ) + } return await invocation.run(() => execute(createAictrlClient({ baseUrl: args.attach, directory }))) } @@ -1209,6 +1255,7 @@ export const RunCommand = cmd({ sessionID: opts.sessionID, parts: opts.parts, agent: opts.agent, + toolAllowlist: opts.toolAllowlist, model: opts.model, variant: opts.variant, ...(opts.format ? { format: opts.format } : {}), @@ -1220,6 +1267,7 @@ export const RunCommand = cmd({ command: opts.command, arguments: opts.arguments ?? "", agent: opts.agent, + toolAllowlist: opts.toolAllowlist, model: opts.model, variant: opts.variant, ...(opts.format ? { format: opts.format } : {}), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 932f1542..628c5a63 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1,10 +1,12 @@ +import { ToolSelection } from "../tool/selection" +import { Project } from "../project/project" import { Log } from "../util/log" import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createRequire } from "module" import os from "os" import z from "zod" -import { mergeDeep, pipe, unique } from "remeda" +import { mergeDeep, unique } from "remeda" import { Global } from "../global" import fs from "fs/promises" import { lazy } from "../util/lazy" @@ -55,9 +57,15 @@ export namespace Config { const managedDir = managedConfigDir() - // Custom merge function that concatenates array fields instead of replacing them - function mergeConfigConcatArrays(target: Info, source: Info): Info { + function mergeConfig(target: Info, source: Info): Info { const merged = mergeDeep(target, source) + if (source.tool_allowlist !== undefined) merged.tool_allowlist = source.tool_allowlist + return merged + } + + // Custom merge function that concatenates plugin/instruction array fields. + function mergeConfigConcatArrays(target: Info, source: Info): Info { + const merged = mergeConfig(target, source) if (target.plugin && source.plugin) { merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) } @@ -67,7 +75,25 @@ export namespace Config { return merged } - export const state = Instance.state(async () => { + type ReadOptions = { setup: boolean; environment?: Record } + + export const state = Instance.state(() => resolve(true)) + + // Attach preflight shares canonical parsing/precedence without local setup. + // Configuration discovery may fetch well-known configuration; it never calls + // the attach receiver or initializes plugins/dependencies or rewrites config. + export async function readToolSelection(directory: string) { + const { sandbox } = await Project.locate(directory, { cache: false }) + const value = (await resolve(false, { directory, worktree: sandbox })).config.tool_allowlist + return value === undefined ? undefined : ToolSelection.Names.parse(value) + } + + async function resolve(setup: boolean, location = { directory: Instance.directory, worktree: Instance.worktree }) { + const environment: Record = {} + const options: ReadOptions = { setup, environment: setup ? undefined : environment } + const content = (text: string, source: { path: string } | { dir: string; source: string }) => + load(text, source, options) + const read = (filepath: string) => loadFile(filepath, options) const auth = await Auth.all() // Config loading order (low -> high precedence): https://aictrl.ai/docs/config#precedence-order @@ -81,7 +107,8 @@ export namespace Config { let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { - process.env[value.key] = value.token + if (setup) process.env[value.key] = value.token + else environment[value.key] = value.token log.debug("fetching remote config", { url: `${key}/.well-known/aictrl` }) const response = await fetch(`${key}/.well-known/aictrl`) if (!response.ok) { @@ -93,7 +120,7 @@ export namespace Config { if (!remoteConfig.$schema) remoteConfig.$schema = "https://aictrl.ai/config.json" result = mergeConfigConcatArrays( result, - await load(JSON.stringify(remoteConfig), { + await content(JSON.stringify(remoteConfig), { dir: path.dirname(`${key}/.well-known/aictrl`), source: `${key}/.well-known/aictrl`, }), @@ -102,23 +129,26 @@ export namespace Config { } } - const token = await Control.token() + const settings = ConfigPaths.settings(options.environment) + const inline = options.environment?.AICTRL_CONFIG_CONTENT ?? process.env.AICTRL_CONFIG_CONTENT + + const token = setup ? await Control.token() : undefined if (token) { } // Global user config overrides remote config. - result = mergeConfigConcatArrays(result, await global()) + result = mergeConfigConcatArrays(result, setup ? await global() : await loadGlobal(options)) // Custom config path overrides global config. if (Flag.AICTRL_CONFIG) { - result = mergeConfigConcatArrays(result, await loadFile(Flag.AICTRL_CONFIG)) + result = mergeConfigConcatArrays(result, await read(Flag.AICTRL_CONFIG)) log.debug("loaded custom config", { path: Flag.AICTRL_CONFIG }) } // Project config overrides global and remote config. - if (!Flag.AICTRL_DISABLE_PROJECT_CONFIG) { - for (const file of await ConfigPaths.projectFiles("aictrl", Instance.directory, Instance.worktree)) { - result = mergeConfigConcatArrays(result, await loadFile(file)) + if (!settings.disableProject) { + for (const file of await ConfigPaths.projectFiles("aictrl", location.directory, location.worktree)) { + result = mergeConfigConcatArrays(result, await read(file)) } } @@ -126,20 +156,20 @@ export namespace Config { result.mode = result.mode || {} result.plugin = result.plugin || [] - const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree) + const directories = await ConfigPaths.directories(location.directory, location.worktree, options.environment) // .aictrl directory config overrides (project and global) config sources. - if (Flag.AICTRL_CONFIG_DIR) { - log.debug("loading config from AICTRL_CONFIG_DIR", { path: Flag.AICTRL_CONFIG_DIR }) + if (settings.configDir) { + log.debug("loading config from AICTRL_CONFIG_DIR", { path: settings.configDir }) } const deps = [] for (const dir of unique(directories)) { - if (dir.endsWith(".aictrl") || dir === Flag.AICTRL_CONFIG_DIR) { + if (dir.endsWith(".aictrl") || dir === settings.configDir) { for (const file of ["aictrl.jsonc", "aictrl.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) - result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) + result = mergeConfigConcatArrays(result, await read(path.join(dir, file))) // to satisfy the type checker result.agent ??= {} result.mode ??= {} @@ -147,27 +177,29 @@ export namespace Config { } } - deps.push( - iife(async () => { - const pkgExists = await Filesystem.exists(path.join(dir, "package.json")) - if (!pkgExists) return - const shouldInstall = await needsInstall(dir) - if (shouldInstall) await installDependencies(dir) - }), - ) + if (setup) { + deps.push( + iife(async () => { + const pkgExists = await Filesystem.exists(path.join(dir, "package.json")) + if (!pkgExists) return + const shouldInstall = await needsInstall(dir) + if (shouldInstall) await installDependencies(dir) + }), + ) - result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) - result.agent = mergeDeep(result.agent, await loadAgent(dir)) - result.agent = mergeDeep(result.agent, await loadMode(dir)) - result.plugin.push(...(await loadPlugin(dir))) + result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) + result.agent = mergeDeep(result.agent, await loadAgent(dir)) + result.agent = mergeDeep(result.agent, await loadMode(dir)) + result.plugin.push(...(await loadPlugin(dir))) + } } // Inline config content overrides all non-managed config sources. - if (process.env.AICTRL_CONFIG_CONTENT) { + if (inline) { result = mergeConfigConcatArrays( result, - await load(process.env.AICTRL_CONFIG_CONTENT, { - dir: Instance.directory, + await content(inline, { + dir: location.directory, source: "AICTRL_CONFIG_CONTENT", }), ) @@ -180,7 +212,7 @@ export namespace Config { // This way it only loads config file and not skills/plugins/commands if (existsSync(managedDir)) { for (const file of ["aictrl.jsonc", "aictrl.json"]) { - result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file))) + result = mergeConfigConcatArrays(result, await read(path.join(managedDir, file))) } } @@ -231,7 +263,7 @@ export namespace Config { directories, deps, } - }) + } export async function waitForDependencies() { const deps = await state().then((x) => x.deps) @@ -1121,6 +1153,9 @@ export namespace Config { layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: Permission.optional(), tools: z.record(z.string(), z.boolean()).optional(), + tool_allowlist: ToolSelection.Names.optional().describe( + "Default exact work-tool names for each invocation; [] selects no work tools. Permissions still apply.", + ), enterprise: z.unknown().optional().describe("@deprecated Session sharing was removed; this key is ignored."), compaction: z .object({ @@ -1163,13 +1198,13 @@ export namespace Config { export type Info = z.output - export const global = lazy(async () => { - let result: Info = pipe( - {}, - mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "aictrl.json"))), - mergeDeep(await loadFile(path.join(Global.Path.config, "aictrl.jsonc"))), - ) + export const global = lazy(() => loadGlobal({ setup: true })) + + async function loadGlobal(options: ReadOptions) { + let result: Info = {} + for (const name of ["config.json", "aictrl.json", "aictrl.jsonc"]) { + result = mergeConfig(result, await loadFile(path.join(Global.Path.config, name), options)) + } const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -1182,23 +1217,25 @@ export namespace Config { const { provider, model, ...rest } = mod.default if (provider && model) result.model = `${provider}/${model}` result["$schema"] = "https://aictrl.ai/config.json" - result = mergeDeep(result, rest) - await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result) - await fs.unlink(legacy) + result = mergeConfig(result, rest) + if (options.setup) { + await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result) + await fs.unlink(legacy) + } }) .catch(() => {}) } return result - }) + } export const { readFile } = ConfigPaths - async function loadFile(filepath: string): Promise { + async function loadFile(filepath: string, options: ReadOptions = { setup: true }): Promise { log.info("loading", { path: filepath }) const text = await readFile(filepath) if (!text) return {} - return load(text, { path: filepath }) + return load(text, { path: filepath }, options) } // Config keys of subsystems that were removed: LSP (#144), worktree snapshots (#181), session @@ -1217,13 +1254,19 @@ export namespace Config { log.warn(`deprecated config is ignored, its subsystem was removed: ${keys.join(", ")}`, { path: source }) } - async function load(text: string, options: { path: string } | { dir: string; source: string }) { + async function load( + text: string, + options: { path: string } | { dir: string; source: string }, + effects: ReadOptions = { setup: true }, + ) { const original = text const source = "path" in options ? options.path : options.source const isFile = "path" in options const data = await ConfigPaths.parseText( text, "path" in options ? options.path : { source: options.source, dir: options.dir }, + "error", + effects.environment, ) const normalized = (() => { @@ -1241,7 +1284,7 @@ export namespace Config { const parsed = Info.safeParse(normalized) if (parsed.success) { - if (!parsed.data.$schema && isFile) { + if (!parsed.data.$schema && isFile && effects.setup) { parsed.data.$schema = "https://aictrl.ai/config.json" const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://aictrl.ai/config.json",') await Bun.write(options.path, updated).catch(() => {}) diff --git a/packages/cli/src/config/paths.ts b/packages/cli/src/config/paths.ts index b7a7c0b9..be776484 100644 --- a/packages/cli/src/config/paths.ts +++ b/packages/cli/src/config/paths.ts @@ -19,10 +19,27 @@ export namespace ConfigPaths { return files } - export async function directories(directory: string, worktree: string) { + function home(environment?: Record) { + return environment?.[process.platform === "win32" ? "USERPROFILE" : "HOME"] ?? os.homedir() + } + + // Mirror only dynamic canonical environment reads. Module-load Flag constants + // must not acquire different precedence in read-only configuration discovery. + export function settings(environment?: Record) { + const disabled = environment?.AICTRL_DISABLE_PROJECT_CONFIG + return { + disableProject: + disabled === undefined ? Flag.AICTRL_DISABLE_PROJECT_CONFIG : ["true", "1"].includes(disabled.toLowerCase()), + configDir: environment?.AICTRL_CONFIG_DIR ?? Flag.AICTRL_CONFIG_DIR, + home: (environment?.AICTRL_TEST_HOME ?? process.env.AICTRL_TEST_HOME) || home(environment), + } + } + + export async function directories(directory: string, worktree: string, environment?: Record) { + const config = settings(environment) return [ Global.Path.config, - ...(!Flag.AICTRL_DISABLE_PROJECT_CONFIG + ...(!config.disableProject ? await Array.fromAsync( Filesystem.up({ targets: [".aictrl", ".opencode"], @@ -34,11 +51,11 @@ export namespace ConfigPaths { ...(await Array.fromAsync( Filesystem.up({ targets: [".aictrl", ".opencode"], - start: Global.Path.home, - stop: Global.Path.home, + start: config.home, + stop: config.home, }), )), - ...(Flag.AICTRL_CONFIG_DIR ? [Flag.AICTRL_CONFIG_DIR] : []), + ...(config.configDir ? [config.configDir] : []), ] } @@ -84,33 +101,62 @@ export namespace ConfigPaths { /** Allowed prefixes for {env:VAR} substitution. Fail-closed: unknown vars expand to empty. */ const ENV_ALLOWED_PREFIXES = [ // aictrl/opencode specific - "AICTRL_", "OPENCODE_", + "AICTRL_", + "OPENCODE_", // AI provider keys (users need these in config) - "ANTHROPIC_", "OPENAI_", "AZURE_", "AWS_", "GOOGLE_", - "GITHUB_", "MISTRAL_", "GROQ_", "TOGETHER_", - "DEEPSEEK_", "XAI_", "COHERE_", "FIREWORKS_", - "ZHIPU_", "AICORE_", + "ANTHROPIC_", + "OPENAI_", + "AZURE_", + "AWS_", + "GOOGLE_", + "GITHUB_", + "MISTRAL_", + "GROQ_", + "TOGETHER_", + "DEEPSEEK_", + "XAI_", + "COHERE_", + "FIREWORKS_", + "ZHIPU_", + "AICORE_", // Standard system vars - "HOME", "USER", "PATH", "SHELL", "TERM", "LANG", - "XDG_", "LC_", "NODE_", "BUN_", + "HOME", + "USER", + "PATH", + "SHELL", + "TERM", + "LANG", + "XDG_", + "LC_", + "NODE_", + "BUN_", // Proxy configuration - "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", - "http_proxy", "https_proxy", "no_proxy", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", ] - const ENV_EXACT_MATCHES = new Set(ENV_ALLOWED_PREFIXES.filter(p => !p.endsWith("_"))) - const ENV_PREFIX_MATCHES = ENV_ALLOWED_PREFIXES.filter(p => p.endsWith("_")) + const ENV_EXACT_MATCHES = new Set(ENV_ALLOWED_PREFIXES.filter((p) => !p.endsWith("_"))) + const ENV_PREFIX_MATCHES = ENV_ALLOWED_PREFIXES.filter((p) => p.endsWith("_")) function isAllowedEnvVar(name: string): boolean { if (ENV_EXACT_MATCHES.has(name)) return true - return ENV_PREFIX_MATCHES.some(prefix => name.startsWith(prefix)) + return ENV_PREFIX_MATCHES.some((prefix) => name.startsWith(prefix)) } /** Apply {env:VAR} and {file:path} substitutions to config text. */ - async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + async function substitute( + text: string, + input: ParseSource, + missing: "error" | "empty" = "error", + environment?: Record, + ) { text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { if (!isAllowedEnvVar(varName)) return "" - return process.env[varName] || "" + return environment?.[varName] ?? process.env[varName] ?? "" }) const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) @@ -136,7 +182,7 @@ export namespace ConfigPaths { let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) + filePath = path.join(home(environment), filePath.slice(2)) } const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) @@ -167,9 +213,14 @@ export namespace ConfigPaths { } /** Substitute and parse JSONC text, throwing JsonError on syntax errors. */ - export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") { + export async function parseText( + text: string, + input: ParseSource, + missing: "error" | "empty" = "error", + environment?: Record, + ) { const configSource = source(input) - text = await substitute(text, input, missing) + text = await substitute(text, input, missing, environment) const errors: JsoncParseError[] = [] const data = parseJsonc(text, errors, { allowTrailingComma: true }) diff --git a/packages/cli/src/plugin/index.ts b/packages/cli/src/plugin/index.ts index 6bbe0be0..a6ba40e4 100644 --- a/packages/cli/src/plugin/index.ts +++ b/packages/cli/src/plugin/index.ts @@ -117,7 +117,6 @@ export namespace Plugin { const hooks = await state().then((x) => x.hooks) const config = await Config.get() for (const hook of hooks) { - // @ts-expect-error this is because we haven't moved plugin to sdk v2 await hook.config?.(config) } Bus.subscribeAll(async (input) => { diff --git a/packages/cli/src/project/project.ts b/packages/cli/src/project/project.ts index 52b40e1c..f5cf7204 100644 --- a/packages/cli/src/project/project.ts +++ b/packages/cli/src/project/project.ts @@ -87,10 +87,9 @@ export namespace Project { } } - export async function fromDirectory(directory: string) { - log.info("fromDirectory", { directory }) - - const data = await iife(async () => { + /** Discover actual roots without opening the database or persisting project state. */ + export async function locate(directory: string, options: { cache: boolean } = { cache: false }) { + return iife(async () => { const matches = Filesystem.up({ targets: [".git"], start: directory }) const dotgit = await matches.next().then((x) => x.value) await matches.return() @@ -137,7 +136,7 @@ export namespace Project { } id = roots[0] - if (id) { + if (id && options.cache) { await Filesystem.write(path.join(dotgit, "aictrl"), id).catch(() => undefined) } } @@ -202,6 +201,12 @@ export namespace Project { vcs: Info.shape.vcs.parse(Flag.AICTRL_FAKE_VCS), } }) + } + + export async function fromDirectory(directory: string) { + log.info("fromDirectory", { directory }) + + const data = await locate(directory, { cache: true }) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) const existing = await iife(async () => { diff --git a/packages/cli/src/session/compaction.ts b/packages/cli/src/session/compaction.ts index bf8a3aa3..00093015 100644 --- a/packages/cli/src/session/compaction.ts +++ b/packages/cli/src/session/compaction.ts @@ -1,3 +1,4 @@ +import { ToolSelection } from "../tool/selection" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Session } from "." @@ -210,6 +211,7 @@ When constructing the summary, try to stick to this template: }, agent: userMessage.agent, model: userMessage.model, + toolSelection: userMessage.toolSelection, }) await Session.updatePart({ id: Identifier.ascending("part"), @@ -238,14 +240,19 @@ When constructing the summary, try to stick to this template: modelID: z.string(), }), auto: z.boolean(), + toolSelection: ToolSelection.Snapshot.optional(), }), async (input) => { + const previous = (await MessageV2.filterCompacted(MessageV2.stream(input.sessionID))).findLast( + (message) => message.info.role === "user", + )?.info const msg = await Session.updateMessage({ id: Identifier.ascending("message"), role: "user", model: input.model, sessionID: input.sessionID, agent: input.agent, + toolSelection: input.toolSelection ?? (previous?.role === "user" ? previous.toolSelection : undefined), time: { created: Date.now(), }, diff --git a/packages/cli/src/session/llm.ts b/packages/cli/src/session/llm.ts index 8925e2ea..e213f717 100644 --- a/packages/cli/src/session/llm.ts +++ b/packages/cli/src/session/llm.ts @@ -23,6 +23,7 @@ import { SystemPrompt } from "./system" import { PermissionNext } from "@/permission/next" import { Auth } from "@/auth" import { ProviderTermination } from "@/provider/termination" +import { ToolSelection } from "@/tool/selection" import { ToolGuidance } from "@/tool/guidance" export namespace LLM { @@ -164,12 +165,14 @@ export namespace LLM { input.model.api.id.toLowerCase().includes("litellm") if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { - tools["_noop"] = tool({ - description: - "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed", - inputSchema: jsonSchema({ type: "object", properties: {} }), - execute: async () => ({ output: "", title: "", metadata: {} }), - }) + tools["_noop"] = ToolGuidance.bindProtocol( + tool({ + description: + "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed", + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => ({ output: "", title: "", metadata: {} }), + }), + ) } return streamText({ @@ -181,13 +184,12 @@ export namespace LLM { async experimental_repairToolCall(failed) { if (failed.toolCall.toolName === "StructuredOutput" && input.structured) { input.structured.repairs.add(failed.toolCall.toolCallId) + const error = await input.structured.reject(failed.toolCall.input) + if (!tools.invalid || !ToolGuidance.isProtocol(tools.invalid)) return null return { ...failed.toolCall, toolName: "invalid", - input: JSON.stringify({ - tool: "StructuredOutput", - error: await input.structured.reject(failed.toolCall.input), - }), + input: JSON.stringify({ tool: "StructuredOutput", error }), } } const lower = failed.toolCall.toolName.toLowerCase() @@ -201,6 +203,8 @@ export namespace LLM { toolName: lower, } } + // A user tool with this name is work, never a repair destination. + if (!tools.invalid || !ToolGuidance.isProtocol(tools.invalid)) return null return { ...failed.toolCall, input: JSON.stringify({ @@ -214,7 +218,7 @@ export namespace LLM { topP: params.topP, topK: params.topK, providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + activeTools: Object.keys(tools).filter((name) => name !== "invalid" || !ToolGuidance.isProtocol(tools[name])), tools, toolChoice: input.toolChoice, maxOutputTokens, @@ -283,8 +287,13 @@ export namespace LLM { const ruleset = PermissionNext.merge(input.agent.permission, input.permission ?? []) const tools = { ...input.tools } const disabled = PermissionNext.disabled(Object.keys(tools), ruleset) + const selection = ToolSelection.restore(input.user.toolSelection) for (const tool of Object.keys(tools)) { - if (input.user.tools?.[tool] === false || disabled.has(tool)) { + if ( + input.user.tools?.[tool] === false || + disabled.has(tool) || + (!ToolGuidance.isProtocol(tools[tool]) && selection.allowlist !== null && !selection.allowlist.includes(tool)) + ) { delete tools[tool] } } diff --git a/packages/cli/src/session/message-v2.ts b/packages/cli/src/session/message-v2.ts index 42e9e3ec..9392a238 100644 --- a/packages/cli/src/session/message-v2.ts +++ b/packages/cli/src/session/message-v2.ts @@ -1,3 +1,4 @@ +import { ToolSelection } from "../tool/selection" import { BusEvent } from "@/bus/bus-event" import z from "zod" import { NamedError } from "@aictrl/util/error" @@ -374,6 +375,7 @@ export namespace MessageV2 { }), system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + toolSelection: ToolSelection.Snapshot.optional(), variant: z.string().optional(), }).meta({ ref: "UserMessage", diff --git a/packages/cli/src/session/prompt.ts b/packages/cli/src/session/prompt.ts index 027bac11..299b822d 100644 --- a/packages/cli/src/session/prompt.ts +++ b/packages/cli/src/session/prompt.ts @@ -1,3 +1,7 @@ +import { Config } from "../config/config" +import { preflightBatch } from "../tool/batch" +import { ToolSelection } from "../tool/selection" +import { ToolCatalog } from "./tool-catalog" import { OutputSchema } from "./output-schema" import path from "path" import os from "os" @@ -121,6 +125,7 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), + toolAllowlist: ToolSelection.Names.optional(), tools: z .record(z.string(), z.boolean()) .optional() @@ -177,28 +182,35 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer - export const prompt = fn(PromptInput, async (input) => { + export const prompt = fn(PromptInput, (input) => submit(input)) + + /** Internal delegation entry: the caller's immutable effective ceiling is never a session default. */ + export function child(input: PromptInput, ceiling?: readonly string[]) { + return submit(PromptInput.parse(input), ceiling) + } + + async function submit(input: PromptInput, ceiling?: readonly string[]) { // Claim the session before writing the user message, so a second prompt // fails before it leaves a message for the running loop to pick up. const abort = start(input.sessionID) - const message = await recordPrompt(input).catch((error) => { + const recorded = await recordPrompt(input, ceiling).catch((error) => { endBeforeLoop(input.sessionID, abort) throw error }) if (input.noReply === true) { endBeforeLoop(input.sessionID, abort) - return message + return recorded.message } - return run(input.sessionID, abort) - }) + return run(input.sessionID, abort, recorded.catalog) + } - async function recordPrompt(input: PromptInput) { + async function recordPrompt(input: PromptInput, ceiling?: readonly string[]) { if (input.format?.type === "json_schema") OutputSchema.compile(input.format.schema) const session = await Session.get(input.sessionID) - const message = await createUserMessage(input) + const { message, catalog } = await createUserMessage(input, ceiling) await Session.touch(input.sessionID) // this is backwards compatibility for allowing `tools` to be specified when @@ -216,7 +228,7 @@ export namespace SessionPrompt { await Session.setPermission({ sessionID: session.id, permission: permissions }) } - return message + return { message, catalog } } export async function resolvePromptParts(template: string): Promise { @@ -313,7 +325,7 @@ export namespace SessionPrompt { }) export const loop = fn(LoopInput, async (input) => run(input.sessionID, start(input.sessionID))) - async function run(sessionID: string, abort: AbortSignal) { + async function run(sessionID: string, abort: AbortSignal, prepared?: ToolCatalog.Info) { using _ = defer(() => { release(sessionID, abort) // Keep idle after processor persistence. Headless consumers use this as @@ -433,13 +445,6 @@ export namespace SessionPrompt { await Bus.publish(Session.Event.Error, { sessionID, error }) break } - if (step === 1) - ensureTitle({ - session, - modelID: lastUser.model.modelID, - providerID: lastUser.model.providerID, - history: msgs, - }).catch((e) => log.error("ensureTitle failed", { error: e })) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { if (Provider.ModelNotFoundError.isInstance(e)) { @@ -453,12 +458,52 @@ export namespace SessionPrompt { } throw e }) + const selection = ToolSelection.restore(lastUser.toolSelection) + // Reuse validation's catalog only for its first turn. Later turns must + // observe MCP discovery invalidation while keeping each request captured. + const catalog = prepared ?? (await ToolCatalog.load(agent, model, sessionID)) + prepared = undefined + const names = ToolCatalog.names( + catalog, + selection, + PermissionNext.merge(agent.permission, session.permission ?? []), + lastUser.tools, + contract ? ["StructuredOutput"] : [], + ) + if ( + contract && + PermissionNext.disabled( + ["StructuredOutput"], + PermissionNext.merge(agent.permission, session.permission ?? []), + ).has("StructuredOutput") + ) { + throw new Error( + "StructuredOutput is denied by the current permissions. Permit StructuredOutput explicitly or remove schema mode; work-tool selection does not grant protocol permissions.", + ) + } const task = tasks.pop() + if (task?.type === "subtask" && selection.allowlist !== null) { + const entry = catalog.entries.get("task") + if (!selection.allowlist.includes("task") || entry?.kind !== "registry" || entry.item.builtin !== "task") + throw new Error("Tool 'task' is unavailable or excluded by this invocation's tool selection") + } + if (step === 1) + ensureTitle({ + session, + modelID: lastUser.model.modelID, + providerID: lastUser.model.providerID, + history: msgs, + }).catch((e) => log.error("ensureTitle failed", { error: e })) // pending subtask // TODO: centralize "invoke tool" logic if (task?.type === "subtask") { - const taskTool = await TaskTool.init() + // Explicit caller-directed subtasks retain their historical authorization + // exception. Active selection gates identity/eligibility, never injects allows. + const entry = catalog.entries.get("task") + const taskTool = + selection.allowlist === null ? await TaskTool.init() : entry?.kind === "registry" ? entry.item : undefined + if (!taskTool) throw new Error("Missing native Task entry after subtask selection preflight") const taskModel = task.model ? await Provider.getModel(task.model.providerID, task.model.modelID) : model const assistantMessage = (await Session.updateMessage({ id: Identifier.ascending("message"), @@ -530,6 +575,17 @@ export namespace SessionPrompt { abort, callID: part.callID, extra: { bypassAgentCheck: true }, + selection: { + names, + restricted: selection.allowlist !== null, + assert(name) { + if (!names.includes(name)) + throw new Error(`Tool '${name}' is excluded by this invocation's tool selection/permissions`) + }, + async execute() { + throw new Error("Nested direct dispatch is unavailable before subtask setup") + }, + }, messages: msgs, async metadata(input) { await Session.updatePart({ @@ -619,6 +675,7 @@ export namespace SessionPrompt { }, agent: lastUser.agent, model: lastUser.model, + toolSelection: lastUser.toolSelection, } await Session.updateMessage(summaryUserMsg) await Session.updatePart({ @@ -660,6 +717,7 @@ export namespace SessionPrompt { sessionID, agent: lastUser.agent, model: lastUser.model, + toolSelection: lastUser.toolSelection, auto: true, }) continue @@ -709,6 +767,9 @@ export namespace SessionPrompt { session, model, tools: lastUser.tools, + selection, + catalog, + controls: contract ? ["StructuredOutput"] : [], processor, bypassAgentCheck, messages: msgs, @@ -829,6 +890,7 @@ export namespace SessionPrompt { sessionID, agent: lastUser.agent, model: lastUser.model, + toolSelection: lastUser.toolSelection, auto: true, }) continue @@ -889,12 +951,50 @@ export namespace SessionPrompt { model: Provider.Model session: Session.Info tools?: Record + selection?: ToolSelection.Snapshot + catalog?: ToolCatalog.Info + controls?: readonly string[] processor: SessionProcessor.Info bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { using _ = log.time("resolveTools") const tools: Record = {} + const catalog = input.catalog ?? (await ToolCatalog.load(input.agent, input.model, input.session.id)) + const snapshot = ToolSelection.restore(input.selection) + const names = ToolCatalog.names( + catalog, + snapshot, + PermissionNext.merge(input.agent.permission, input.session.permission ?? []), + input.tools, + input.controls, + ) + const selection: NonNullable = { + names, + restricted: snapshot.allowlist !== null, + assert(name, batch = false) { + if (!names.includes(name)) + throw new Error(`Tool '${name}' is unavailable or excluded by this invocation's tool selection/permissions`) + if (batch && (name === "batch" || catalog.entries.get(name)?.kind !== "registry")) + throw new Error( + `Tool '${name}' cannot be batched; recursive batch and external MCP tools must be called directly`, + ) + }, + async execute(name, args, ctx) { + selection.assert(name) + const definition = tools[name] + if (!definition?.execute) throw new Error(`Tool '${name}' has no executable request definition`) + const entry = catalog.entries.get(name) + const parsed = entry?.kind === "registry" ? entry.item.parameters.parse(args) : args + return (await definition.execute(parsed, { + toolCallId: ctx.callID ?? Identifier.ascending("part"), + messages: [], + abortSignal: ctx.abort, + })) as Awaited["execute"]>> + }, + } + + Object.freeze(selection) const context = (toolId: string, args: any, options: ToolCallOptions): Tool.Context => ({ sessionID: input.session.id, @@ -904,6 +1004,7 @@ export namespace SessionPrompt { extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck }, agent: input.agent.name, messages: input.messages, + selection, metadata: async (val: { title?: string; metadata?: any }) => { const match = input.processor.partFromToolCall(options.toolCallId) if (match && match.state.status === "running") { @@ -936,17 +1037,17 @@ export namespace SessionPrompt { }, }) - for (const item of await ToolRegistry.tools( - { modelID: input.model.api.id, providerID: input.model.providerID }, - input.agent, - input.session.id, - )) { + for (const [name, entry] of catalog.entries) { + if (entry.kind !== "registry") continue + const item = entry.item const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const definition = tool({ id: item.id as any, description: item.description, inputSchema: jsonSchema(schema as any), async execute(args, options) { + if (ToolCatalog.work(entry)) selection.assert(item.id) + if (item.builtin === "batch") preflightBatch(args, selection) const ctx = context(item.id, args, options) await Plugin.trigger( "tool.execute.before", @@ -985,7 +1086,10 @@ export namespace SessionPrompt { tools[item.id] = ToolGuidance.bind(definition, item.builtin, item.renderDescription) } - for (const [key, item] of Object.entries(await MCP.tools())) { + for (const [key, entry] of catalog.entries) { + if (entry.kind !== "mcp") continue + // Never mutate a cached MCP SDK definition between invocations. + const item = { ...entry.item } const execute = item.execute if (!execute) continue @@ -993,6 +1097,7 @@ export namespace SessionPrompt { item.inputSchema = jsonSchema(transformed) // Wrap execute to add plugin hooks and format output item.execute = async (args, opts) => { + selection.assert(key) const ctx = context(key, args, opts) await Plugin.trigger( @@ -1078,7 +1183,9 @@ export namespace SessionPrompt { tools[key] = item } - return tools + return Object.fromEntries( + Object.entries(tools).filter(([name, definition]) => ToolGuidance.isProtocol(definition) || names.includes(name)), + ) } /** @internal Exported for testing */ @@ -1131,7 +1238,7 @@ export namespace SessionPrompt { }, }) // Retain the internal identifier used by tool consumers. - return Object.assign(result, { id: "StructuredOutput" as const }) + return Object.assign(ToolGuidance.bindProtocol(result), { id: "StructuredOutput" as const }) } // a client can still name an agent that no longer exists, such as the removed plan agent @@ -1148,10 +1255,34 @@ export namespace SessionPrompt { throw error } - async function createUserMessage(input: PromptInput) { + async function createUserMessage(input: PromptInput, ceiling?: readonly string[]) { const agent = await requireAgent(input.agent ?? (await Agent.defaultAgent()), input.sessionID) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) + const preference = input.toolAllowlist ?? (await Config.get()).tool_allowlist + const selection = ToolSelection.resolve(preference, ceiling, input.toolAllowlist !== undefined) + // Omitted selection preserves noReply's ability to record before a provider + // is available. An actual selection validates its catalog before persistence. + const catalog = + selection.allowlist === null + ? undefined + : await ToolCatalog.load(agent, await Provider.getModel(model.providerID, model.modelID), input.sessionID) + // Only a newly explicit preference is validated as input. Inherited names absent + // from a child's different model catalog are an ordinary intersection. + if (catalog) { + const controls = [ + ...ToolCatalog.controls(catalog), + ...(input.format?.type === "json_schema" ? ["StructuredOutput"] : []), + ] + ToolSelection.validate( + input.toolAllowlist ?? (ceiling === undefined ? (preference ?? null) : null), + [...catalog.entries.keys()].filter((name) => !controls.includes(name)), + catalog.known, + controls, + ) + } + if (selection.allowlist) Object.freeze(selection.allowlist) + Object.freeze(selection) const full = !input.variant && agent.variant ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) @@ -1166,6 +1297,7 @@ export namespace SessionPrompt { created: Date.now(), }, tools: input.tools, + toolSelection: selection, agent: agent.name, model, system: input.system, @@ -1484,10 +1616,7 @@ export namespace SessionPrompt { await Session.updatePart(part) } - return { - info, - parts, - } + return { message: { info, parts }, catalog } } export const CommandInput = z.object({ @@ -1497,6 +1626,7 @@ export namespace SessionPrompt { model: z.string().optional(), arguments: z.string(), command: z.string(), + toolAllowlist: ToolSelection.Names.optional(), format: MessageV2.Format.optional(), variant: z.string().optional(), parts: z @@ -1530,6 +1660,7 @@ export namespace SessionPrompt { } export async function command(input: CommandInput) { + input = CommandInput.parse(input) log.info("command", input) const command = await Command.get(input.command) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) @@ -1651,6 +1782,7 @@ export namespace SessionPrompt { agent: userAgent, parts, format: input.format, + toolAllowlist: input.toolAllowlist, variant: input.variant, })) as MessageV2.WithParts diff --git a/packages/cli/src/session/tool-catalog.ts b/packages/cli/src/session/tool-catalog.ts new file mode 100644 index 00000000..895702ca --- /dev/null +++ b/packages/cli/src/session/tool-catalog.ts @@ -0,0 +1,56 @@ +import { ToolRegistry } from "../tool/registry" +import { MCP } from "../mcp" +import { ToolSelection } from "../tool/selection" +import { PermissionNext } from "../permission/next" +import type { Agent } from "../agent/agent" +import type { Provider } from "../provider/provider" + +/** A resolved invocation catalog preserves actual registry/MCP collision identity. */ +export namespace ToolCatalog { + type Registered = Awaited>[number] + type External = Awaited>[string] + export type Entry = { kind: "registry"; item: Registered } | { kind: "mcp"; item: External } + export type Info = { entries: Map; known: string[] } + + export async function load(agent: Agent.Info, model: Provider.Model, sessionID: string): Promise { + const entries = new Map() + for (const item of await ToolRegistry.tools( + { modelID: model.api.id, providerID: model.providerID }, + agent, + sessionID, + )) + entries.set(item.id, { kind: "registry", item }) + for (const [name, item] of Object.entries(await MCP.tools())) { + if (item.execute) entries.set(name, { kind: "mcp", item }) + } + return { entries, known: [...new Set([...(await ToolRegistry.ids()), ...entries.keys()])] } + } + + export function work(entry: Entry) { + return entry.kind !== "registry" || entry.item.builtin !== "invalid" + } + + export function controls(catalog: Info) { + return [...catalog.entries].flatMap(([name, entry]) => (work(entry) ? [] : [name])) + } + + export function names( + catalog: Info, + snapshot: ToolSelection.Snapshot, + permission: PermissionNext.Ruleset, + legacy?: Record, + controls: readonly string[] = [], + ) { + const disabled = PermissionNext.disabled([...catalog.entries.keys()], permission) + return Object.freeze( + [...catalog.entries.keys()].filter( + (name) => + work(catalog.entries.get(name)!) && + !controls.includes(name) && + !disabled.has(name) && + legacy?.[name] !== false && + (snapshot.allowlist === null || snapshot.allowlist.includes(name)), + ), + ) + } +} diff --git a/packages/cli/src/tool/batch.ts b/packages/cli/src/tool/batch.ts index aa91a53e..c3ed6ebf 100644 --- a/packages/cli/src/tool/batch.ts +++ b/packages/cli/src/tool/batch.ts @@ -6,21 +6,28 @@ import { ToolGuidance } from "./guidance" const DISALLOWED = new Set(["batch"]) const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED]) +const parameters = z.object({ + tool_calls: z + .array( + z.object({ + tool: z.string().describe("The name of the tool to execute"), + parameters: z.object({}).loose().describe("Parameters for the tool"), + }), + ) + .min(1, "Provide at least one tool call") + .describe("Array of tool calls to execute in parallel"), +}) + +export function preflightBatch(input: unknown, selection: NonNullable) { + const parsed = parameters.parse(input) + for (const call of parsed.tool_calls) selection.assert(call.tool, true) +} + export const BatchTool = Tool.define("batch", async () => { return { description: DESCRIPTION, renderDescription: ToolGuidance.batch, - parameters: z.object({ - tool_calls: z - .array( - z.object({ - tool: z.string().describe("The name of the tool to execute"), - parameters: z.object({}).loose().describe("Parameters for the tool"), - }), - ) - .min(1, "Provide at least one tool call") - .describe("Array of tool calls to execute in parallel"), - }), + parameters, formatValidationError(error) { const formattedErrors = error.issues .map((issue) => { @@ -38,9 +45,15 @@ export const BatchTool = Tool.define("batch", async () => { const toolCalls = params.tool_calls.slice(0, 25) const discardedCalls = params.tool_calls.slice(25) - const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) - const toolMap = new Map(availableTools.map((t) => [t.id, t])) + // Request execution shares the exact resolved catalog and permission/selection + // intersection. Validate the whole group before hooks or any grouped effect. + if (ctx.selection) preflightBatch(params, ctx.selection) + const toolMap = ctx.selection + ? undefined + : await import("./registry").then( + async ({ ToolRegistry }) => + new Map((await ToolRegistry.tools({ modelID: "", providerID: "" })).map((tool) => [tool.id, tool])), + ) const executeCall = async (call: (typeof toolCalls)[0]) => { const callStartTime = Date.now() @@ -53,14 +66,16 @@ export const BatchTool = Tool.define("batch", async () => { ) } - const tool = toolMap.get(call.tool) - if (!tool) { - const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name)) + const tool = toolMap?.get(call.tool) + if (!ctx.selection && !tool) { + const availableToolsList = Array.from(toolMap!.keys()).filter( + (name) => !FILTERED_FROM_SUGGESTIONS.has(name), + ) throw new Error( `Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`, ) } - const validatedParams = tool.parameters.parse(call.parameters) + const validatedParams = tool ? tool.parameters.parse(call.parameters) : call.parameters await Session.updatePart({ id: partID, @@ -78,7 +93,10 @@ export const BatchTool = Tool.define("batch", async () => { }, }) - const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + const child = { ...ctx, callID: partID } + const result = ctx.selection + ? await ctx.selection.execute(call.tool, validatedParams, child) + : await tool!.execute(validatedParams, child) const attachments = result.attachments?.map((attachment) => ({ ...attachment, id: Identifier.ascending("part"), diff --git a/packages/cli/src/tool/guidance.ts b/packages/cli/src/tool/guidance.ts index 176ecfb6..319d4e43 100644 --- a/packages/cli/src/tool/guidance.ts +++ b/packages/cli/src/tool/guidance.ts @@ -3,15 +3,24 @@ import type { Tool } from "ai" export namespace ToolGuidance { const builtin = Symbol("builtin") const renderer = Symbol("description.renderer") + const protocol = Symbol("protocol.control") export type Renderer = (description: string, tools: ReadonlySet) => string - type Owned = Tool & { [builtin]?: string; [renderer]?: Renderer } + type Owned = Tool & { [builtin]?: string; [renderer]?: Renderer; [protocol]?: true } export function bind(tool: Tool, id?: string, render?: Renderer): Tool { if (!id) return tool return { ...tool, [builtin]: id, [renderer]: render } as Owned } + export function bindProtocol(tool: T): T { + return { ...tool, [protocol]: true } + } + + export function isProtocol(tool: Tool) { + return (tool as Owned)[protocol] === true || (tool as Owned)[builtin] === "invalid" + } + export function capabilities(tools: Record) { return new Set( Object.entries(tools).flatMap(([id, tool]) => (id !== "invalid" && (tool as Owned)[builtin] === id ? [id] : [])), diff --git a/packages/cli/src/tool/registry.ts b/packages/cli/src/tool/registry.ts index 7a6f71b6..1be37c96 100644 --- a/packages/cli/src/tool/registry.ts +++ b/packages/cli/src/tool/registry.ts @@ -80,11 +80,20 @@ export namespace ToolRegistry { parameters: z.object(def.args), description: def.description, execute: async (args, ctx) => { + const selection = ctx.selection const pluginCtx = { ...ctx, directory: Instance.directory, worktree: Instance.worktree, - } as unknown as PluginToolContext + selection: selection + ? Object.freeze({ + names: Object.freeze([...selection.names]), + restricted: selection.restricted, + assert: (name: string) => selection.assert(name), + execute: (name: string, args: unknown) => selection.execute(name, args, ctx), + }) + : undefined, + } satisfies PluginToolContext const result = await def.execute(args as any, pluginCtx) const out = await Truncate.output(result, {}, initCtx?.agent) return { diff --git a/packages/cli/src/tool/selection.ts b/packages/cli/src/tool/selection.ts new file mode 100644 index 00000000..a0db51f5 --- /dev/null +++ b/packages/cli/src/tool/selection.ts @@ -0,0 +1,55 @@ +import z from "zod" + +/** Work-tool selection is independent of permission rules and protocol tools. */ +export namespace ToolSelection { + export const Names = z + .array( + z + .string() + .min(1) + .refine((name) => name.trim() === name, "Tool names must not contain surrounding whitespace"), + ) + .transform((names) => [...new Set(names)]) + export const Snapshot = z.object({ version: z.literal(1), allowlist: Names.nullable() }).strict() + export type Snapshot = z.infer + + export function parse(value: string) { + return Names.parse(value === "" ? [] : value.split(",")) + } + + export function resolve(preference: string[] | undefined, ceiling?: readonly string[], explicit = true): Snapshot { + const names = preference === undefined ? undefined : Names.parse(preference) + if (explicit && ceiling && names?.some((name) => !ceiling.includes(name))) { + throw new Error( + `Tool selection cannot widen the parent invocation: ${names.filter((name) => !ceiling.includes(name)).join(", ")}`, + ) + } + return { + version: 1, + allowlist: ceiling ? [...ceiling].filter((name) => !names || names.includes(name)) : (names ?? null), + } + } + + export function restore(value?: Snapshot): Snapshot { + return value === undefined ? { version: 1, allowlist: null } : Snapshot.parse(value) + } + + export function validate( + names: readonly string[] | null, + eligible: readonly string[], + known: readonly string[], + controls: readonly string[] = [], + ) { + for (const name of names ?? []) { + if (eligible.includes(name) && !controls.includes(name)) continue + const reason = controls.includes(name) + ? "is a protocol/control tool, not a selectable work tool" + : known.includes(name) + ? "is unavailable for the current model or enabled tools" + : "is unknown or unavailable in the current tool catalog" + throw new Error( + `Tool '${name}' ${reason}. Available work tools: ${eligible.filter((id) => !controls.includes(id)).join(", ") || "(none)"}`, + ) + } + } +} diff --git a/packages/cli/src/tool/task.ts b/packages/cli/src/tool/task.ts index d8b678ab..db9008ca 100644 --- a/packages/cli/src/tool/task.ts +++ b/packages/cli/src/tool/task.ts @@ -145,16 +145,19 @@ export const TaskTool = Tool.define("task", async (ctx) => { using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) - const result = await SessionPrompt.prompt({ - messageID, - sessionID: session.id, - model: { - modelID: model.modelID, - providerID: model.providerID, + const result = await SessionPrompt.child( + { + messageID, + sessionID: session.id, + model: { + modelID: model.modelID, + providerID: model.providerID, + }, + agent: agent.name, + parts: promptParts, }, - agent: agent.name, - parts: promptParts, - }) + ctx.selection?.restricted ? ctx.selection.names : undefined, + ) const text = await completeTask(result, session.id, ctx.sessionID) diff --git a/packages/cli/src/tool/tool.ts b/packages/cli/src/tool/tool.ts index e76c1ca5..502dd5bc 100644 --- a/packages/cli/src/tool/tool.ts +++ b/packages/cli/src/tool/tool.ts @@ -22,6 +22,22 @@ export namespace Tool { abort: AbortSignal callID?: string extra?: { [key: string]: any } + /** Request-local checked dispatch. Absent only for independent/debug tool callers. */ + selection?: { + readonly names: readonly string[] + readonly restricted: boolean + assert(name: string, batch?: boolean): void + execute( + name: string, + args: unknown, + ctx: Context, + ): Promise<{ + title: string + metadata: Metadata + output: string + attachments?: Omit[] + }> + } messages: MessageV2.WithParts[] metadata(input: { title?: string; metadata?: M }): void ask(input: Omit): Promise diff --git a/packages/cli/test/cli/run-tool-selection-attach.test.ts b/packages/cli/test/cli/run-tool-selection-attach.test.ts new file mode 100644 index 00000000..9205763c --- /dev/null +++ b/packages/cli/test/cli/run-tool-selection-attach.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs/promises" +import os from "node:os" +import { Database as SQLite } from "bun:sqlite" +import { existsSync } from "node:fs" + +const root = path.resolve(import.meta.dir, "../../src") +type Input = { + label: string + args?: string[] + mode?: "readonly" | "normal" | "deadline-hang" + inline?: Record + remote?: Record + authKey?: string + authValue?: string + env?: Record + files?: Record> + legacy?: boolean + control?: boolean + deadlineMs?: number +} +const guard = `import { appendFileSync } from "node:fs" +const fetch_ = globalThis.fetch +globalThis.fetch = Object.assign(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + const allowed = url.origin === process.env.SELECTOR_ATTACH_ORIGIN + appendFileSync(process.env.SELECTOR_ATTACH_NETWORK, JSON.stringify({origin:url.origin,path:url.pathname,allowed}) + "\\n") + if (!allowed) throw new Error("Nonlocal fixture fetch blocked: " + url.origin) + return fetch_(input, init) +}, {preconnect: () => {throw new Error("No preconnect in fixture")}}) +const spawn_ = Bun.spawn +Bun.spawn = function(...args) { + const cmd = Array.isArray(args[0]) ? args[0] : args[0].cmd + if (cmd?.includes("install")) { + appendFileSync(process.env.SELECTOR_ATTACH_INSTALL, JSON.stringify(cmd) + "\\n") + throw new Error("Fixture forbids dependency installation") + } + return spawn_.apply(Bun, args) +} +` +const program = `if (process.env.SELECTOR_ATTACH_MODE === "deadline-hang") await Bun.sleep(10000) +const root = process.env.SELECTOR_RUNTIME_ROOT +const [{Instance},{Config}] = await Promise.all([import(root + "/project/instance.ts"),import(root + "/config/config.ts")]) +const before = {content:process.env.AICTRL_CONFIG_CONTENT,dir:process.env.AICTRL_CONFIG_DIR,disable:process.env.AICTRL_DISABLE_PROJECT_CONFIG,token:process.env.AICTRL_FIXTURE_TOKEN} +try { + if (process.env.SELECTOR_ATTACH_CONTROL === "true") { + const [{Database},{ControlAccountTable}]=await Promise.all([import(root+"/storage/db.ts"),import(root+"/control/control.sql.ts")]) + Database.use(db=>db.insert(ControlAccountTable).values({email:"fixture@example.invalid",url:process.env.SELECTOR_ATTACH_ORIGIN,access_token:"inert-old",refresh_token:"inert-refresh",token_expiry:1,active:true}).run()) + } + const selection = process.env.SELECTOR_ATTACH_MODE === "normal" ? await Instance.provide({directory:process.cwd(),fn:async () => (await Config.get()).tool_allowlist}) : await Config.readToolSelection(process.cwd()) + const after = {content:process.env.AICTRL_CONFIG_CONTENT,dir:process.env.AICTRL_CONFIG_DIR,disable:process.env.AICTRL_DISABLE_PROJECT_CONFIG,token:process.env.AICTRL_FIXTURE_TOKEN} + console.log(JSON.stringify({selection:selection ?? null,before,after})) +} catch (error) { console.error(String(error)); process.exitCode = 1 } +await Instance.disposeAll() +` + +async function run(input: Input) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aictrl-selector-attach-")) + const requests: { path: string; method: string; body: unknown }[] = [] + let stream: ReadableStreamDefaultController | undefined + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + const url = new URL(request.url) + const body = request.method === "POST" ? await request.json().catch(() => null) : null + requests.push({ path: url.pathname, method: request.method, body }) + if (url.pathname === "/.well-known/aictrl") return Response.json({ config: input.remote ?? {} }) + if (url.pathname === "/oauth/token") + return Response.json({ access_token: "inert-new", refresh_token: "inert-refreshed", expires_in: 3600 }) + if (url.pathname === "/event") + return new Response( + new ReadableStream({ + start(controller) { + stream = controller + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + if (url.pathname === "/session") return Response.json({ id: "ses_attachfixture" }) + if (url.pathname.endsWith("/message") || url.pathname.endsWith("/command")) { + stream?.enqueue( + new TextEncoder().encode( + 'data: {"type":"session.status","properties":{"sessionID":"ses_attachfixture","status":{"type":"idle"}}}\n\n', + ), + ) + return Response.json({}) + } + return Response.json({ fixtureUnexpectedPath: url.pathname }, { status: 404 }) + }, + }) + const write = async (name: string, value: string | object) => { + const destination = path.join(directory, name) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(destination, typeof value === "string" ? value : JSON.stringify(value)) + } + for (const [name, value] of Object.entries(input.files ?? {})) await write(name, value) + if (input.authKey) + await write("data/aictrl/auth.json", { + [server.url.origin]: { + type: "wellknown", + key: input.authKey, + token: (input.authValue ?? "fixture-token").replaceAll("$FIXTURE", directory), + }, + }) + await write("guard.ts", guard) + await write("record.ts", program) + await write("network.jsonl", "") + await write("installs.jsonl", "") + await write("cache/aictrl/version", "21") + await write("cache/aictrl/preserve-sentinel", "unchanged-cache") + const before: Record = {} + for (const name of Object.keys(input.files ?? {})) before[name] = await Bun.file(path.join(directory, name)).text() + const env = { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter), + SHELL: "/bin/bash", + HOME: path.join(directory, "home"), + AICTRL_TEST_HOME: path.join(directory, "home"), + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(directory, "managed"), + XDG_DATA_HOME: path.join(directory, "data"), + XDG_CONFIG_HOME: path.join(directory, "config"), + XDG_STATE_HOME: path.join(directory, "state"), + XDG_CACHE_HOME: path.join(directory, "cache"), + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + SELECTOR_ATTACH_ORIGIN: server.url.origin, + SELECTOR_ATTACH_NETWORK: path.join(directory, "network.jsonl"), + SELECTOR_ATTACH_INSTALL: path.join(directory, "installs.jsonl"), + SELECTOR_RUNTIME_ROOT: root, + SELECTOR_ATTACH_MODE: input.mode ?? "cli", + SELECTOR_ATTACH_CONTROL: String(!!input.control), + ...(input.inline ? { AICTRL_CONFIG_CONTENT: JSON.stringify(input.inline) } : {}), + ...Object.fromEntries( + Object.entries(input.env ?? {}).map(([key, value]) => [key, value.replaceAll("$FIXTURE", directory)]), + ), + } + await fs.mkdir(env.HOME, { recursive: true }) + if (input.legacy) { + const gitEnv = { PATH: env.PATH, HOME: env.HOME, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" } + for (const args of [ + ["init"], + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ]) { + const git = Bun.spawnSync(["git", ...args], { + cwd: directory, + env: gitEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + expect(git.exitCode, git.stderr.toString()).toBe(0) + } + await write( + "seed.ts", + `const root=process.env.SELECTOR_RUNTIME_ROOT +const [{Database},{ProjectTable},{SessionTable}]=await Promise.all([import(root+"/storage/db.ts"),import(root+"/project/project.sql.ts"),import(root+"/session/session.sql.ts")]) +Database.use(db=>db.insert(ProjectTable).values({id:"global",worktree:"/",sandboxes:[],time_created:100,time_updated:100}).run()) +Database.use(db=>db.insert(SessionTable).values({id:"ses_legacyfixture",project_id:"global",slug:"legacy",directory:process.cwd(),title:"Keep legacy",version:"fixture",time_created:100,time_updated:100}).run()) +Database.close() +`, + ) + const seed = Bun.spawn( + [ + process.execPath, + "run", + "--preload", + path.join(directory, "guard.ts"), + "--conditions=browser", + path.join(directory, "seed.ts"), + ], + { cwd: directory, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ) + const stderr = await new Response(seed.stderr).text() + expect(await seed.exited, stderr).toBe(0) + } + const database = () => { + const file = path.join(directory, "data/aictrl/aictrl.db") + if (!existsSync(file)) return null + const db = new SQLite(file, { readonly: true }) + try { + return { + projects: db.query("SELECT * FROM project ORDER BY id").all(), + sessions: db.query("SELECT * FROM session ORDER BY id").all(), + } + } finally { + db.close() + } + } + const databaseBefore = database() + const command = [ + process.execPath, + "run", + "--preload", + path.join(directory, "guard.ts"), + "--conditions=browser", + ...(input.mode + ? [path.join(directory, "record.ts")] + : [ + path.join(root, "index.ts"), + "run", + "--attach", + server.url.origin, + ...(input.args ?? []), + "Fixture attach prompt.", + ]), + ] + const child = Bun.spawn(command, { cwd: directory, env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }) + const deadlineMs = input.deadlineMs ?? 10000 + let deadlineFired = false + const timeout = setTimeout(() => { + deadlineFired = true + child.kill("SIGKILL") + }, deadlineMs) + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + clearTimeout(timeout) + const after: Record = {} + for (const name of Object.keys(before)) + after[name] = await Bun.file(path.join(directory, name)) + .text() + .catch(() => null) + const network = (await Bun.file(env.SELECTOR_ATTACH_NETWORK).text()) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + const installs = (await Bun.file(env.SELECTOR_ATTACH_INSTALL).text()).split("\n").filter(Boolean) + const markers: Record = {} + for (const name of [ + "plugin-marker", + ".aictrl/.gitignore", + ".aictrl/node_modules", + "config/aictrl/config.json", + ".git/aictrl", + ]) + markers[name] = existsSync(path.join(directory, name)) + const cache = await Bun.file(path.join(directory, "cache/aictrl/preserve-sentinel")) + .text() + .catch(() => null) + const databaseAfter = database() + const result = { + deadlineFired, + deadlineMs, + input, + exit, + stdout, + stderr, + requests, + network, + installs, + before, + after, + markers, + cache, + databaseBefore, + databaseAfter, + } + if (process.env.SELECTOR_CAPTURE_DIR) + await Bun.write( + path.join(process.env.SELECTOR_CAPTURE_DIR, input.label + ".json"), + JSON.stringify(result, null, 2), + ) + if (deadlineFired) + throw new Error( + `${input.label}: child deadline exceeded after ${deadlineMs}ms; receiverRequests=${requests.length}; exit=${exit}; stdout=${stdout.slice(-2000)}; stderr=${stderr.slice(-2000)}`, + ) + expect(network.filter((request) => !request.allowed)).toEqual([]) + expect(installs).toEqual([]) + return result + } finally { + clearTimeout(timeout) + child.kill("SIGKILL") + server.stop(true) + await fs.rm(directory, { recursive: true, force: true }) + } +} +function selection(result: Awaited>) { + expect(result.exit, result.stderr + result.stdout).toBe(0) + return JSON.parse(result.stdout.trim().split("\n").at(-1)!) +} + +describe("read-only attach canonical config resolution (#246)", () => { + for (const value of ["read", ""]) + test(`explicit CLI selector ${JSON.stringify(value)} rejects before configuration discovery`, async () => { + const result = await run({ + label: value ? "attach-cli-read" : "attach-cli-empty", + args: ["--tools", value], + authKey: "AICTRL_FIXTURE_TOKEN", + files: { "aictrl.json": "{invalid-json" }, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.stderr + result.stdout).toContain("cannot verify server-side enforcement") + expect(result.requests).toEqual([]) + expect(result.network).toEqual([]) + expect(result.databaseAfter).toBe(null) + }) + for (const values of [["read"], []]) + test(`inline config selector ${JSON.stringify(values)} rejects before receiver work`, async () => { + const result = await run({ + label: values.length ? "attach-config-read" : "attach-config-empty", + inline: { tool_allowlist: values }, + args: ["--session", "ses_original", "--fork"], + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.stderr + result.stdout).toContain("cannot verify server-side enforcement") + expect(result.requests).toEqual([]) + expect(result.databaseAfter).toBe(null) + }) + test("valid omitted selector preserves receiver payload and never initializes local plugin or dependencies", async () => { + const result = await run({ + label: "attach-omitted-local-effects", + inline: { plugin: ["./marker.ts"] }, + files: { + "marker.ts": + 'import {writeFileSync} from "node:fs"; writeFileSync("plugin-marker","loaded"); export default async()=>{writeFileSync("plugin-marker","initialized");return {}}', + ".aictrl/package.json": { dependencies: { "@aictrl/plugin": "fixture-never-install" } }, + ".aictrl/command/broken.md": "---\nthis: [not valid yaml\n---\n", + "aictrl.json": { instructions: [] }, + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map((request) => [request.method, request.path])).toEqual([ + ["POST", "/session"], + ["GET", "/event"], + ["POST", "/session/ses_attachfixture/message"], + ]) + expect(result.requests[0].body).toEqual({ title: "Fixture attach prompt." }) + expect(result.requests[2].body).toEqual({ parts: [{ type: "text", text: '"Fixture attach prompt."\n' }] }) + expect(result.markers["plugin-marker"]).toBe(false) + expect(result.markers[".aictrl/.gitignore"]).toBe(false) + expect(result.markers[".aictrl/node_modules"]).toBe(false) + expect(result.after).toEqual(result.before) + expect(result.databaseAfter).toBe(null) + }) + test("invalid local config fails strictly without receiver calls", async () => { + const result = await run({ + label: "attach-invalid-config", + files: { "aictrl.json": { unknown_fixture_key: true } }, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.after).toEqual(result.before) + expect(result.databaseAfter).toBe(null) + }) + const sources: (Input & { expected: string[] })[] = [ + { label: "global", files: { "config/aictrl/aictrl.json": { tool_allowlist: ["global"] } }, expected: ["global"] }, + { + label: "custom-path", + files: { + "config/aictrl/aictrl.json": { tool_allowlist: ["global"] }, + "custom.json": { tool_allowlist: ["custom"] }, + }, + env: { AICTRL_CONFIG: "$FIXTURE/custom.json" }, + expected: ["custom"], + }, + { + label: "project", + files: { "custom.json": { tool_allowlist: ["custom"] }, "aictrl.json": { tool_allowlist: ["project"] } }, + env: { AICTRL_CONFIG: "$FIXTURE/custom.json" }, + expected: ["project"], + }, + { + label: "dot-project", + files: { + "aictrl.json": { tool_allowlist: ["project"] }, + ".aictrl/aictrl.json": { tool_allowlist: ["dot-project"] }, + }, + expected: ["dot-project"], + }, + { + label: "config-dir", + files: { + ".aictrl/aictrl.json": { tool_allowlist: ["dot-project"] }, + "extra/aictrl.json": { tool_allowlist: ["config-dir"] }, + }, + env: { AICTRL_CONFIG_DIR: "$FIXTURE/extra" }, + expected: ["config-dir"], + }, + { + label: "inline", + files: { "aictrl.json": { tool_allowlist: ["project"] } }, + inline: { tool_allowlist: ["inline"] }, + expected: ["inline"], + }, + { + label: "managed-empty", + files: { "managed/aictrl.json": { tool_allowlist: [] } }, + inline: { tool_allowlist: ["inline"] }, + expected: [], + }, + { + label: "project-disabled", + files: { + "aictrl.json": { tool_allowlist: ["project"] }, + ".aictrl/aictrl.json": { tool_allowlist: ["dot-project"] }, + "config/aictrl/aictrl.json": { tool_allowlist: ["global"] }, + }, + env: { AICTRL_DISABLE_PROJECT_CONFIG: "true" }, + expected: ["global"], + }, + ] + for (const item of sources) + test(`canonical source precedence: ${item.label} and unchanged config bytes`, async () => { + const result = await run({ ...item, label: "precedence-" + item.label, mode: "readonly" }) + const data = selection(result) + expect(data.selection).toEqual(item.expected) + expect(data.after).toEqual(data.before) + expect(result.after).toEqual(result.before) + expect(result.databaseAfter).toBe(null) + }) + test("legacy TOML is read without migration rewrite or unlink", async () => { + const result = await run({ + label: "readonly-toml", + mode: "readonly", + files: { "config/aictrl/config": "tool_allowlist = []\n" }, + }) + expect(selection(result).selection).toEqual([]) + expect(result.after).toEqual(result.before) + expect(result.markers["config/aictrl/config.json"]).toBe(false) + }) + test("well-known substitution uses local overlay without changing process environment", async () => { + const result = await run({ + label: "overlay-substitution", + mode: "readonly", + authKey: "AICTRL_FIXTURE_TOKEN", + authValue: "read", + remote: { tool_allowlist: ["{env:AICTRL_FIXTURE_TOKEN}"] }, + }) + const data = selection(result) + expect(data.selection).toEqual(["read"]) + expect(data.after).toEqual(data.before) + expect(result.requests.map((request) => request.path)).toEqual(["/.well-known/aictrl"]) + }) + for (const mode of ["normal", "readonly"] as const) + test(`dynamic well-known configuration directory: ${mode}`, async () => { + const result = await run({ + label: "overlay-dir-" + mode, + mode, + authKey: "AICTRL_CONFIG_DIR", + authValue: "$FIXTURE/extra", + files: { "extra/aictrl.json": { tool_allowlist: [] } }, + }) + const data = selection(result) + expect(data.selection).toEqual([]) + if (mode === "readonly") expect(data.after).toEqual(data.before) + }) + for (const mode of ["normal", "readonly"] as const) + test(`dynamic well-known project disable: ${mode}`, async () => { + const result = await run({ + label: "overlay-disable-" + mode, + mode, + authKey: "AICTRL_DISABLE_PROJECT_CONFIG", + authValue: "true", + files: { "aictrl.json": { tool_allowlist: [] } }, + }) + const data = selection(result) + expect(data.selection).toBe(null) + if (mode === "readonly") expect(data.after).toEqual(data.before) + }) + for (const mode of ["normal", "readonly"] as const) + test(`expired inert Control credentials refresh only during ordinary setup: ${mode}`, async () => { + const result = await run({ label: "control-refresh-" + mode, mode, control: true }) + expect(selection(result).selection).toBe(null) + expect(result.requests.map((request) => request.path)).toEqual(mode === "normal" ? ["/oauth/token"] : []) + }) + test("config selector rejection leaves local projects legacy sessions and git cache untouched", async () => { + const result = await run({ label: "readonly-local-state", inline: { tool_allowlist: [] }, legacy: true }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.databaseAfter).toEqual(result.databaseBefore) + expect(result.markers[".git/aictrl"]).toBe(false) + expect(result.cache).toBe("unchanged-cache") + }) + test("normal canonical resolution applies well-known inline environment override", async () => { + const result = await run({ + label: "overlay-inline-normal", + mode: "normal", + authKey: "AICTRL_CONFIG_CONTENT", + authValue: '{"tool_allowlist":[]}', + }) + expect(selection(result).selection).toEqual([]) + }) + test("read-only resolution applies well-known inline override without changing process environment", async () => { + const result = await run({ + label: "overlay-inline-readonly", + mode: "readonly", + authKey: "AICTRL_CONFIG_CONTENT", + authValue: '{"tool_allowlist":[]}', + }) + const data = selection(result) + expect(data.selection).toEqual([]) + expect(data.after).toEqual(data.before) + }) + test("config-derived well-known inline empty selection rejects attach before receiver", async () => { + const result = await run({ + label: "overlay-inline-attach", + authKey: "AICTRL_CONFIG_CONTENT", + authValue: '{"tool_allowlist":[]}', + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests.map((request) => request.path)).toEqual(["/.well-known/aictrl"]) + }) +}) + +test("forced attach fixture deadline cannot pass as an expected negative exit", async () => { + await expect(run({ label: "forced-attach-deadline", mode: "deadline-hang", deadlineMs: 20 })).rejects.toThrow( + /child deadline exceeded after 20ms; receiverRequests=0/, + ) +}, 20000) diff --git a/packages/cli/test/cli/run-tool-selection.test.ts b/packages/cli/test/cli/run-tool-selection.test.ts new file mode 100644 index 00000000..628cb4e8 --- /dev/null +++ b/packages/cli/test/cli/run-tool-selection.test.ts @@ -0,0 +1,1659 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs/promises" +import { tmpdir } from "../fixture/fixture" + +const entry = path.resolve(import.meta.dir, "../../src/index.ts") +const full = ["bash", "edit", "glob", "grep", "read", "skill", "task", "webfetch", "write"] +type Request = { + model?: string + input?: unknown + instructions?: string + tools?: { function: { name: string; description?: string } }[] + messages?: { role: string; content: unknown }[] +} +type Call = { name: string; arguments: Record } +type Input = { + label: string + args?: string[] + config?: Record + entry?: string + noReply?: boolean + model?: string + runtime?: string + codex?: boolean + proxy?: boolean + noMessage?: boolean + deadlineMs?: number + calls?: (Call | null | ((requests: Request[]) => Call))[] + setup?: (directory: string) => Promise | void> + markers?: string[] + files?: string[] +} + +// Static fixture code only. Model/config payload remains data, never generated code. +const guard = [ + 'import { appendFileSync } from "node:fs"', + "const original = globalThis.fetch", + "const spawn = Bun.spawn.bind(Bun)", + "const checked = (...args) => {", + " const command = Array.isArray(args[0]) ? args[0] : args[0]?.cmd", + " if (command && /(?:^|\\/)bun$/.test(String(command[0])) && ['add','install'].includes(command[1])) throw new Error('Fixture forbids dependency installation')", + " return spawn(...args)", + "}", + "Bun.spawn = checked", + "globalThis.fetch = Object.assign(async (input, init) => {", + " const url = new URL(input instanceof Request ? input.url : String(input))", + " if (process.env.SELECTOR_FIXTURE_CODEX === 'true' && url.origin === 'https://chatgpt.com' && url.pathname === '/backend-api/codex/responses') {", + ' appendFileSync(process.env.SELECTOR_FIXTURE_GUARD_LOG,JSON.stringify({origin:url.origin,path:url.pathname,allowed:true,intercepted:true})+"\\n")', + ' appendFileSync(process.env.SELECTOR_FIXTURE_CODEX_LOG,JSON.stringify(JSON.parse(String(init?.body)))+"\\n")', + " const events=[{type:'response.created',response:{id:'resp-fixture',created_at:1,model:'gpt-5.2',service_tier:null}},{type:'response.output_item.added',output_index:0,item:{type:'message',id:'item-fixture'}},{type:'response.output_text.delta',item_id:'item-fixture',delta:'done',logprobs:null},{type:'response.output_item.done',output_index:0,item:{type:'message',id:'item-fixture'}},{type:'response.completed',response:{incomplete_details:null,usage:{input_tokens:1,input_tokens_details:null,output_tokens:1,output_tokens_details:null},service_tier:null}}]", + " return new Response(events.map(event=>'data: '+JSON.stringify(event)).concat('data: [DONE]').join('\\n\\n')+'\\n\\n',{headers:{'content-type':'text/event-stream'}})", + " }", + " const allowed = url.origin === process.env.SELECTOR_FIXTURE_ORIGIN", + ' appendFileSync(process.env.SELECTOR_FIXTURE_GUARD_LOG, JSON.stringify({ origin: url.origin, path: url.pathname, allowed }) + "\\n")', + ' if (!allowed) throw new Error("Selector fixture blocked nonlocal fetch: " + url.origin)', + " return original(input, init)", + '}, { preconnect: () => { throw new Error("Selector fixture forbids preconnect") } })', +].join("\n") + +async function run(input: Input) { + await using tmp = await tmpdir() + const requests: Request[] = [] + const calls = [...(input.calls ?? [])] + let siblingCount = 0 + let releaseConcurrent: (() => void) | undefined + const concurrentBarrier = new Promise((resolve) => { + releaseConcurrent = resolve + }) + const extra = await input.setup?.(tmp.path) + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + requests.push((await request.json()) as Request) + if (input.runtime === "concurrent-sessions") { + if (requests.length >= 2) releaseConcurrent?.() + await concurrentBarrier + } + if ( + (input.runtime === "cancel-model" && requests.length === 1) || + (input.runtime === "overlap-child-resume" && + requests.at(-1)?.messages?.at(-1)?.role === "user" && + String(requests.at(-1)?.messages?.at(-1)?.content).includes("Held resumed child")) + ) { + await Bun.write(path.join(tmp.path, "phase-ready.json"), "true") + const deadline = Date.now() + 5000 + while (!(await Bun.file(path.join(tmp.path, "phase-release.json")).exists())) { + if (Date.now() > deadline) throw new Error("Model cancellation fixture barrier timeout") + await Bun.sleep(5) + } + } + if (input.runtime === "sibling-children") { + const last = requests.at(-1)?.messages?.at(-1) + if (last?.role === "user" && String(last.content).includes("Child sibling")) { + siblingCount += 1 + if (siblingCount >= 2) releaseConcurrent?.() + await concurrentBarrier + } + } + const scripted = calls.shift() + const call = typeof scripted === "function" ? scripted(requests) : scripted + const chunks = [ + { + id: "selector-fixture", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + role: "assistant", + ...(call + ? { + tool_calls: [ + { + index: 0, + id: `call_${requests.length}`, + type: "function", + function: { name: call.name, arguments: JSON.stringify(call.arguments) }, + }, + ], + } + : { content: "Fixture complete" }), + }, + }, + ], + }, + { + id: "selector-fixture", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: call ? "tool_calls" : "stop" }], + }, + ] + return new Response( + chunks + .map((chunk) => `data: ${JSON.stringify(chunk)}`) + .concat("data: [DONE]") + .join("\n\n") + "\n\n", + { headers: { "content-type": "text/event-stream" } }, + ) + }, + }) + const config = { + enabled_providers: [input.codex ? "openai" : "fixture"], + provider: { + [input.codex ? "openai" : "fixture"]: { + npm: input.codex ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible", + options: { + apiKey: "fixture", + baseURL: `${server.url.origin}/v1`, + ...(input.proxy ? { litellmProxy: true } : {}), + }, + ...(input.codex + ? {} + : { + models: Object.fromEntries( + ["fixture", "gpt-5"].map((id) => [ + id, + { name: id, tool_call: true, limit: { context: 200000, output: 1000 } }, + ]), + ), + }), + }, + }, + agent: { title: { disable: true } }, + permission: { "*": "allow" }, + ...input.config, + ...(extra ?? {}), + } + const home = path.join(tmp.path, "home") + const preload = path.join(tmp.path, "network-guard.ts") + const ledger = path.join(tmp.path, "network.jsonl") + await fs.mkdir(home) + await Bun.write(preload, guard) + await Bun.write(ledger, "") + const codexLog = path.join(tmp.path, "codex-captures.jsonl") + await Bun.write(codexLog, "") + if (input.codex) + await Bun.write( + path.join(tmp.path, "data/aictrl/auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 60000 }, + }), + ) + const selected = input.entry ?? entry + const program = path.join(tmp.path, "record-only.ts") + if (input.noReply) + await Bun.write( + program, + [ + "const root = process.env.SELECTOR_RUNTIME_ROOT", + 'const [{ Instance }, { Session }, { SessionPrompt }] = await Promise.all([import(root + "/project/instance.ts"), import(root + "/session/index.ts"), import(root + "/session/prompt.ts")])', + "try { await Instance.provide({ directory: process.cwd(), fn: async () => {", + ' const session = await Session.create({ title: "Record unavailable model" })', + ' const message = await SessionPrompt.prompt({ sessionID: session.id, noReply: true, model: { providerID: "fixture", modelID: "unavailable-model" }, parts: [{ type: "text", text: "Record only; do not run a model." }] })', + " console.log(JSON.stringify({ role: message.info.role, model: message.info.model, saved: (await Session.messages({ sessionID: session.id })).length }))", + "} }) } catch (error) { console.error(String(error)); process.exitCode = 1 }", + "await Instance.disposeAll()", + ].join("\n"), + ) + const command = [ + process.execPath, + "run", + "--preload", + preload, + "--conditions=browser", + ...(input.runtime + ? [path.resolve(import.meta.dir, "../fixture/tool-selection-runtime.ts")] + : input.noReply + ? [program] + : [ + selected, + "run", + "--format", + "json", + "--model", + input.codex ? "openai/gpt-5.2" : "fixture/" + (input.model ?? "fixture"), + ...(input.args ?? []), + ...(input.noMessage ? [] : ["Say fixture complete without tools."]), + ]), + ] + const process_ = Bun.spawn(command, { + cwd: tmp.path, + env: { + PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter), + SHELL: "/bin/bash", + HOME: home, + AICTRL_TEST_HOME: home, + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(tmp.path, "managed"), + XDG_DATA_HOME: path.join(tmp.path, "data"), + XDG_CONFIG_HOME: path.join(tmp.path, "config"), + XDG_STATE_HOME: path.join(tmp.path, "state"), + XDG_CACHE_HOME: path.join(tmp.path, "cache"), + AICTRL_CONFIG_CONTENT: JSON.stringify(config), + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS: "5000", + SELECTOR_FIXTURE_ORIGIN: server.url.origin, + SELECTOR_FIXTURE_GUARD_LOG: ledger, + SELECTOR_FIXTURE_ZOD: import.meta.resolve("zod"), + SELECTOR_RUNTIME_ROOT: path.dirname(selected), + SELECTOR_RUNTIME_MODE: input.runtime ?? "", + SELECTOR_RUNTIME_PRELOAD: preload, + SELECTOR_FIXTURE_CODEX: String(!!input.codex), + SELECTOR_FIXTURE_CODEX_LOG: codexLog, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const deadlineMs = input.deadlineMs ?? 15000 + let deadlineFired = false + const timeout = setTimeout(() => { + deadlineFired = true + process_.kill("SIGKILL") + }, deadlineMs) + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(process_.stdout).text(), + new Response(process_.stderr).text(), + process_.exited, + ]) + clearTimeout(timeout) + const network = (await Bun.file(ledger).text()) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as { origin: string; path: string; allowed: boolean }) + if (input.codex) + requests.push( + ...(await Bun.file(codexLog).text()) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Request), + ) + const markers: Record = {} + for (const name of input.markers ?? []) { + const file = Bun.file(path.join(tmp.path, name)) + markers[name] = (await file.exists()) ? await file.json() : null + } + const files: Record = {} + for (const name of input.files ?? []) { + const file = Bun.file(path.join(tmp.path, name)) + files[name] = (await file.exists()) ? await file.text() : null + } + const result = { + deadlineFired, + deadlineMs, + files, + label: input.label, + args: input.args ?? [], + config, + command, + exit, + stdout, + stderr, + requests, + network, + markers, + } + if (process.env.SELECTOR_CAPTURE_DIR) + await Bun.write( + path.join(process.env.SELECTOR_CAPTURE_DIR, input.label + ".json"), + JSON.stringify(result, null, 2), + ) + if (deadlineFired) + throw new Error( + `${input.label}: child deadline exceeded after ${deadlineMs}ms; providerRequests=${requests.length}; exit=${exit}; stdout=${stdout.slice(-2000)}; stderr=${stderr.slice(-2000)}`, + ) + expect(network.filter((request) => !request.allowed)).toEqual([]) + return result + } finally { + clearTimeout(timeout) + process_.kill("SIGKILL") + server.stop(true) + } +} + +function names(request: Request) { + return (request.tools ?? []).map((tool) => tool.function.name).sort() +} + +const cases = [ + { label: "no-selector", expected: full }, + { label: "explicit-read", args: ["--tools", "read"], expected: ["read"] }, + { label: "config-read", config: { tool_allowlist: ["read"] }, expected: ["read"] }, + { label: "explicit-empty", args: ["--tools", ""], expected: [] }, + { label: "config-empty", config: { tool_allowlist: [] }, expected: [] }, + { + label: "cli-replaces-config", + args: ["--tools", "read"], + config: { tool_allowlist: ["bash", "write"] }, + expected: ["read"], + }, +] + +describe("per-run work-tool selection through actual CLI (#246)", () => { + for (const item of cases) { + test( + item.label, + async () => { + const result = await run(item) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(1) + expect(names(result.requests[0])).toEqual(item.expected) + expect(JSON.stringify(result.requests[0].messages)).toContain("Say fixture complete without tools.") + }, + 20000, + ) + } +}) + +const baseline = process.env.SELECTOR_BASELINE_ENTRY ?? entry +const collision = [ + 'import { writeFile } from "node:fs/promises"', + 'export default { description: "Ordinary custom work tool", args: {}, execute: async () => { await writeFile(new URL("../../work-marker.json", import.meta.url), JSON.stringify({ executed: true })); return "Custom work executed" } }', +].join("\n") +const custom = [ + 'import { writeFile } from "node:fs/promises"', + "const { default: z } = await import(process.env.SELECTOR_FIXTURE_ZOD)", + 'export default { description: "Raw custom schema fixture", args: { text: z.string().trim(), tag: z.string().default("default-tag") }, execute: async (args) => { await writeFile(new URL("../../custom-marker.json", import.meta.url), JSON.stringify(args)); return "Custom executed" } }', +].join("\n") +const hooks = [ + 'import { writeFile } from "node:fs/promises"', + 'export default async () => ({ "tool.execute.before": async (input, output) => { if (input.tool === "strict") await writeFile(new URL("./hook-marker.json", import.meta.url), JSON.stringify(output.args)) } })', +].join("\n") + +const publicDelegate = `import { tool, type ToolContext } from "@aictrl/plugin/tool" +import { writeFile } from "node:fs/promises" +export default tool({ + description: "Dispatch through the public checked selection facade", + args: { name: tool.schema.string(), payload: tool.schema.unknown().optional(), spoof: tool.schema.boolean().optional(), waitRunning:tool.schema.boolean().optional() }, + async execute(args, context: ToolContext) { + const selection = context.selection + if (!selection) throw new Error("Expected request-local selection facade") + await writeFile(new URL("../../caller-marker.json",import.meta.url),JSON.stringify({sessionID:context.sessionID,messageID:context.messageID,agent:context.agent,directory:context.directory,worktree:context.worktree,abortPresent:context.abort instanceof AbortSignal,abortInitially:context.abort.aborted})) + await writeFile(new URL("../../facade-marker.json",import.meta.url),JSON.stringify({names:[...selection.names].sort(),restricted:selection.restricted,frozen:Object.isFrozen(selection),namesFrozen:Object.isFrozen(selection.names),executeArity:selection.execute.length,assertArity:selection.assert.length})) + if(args.waitRunning) { + const deadline=Date.now()+2000 + while(!await Bun.file(new URL("../../delegate-running.json",import.meta.url)).exists()){if(Date.now()>deadline)throw new Error("Actual delegate never entered running state");await Bun.sleep(5)} + } + await context.metadata({title:"Public delegate running",metadata:{facade:true}}) + selection.assert(args.name) + const result = args.spoof ? await Reflect.apply(selection.execute,undefined,[args.name,args.payload ?? {},{sessionID:"spoofed",messageID:"spoofed",abort:new AbortController().signal}]) : await selection.execute(args.name,args.payload ?? {}) + await writeFile(new URL("../../delegate-marker.json",import.meta.url),JSON.stringify({title:result.title,output:result.output,metadata:result.metadata})) + return result.output + } +})` +const publicLeaf = `import { tool, type ToolContext } from "@aictrl/plugin/tool" +import { writeFile } from "node:fs/promises" +export default tool({ + description: "Public plugin work with parsed arguments", + args: {text:tool.schema.string().trim(),tag:tool.schema.string().default("public-default")}, + async execute(args, context: ToolContext) { + await context.ask({permission:"public_leaf",patterns:["fixture"],always:["fixture"],metadata:{}}) + await writeFile(new URL("../../leaf-marker.json",import.meta.url),JSON.stringify(args)) + return "Public leaf complete" + } +})` +const publicAbortLeaf = `import { tool, type ToolContext } from "@aictrl/plugin/tool" +import { writeFile } from "node:fs/promises" +export default tool({description:"Wait until the bound caller signal aborts",args:{},async execute(args,context:ToolContext){ + await context.metadata({title:"Public leaf waiting",metadata:{abortLeaf:true}}) + await writeFile(new URL("../../abort-ready.json",import.meta.url),JSON.stringify({sessionID:context.sessionID,messageID:context.messageID,agent:context.agent,directory:context.directory,worktree:context.worktree,abortPresent:context.abort instanceof AbortSignal,abortInitially:context.abort.aborted})) + return await new Promise(resolve=>{ + const stop=()=>{void(async()=>{await writeFile(new URL("../../abort-observed.json",import.meta.url),JSON.stringify({aborted:context.abort.aborted}));resolve("Bound caller aborted")})()} + if(context.abort.aborted)stop();else context.abort.addEventListener("abort",stop,{once:true}) + }) +}})` +const metadataProbe = `import {tool,type ToolContext} from "@aictrl/plugin/tool" +export default tool({description:"Probe actual metadata registration timing",args:{},async execute(args,context:ToolContext){ + await context.metadata({title:"Immediate metadata probe",metadata:{immediateProbe:true}}) + const deadline=Date.now()+2000 + while(!await Bun.file(new URL("../../probe-running.json",import.meta.url)).exists()){if(Date.now()>deadline)throw new Error("Probe never entered real running state");await Bun.sleep(5)} + await context.metadata({title:"Ready metadata probe",metadata:{readyProbe:true}}) + return "Metadata probe complete" +}})` +async function setupPublicFacade(directory: string) { + const modules = path.join(directory, "node_modules/@aictrl") + await fs.mkdir(modules, { recursive: true }) + await fs.symlink(path.resolve(import.meta.dir, "../../../plugin"), path.join(modules, "plugin"), "dir") + await Bun.write(path.join(directory, ".aictrl/tool/delegate.ts"), publicDelegate) + await Bun.write(path.join(directory, ".aictrl/tool/leaf.ts"), publicLeaf) +} + +const mcpFixture = `import {createInterface} from "node:readline" +import {writeFile} from "node:fs/promises" +for await (const line of createInterface({input:process.stdin})) { + const request=JSON.parse(line) + if (request.id === undefined) continue + let result={} + if (request.method === "initialize") result={protocolVersion:request.params.protocolVersion,capabilities:{tools:{}},serverInfo:{name:"selector-fixture",version:"1"}} + if (request.method === "tools/list") result={tools:[{name:"echo",description:"Original MCP Task Grep Read body preserved",inputSchema:{type:"object",properties:{text:{type:"string"}},required:["text"],additionalProperties:false}}]} + if (request.method === "tools/call") { + await writeFile(new URL("./mcp-marker.json",import.meta.url),JSON.stringify(request.params)) + result={content:[{type:"text",text:"MCP fixture result: "+request.params.arguments.text}]} + } + process.stdout.write(JSON.stringify({jsonrpc:"2.0",id:request.id,result})+"\\n") +}` +async function setupMCP(directory: string) { + const file = path.join(directory, "mcp.ts") + await Bun.write(file, mcpFixture) + return { mcp: { fixture: { type: "local", command: [process.execPath, file], enabled: true } } } +} +const groupHooks = `import {writeFile} from "node:fs/promises" +export default async()=>({"tool.execute.before":async(input)=>{await writeFile(new URL("./group-hook-"+input.tool+".json",import.meta.url),JSON.stringify({tool:input.tool}))}})` + +const childTask: Call = { + name: "task", + arguments: { description: "Fixture child", prompt: "Child fixture harmless response", subagent_type: "explore" }, +} +describe("immutable snapshots through actual session lifecycles (#246)", () => { + for (const configured of [false, true]) + test(`loop freezes saved selector and fresh omission ${configured ? "resolves config" : "restores unrestricted default"}`, async () => { + const result = await run({ + label: configured ? "snapshot-reset-config" : "snapshot-reset-default", + runtime: "snapshot-reset", + config: configured ? { tool_allowlist: ["bash"] } : {}, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([ + ["read"], + configured ? ["bash"] : full, + [], + configured ? ["bash"] : full, + ]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [ + { toolSelection: { version: 1, allowlist: ["read"] } }, + { toolSelection: { version: 1, allowlist: configured ? ["bash"] : null } }, + { toolSelection: { version: 1, allowlist: [] } }, + { toolSelection: { version: 1, allowlist: configured ? ["bash"] : null } }, + ], + }) + }, 20000) + test("historical wire User without metadata resumes unrestricted and new prompt resolves config", async () => { + const result = await run({ + label: "snapshot-historical-missing", + runtime: "historical-missing", + config: { tool_allowlist: ["read"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([full, ["read"]]) + const report = result.markers["runtime-report.json"] as { users: unknown[] } + expect(report.users[0]).not.toHaveProperty("toolSelection") + expect(report.users[1]).toMatchObject({ toolSelection: { version: 1, allowlist: ["read"] } }) + }, 20000) + test("actual fork copies frozen snapshot but fresh fork prompt resolves current config", async () => { + const result = await run({ + label: "snapshot-fork", + runtime: "fork-snapshot", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"], ["bash"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + original: [{ toolSelection: { version: 1, allowlist: ["read"] } }], + forked: [ + { toolSelection: { version: 1, allowlist: ["read"] } }, + { toolSelection: { version: 1, allowlist: ["bash"] } }, + ], + }) + }, 20000) + test("actual CLI export and import preserve selector before resumed inference", async () => { + const result = await run({ + label: "snapshot-export-import", + runtime: "export-import", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + exported: [{ toolSelection: { version: 1, allowlist: ["read"] } }], + users: [{ toolSelection: { version: 1, allowlist: ["read"] } }], + imported: expect.stringContaining("Imported session:"), + }) + }, 20000) + test("real compaction summary and automatic continuation both copy the saved snapshot", async () => { + const result = await run({ + label: "snapshot-compaction", + runtime: "compaction-snapshot", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([[], ["read"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [ + { toolSelection: { version: 1, allowlist: ["read"] } }, + { toolSelection: { version: 1, allowlist: ["read"] } }, + { toolSelection: { version: 1, allowlist: ["read"] } }, + ], + parts: [{ type: "compaction", auto: true }], + }) + }, 20000) + test("real command Task summary continuation copies its invocation snapshot", async () => { + const result = await run({ + label: "snapshot-command", + runtime: "command-snapshot", + config: { command: { inspect: { template: "Inspect harmless fixture.", agent: "explore", subtask: true } } }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"], ["read", "task"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [ + { toolSelection: { version: 1, allowlist: ["task", "read"] } }, + { toolSelection: { version: 1, allowlist: ["task", "read"] } }, + ], + parts: expect.arrayContaining([ + expect.objectContaining({ + text: "Summarize the task tool output above and continue with your task.", + synthetic: true, + }), + ]), + }) + }, 20000) + test("concurrent sessions retain separate selections while both provider requests are held", async () => { + const result = await run({ + label: "snapshot-concurrent", + runtime: "concurrent-sessions", + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(2) + for (const request of result.requests) + expect(names(request)).toEqual( + JSON.stringify(request.messages).includes("Concurrent read invocation") ? ["read"] : ["bash"], + ) + expect(result.markers["runtime-report.json"]).toMatchObject({ + first: { users: [{ toolSelection: { version: 1, allowlist: ["read"] } }] }, + second: { users: [{ toolSelection: { version: 1, allowlist: ["bash"] } }] }, + }) + }, 20000) +}) +describe("actual child ceilings and model catalogs (#246)", () => { + for (const item of [ + { label: "child-default-cross-model", selected: undefined, expected: full.filter((name) => name !== "task") }, + { label: "child-active-read", selected: ["task", "read"], expected: ["read"] }, + { label: "child-active-unavailable-peer", selected: ["task", "apply_patch"], expected: [] }, + { label: "child-own-config", selected: ["task", "read"], expected: [] }, + ]) + test( + item.label, + async () => { + const result = await run({ + label: item.label, + model: "gpt-5", + args: item.selected ? ["--tools", item.selected.join(",")] : [], + config: { + agent: { title: { disable: true }, explore: { model: "fixture/fixture" } }, + ...(item.label === "child-own-config" ? { tool_allowlist: ["bash"] } : {}), + }, + calls: [childTask], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map((request) => request.model)).toEqual(["gpt-5", "fixture", "gpt-5"]) + expect(names(result.requests[1])).toEqual(item.expected) + if (!item.selected) { + expect(names(result.requests[0])).toContain("apply_patch") + expect(names(result.requests[1])).toContain("edit") + expect(names(result.requests[1])).toContain("write") + } + }, + 20000, + ) + test("resumed real Task child intersects the new parent effective ceiling and saves each snapshot", async () => { + const result = await run({ + label: "child-resume-tighten", + runtime: "resume-child", + markers: ["runtime-report.json"], + calls: [ + childTask, + null, + null, + (requests) => { + const id = JSON.stringify(requests).match(/task_id: (ses_[a-zA-Z0-9]+)/)?.[1] + if (!id) throw new Error("Missing actual Task result task_id") + return { ...childTask, arguments: { ...childTask.arguments, task_id: id } } + }, + null, + null, + ], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(6) + expect(names(result.requests[1])).toEqual(["bash", "read"]) + expect(names(result.requests[4])).toEqual(["read"]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [ + { toolSelection: { version: 1, allowlist: ["task", "read", "bash"] } }, + { toolSelection: { version: 1, allowlist: ["task", "read"] } }, + ], + children: [ + { + users: [ + { toolSelection: { version: 1, allowlist: ["bash", "read", "task"] } }, + { toolSelection: { version: 1, allowlist: ["read", "task"] } }, + ], + }, + ], + }) + }, 20000) + test("resumed real Task child also intersects its own config on each invocation", async () => { + const result = await run({ + label: "child-resume-own-config", + runtime: "resume-child-config", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + calls: [ + childTask, + null, + null, + (requests) => { + const task_id = JSON.stringify(requests).match(/task_id: (ses_[a-zA-Z0-9]+)/)?.[1] + if (!task_id) throw new Error("Missing real child task_id") + return { ...childTask, arguments: { ...childTask.arguments, task_id } } + }, + null, + null, + ], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(6) + expect(names(result.requests[1])).toEqual(["bash"]) + expect(names(result.requests[4])).toEqual([]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + children: [ + { + users: [ + { toolSelection: { version: 1, allowlist: ["bash"] } }, + { toolSelection: { version: 1, allowlist: [] } }, + ], + }, + ], + }) + }, 20000) + test("private child submission rejects explicit widening before saving a User or invoking a model", async () => { + const result = await run({ + label: "child-explicit-widen", + runtime: "child-widen", + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toEqual([]) + expect(result.markers["runtime-report.json"]).toEqual({ + failure: expect.stringContaining("cannot widen"), + users: [], + }) + }, 20000) + for (const customTask of [false, true]) + test(`explicit command selected ${customTask ? "custom Task identity" : "omitted Task"} is rejected before inference`, async () => { + const result = await run({ + label: customTask ? "direct-task-custom-identity" : "direct-task-omitted", + args: ["--command", "inspect", "--tools", customTask ? "task" : "read"], + config: { command: { inspect: { template: "Inspect fixture", agent: "explore", subtask: true } } }, + markers: ["work-marker.json"], + setup: customTask + ? async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/task.ts"), collision) + } + : undefined, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.markers["work-marker.json"]).toBeNull() + }, 20000) + for (const model of ["fixture", "gpt-5"]) + test(`native read-only schema and guidance use final ${model} catalog`, async () => { + const result = await run({ label: "model-read-guidance-" + model, model, args: ["--tools", "read"] }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["read"]) + expect(result.requests[0].tools?.[0].function.description).not.toMatch(/\b(?:Task|Grep|Glob|Bash)\b/) + expect(JSON.stringify(result.requests[0].messages)).not.toContain("with bash") + }, 20000) + test("model-eligible native apply_patch remains selectable on actual GPT registry", async () => { + const result = await run({ label: "model-gpt-apply-patch", model: "gpt-5", args: ["--tools", "apply_patch"] }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["apply_patch"]) + }, 20000) +}) + +describe("native protocol controls and Codex selector snapshots (#246)", () => { + test("Codex actual serialized full reduced full uses saved selections without mutating instructions", async () => { + const result = await run({ + label: "selector-codex-reset", + codex: true, + runtime: "codex-reset", + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(3) + const definitions = (request: Request) => request.tools as unknown as { name: string; description: string }[] + const list = (request: Request) => + definitions(request) + .map((tool) => tool.name) + .sort() + const expected = ["apply_patch", ...full.filter((name) => name !== "edit" && name !== "write")].sort() + expect(result.requests.map(list)).toEqual([expected, ["read"], expected]) + expect(result.requests[0].instructions).toContain("Try to use apply_patch") + expect(result.requests[1].instructions).toContain("Use Read") + for (const advice of ["Try to use apply_patch", "Use Glob", "Grep to search", "Use Bash for terminal"]) + expect(result.requests[1].instructions).not.toContain(advice) + expect(result.requests[1].instructions).toContain("NEVER revert existing changes") + expect(result.requests[0].instructions).toBe(result.requests[2].instructions) + expect(result.requests[0].tools).toEqual(result.requests[2].tools) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [ + { toolSelection: { version: 1, allowlist: null } }, + { toolSelection: { version: 1, allowlist: ["read"] } }, + { toolSelection: { version: 1, allowlist: null } }, + ], + }) + }, 20000) + test("Codex CLI explicit empty work selector declares no work tools", async () => { + const result = await run({ label: "selector-codex-empty", codex: true, args: ["--tools", ""] }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(1) + expect(result.requests[0].tools ?? []).toEqual([]) + }, 20000) + for (const name of ["invalid", "_noop"]) + test(`native ${name} cannot be explicitly selected as work`, async () => { + const result = await run({ label: "protocol-select-" + name, args: ["--tools", name] }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + }, 20000) + for (const selected of [false, true]) + test(`schema protocol ${selected ? "cannot be selected as work" : "retains permission denial under empty work selector"}`, async () => { + const result = await run({ + label: selected ? "protocol-select-structured" : "protocol-structured-denied", + args: ["--tools", selected ? "StructuredOutput" : "", "--output-schema", "schema.json"], + config: { permission: { "*": "deny", ...(selected ? { StructuredOutput: "allow" } : {}) } }, + setup: async (directory) => { + await Bun.write( + path.join(directory, "schema.json"), + JSON.stringify({ type: "object", properties: { result: { type: "string" } }, required: ["result"] }), + ) + }, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.stderr + result.stdout).toContain("StructuredOutput") + }, 20000) + test("native LiteLLM noop survives empty work selection with real tool-call history", async () => { + const result = await run({ + label: "protocol-noop-history", + runtime: "noop-history", + proxy: true, + config: { permission: { "*": "allow", invalid: "deny" } }, + calls: [{ name: "read", arguments: { filePath: "read-fixture.txt" } }, null, null], + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"], ["read"], ["_noop"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [{ toolSelection: { version: 1, allowlist: ["read"] } }, { toolSelection: { version: 1, allowlist: [] } }], + }) + }, 20000) +}) + +describe("exact selection and real dispatch boundaries (#246)", () => { + test("repeated tools flags fail with actionable single-argument guidance before inference", async () => { + const result = await run({ label: "exact-repeated-flags", args: ["--tools", "read", "--tools", "bash"] }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.stderr + result.stdout).toContain("--tools") + expect(result.stderr + result.stdout).toMatch(/once|single|comma|repeat/i) + expect(result.stderr + result.stdout).not.toContain("split is not a function") + }, 20000) + test("missing tools argument is an explicit invocation error before inference", async () => { + const result = await run({ label: "exact-missing-argument", args: ["--tools"], noMessage: true }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.stderr + result.stdout).toContain("tools") + expect(result.stderr + result.stdout).toMatch(/argument|following|value/i) + }, 20000) + for (const [label, value] of [ + ["leading-space", " read"], + ["trailing-space", "read "], + ["empty-middle", "read,,bash"], + ["empty-prefix", ",read"], + ["empty-suffix", "read,"], + ["case-sensitive", "Read"], + ["unknown", "unregistered_fixture"], + ["model-ineligible", "apply_patch"], + ]) + test(`rejects ${label} before title or model requests`, async () => { + const result = await run({ + label: "exact-" + label, + args: ["--tools", value], + config: { agent: { title: { disable: false } } }, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + }, 20000) + for (const [label, value] of [ + ["string", "read"], + ["empty-element", ["read", ""]], + ["whitespace", [" read"]], + ["null", [null]], + ] as const) + test(`strictly rejects malformed config selector ${label}`, async () => { + const result = await run({ label: "config-malformed-" + label, config: { tool_allowlist: value } }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + }, 20000) + test("duplicate exact CLI names are stable-deduplicated without adding aliases", async () => { + const result = await run({ label: "exact-dedup", args: ["--tools", "read,bash,read,bash"] }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["bash", "read"]) + }, 20000) + for (const denied of [false, true]) + test(`selected Read ${denied ? "obeys path denial" : "reads allowed fixture control"}`, async () => { + const sentinel = "Private path acceptance sentinel 246" + const result = await run({ + label: denied ? "read-path-denied" : "read-path-positive", + args: ["--tools", "read"], + calls: [{ name: "read", arguments: { filePath: "private.txt" } }], + setup: async (directory) => { + const file = path.join(directory, "private.txt") + await Bun.write(file, sentinel) + return denied ? { permission: { "*": "allow", read: { "*": "allow", [file]: "deny" } } } : undefined + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["read"]) + expect(result.requests).toHaveLength(2) + expect(JSON.stringify(result.requests[1].messages).includes(sentinel)).toBe(!denied) + }, 20000) + test("empty work selection does not gate caller-directed file attachment loading", async () => { + const result = await run({ + label: "caller-attachment-empty", + args: ["--tools", "", "--file", "caller.txt", "--"], + setup: async (directory) => { + await Bun.write(path.join(directory, "caller.txt"), "Caller directed content 246") + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual([]) + expect(JSON.stringify(result.requests[0].messages)).toContain("Caller directed content 246") + }, 20000) + for (const selected of [false, true]) + test(`real MCP wrapper ${selected ? "executes selected exact key" : "blocks omitted key"}`, async () => { + const result = await run({ + label: selected ? "mcp-selected" : "mcp-omitted", + args: ["--tools", selected ? "fixture_echo" : ""], + calls: [{ name: "fixture_echo", arguments: { text: "accepted" } }], + markers: ["mcp-marker.json"], + setup: setupMCP, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(selected ? ["fixture_echo"] : []) + expect(result.markers["mcp-marker.json"]).toEqual( + selected ? { name: "echo", arguments: { text: "accepted" } } : null, + ) + if (selected) + expect(result.requests[0].tools?.[0].function.description).toBe("Original MCP Task Grep Read body preserved") + }, 20000) + for (const label of ["positive", "omitted", "permission", "unknown", "recursive", "external-mcp"]) + test(`Batch whole-group ${label} preflight precedes outer hook and all work effects`, async () => { + const second = + label === "unknown" + ? "unregistered_fixture" + : label === "recursive" + ? "batch" + : label === "external-mcp" + ? "fixture_echo" + : "omitted" + const selected = ["batch", "first", ...(label === "omitted" || label === "unknown" ? [] : [second])] + const result = await run({ + label: "group-" + label, + args: ["--tools", selected.join(",")], + config: { + experimental: { batch_tool: true }, + ...(label === "permission" ? { permission: { "*": "allow", omitted: "deny" } } : {}), + }, + markers: [ + "group-hook-batch.json", + "group-hook-first.json", + "group-hook-omitted.json", + "work-marker.json", + "mcp-marker.json", + ], + calls: [ + { + name: "batch", + arguments: { + tool_calls: [ + { tool: "first", parameters: {} }, + { tool: second, parameters: label === "external-mcp" ? { text: "never" } : {} }, + ], + }, + }, + ], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/first.ts"), collision) + await Bun.write(path.join(directory, ".aictrl/tool/omitted.ts"), collision) + const plugin = path.join(directory, "group-hooks.ts") + await Bun.write(plugin, groupHooks) + return { + plugin: [new URL("file://" + plugin).href], + ...(label === "external-mcp" ? await setupMCP(directory) : {}), + } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.markers["work-marker.json"]).toEqual(label === "positive" ? { executed: true } : null) + for (const tool of ["batch", "first", "omitted"]) + expect(result.markers[`group-hook-${tool}.json`]).toEqual(label === "positive" ? { tool } : null) + expect(result.markers["mcp-marker.json"]).toBeNull() + }, 20000) +}) + +describe("public plugin checked selection facade (#246)", () => { + for (const [label, source] of [ + ["baseline", baseline], + ["candidate", entry], + ] as const) + test.skipIf(label === "baseline" && !process.env.SELECTOR_BASELINE_ENTRY)( + `${label} public metadata registration diagnostic uses real running-state barrier`, + async () => { + const result = await run({ + label: "metadata-registration-" + label, + entry: source, + runtime: "metadata-registration", + calls: [{ name: "probe", arguments: {} }], + markers: ["runtime-report.json"], + setup: async (directory) => { + const modules = path.join(directory, "node_modules/@aictrl") + await fs.mkdir(modules, { recursive: true }) + await fs.symlink(path.resolve(path.dirname(source), "../../plugin"), path.join(modules, "plugin"), "dir") + await Bun.write(path.join(directory, ".aictrl/tool/probe.ts"), metadataProbe) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(2) + expect(result.markers["runtime-report.json"]).toMatchObject({ ready: true }) + }, + 20000, + ) + for (const selected of [false, true]) + test(`public facade dispatch to MCP ${selected ? "normalizes selected result" : "blocks omitted target"}`, async () => { + const result = await run({ + label: selected ? "public-mcp-selected" : "public-mcp-omitted", + args: ["--tools", selected ? "delegate,fixture_echo" : "delegate"], + calls: [{ name: "delegate", arguments: { name: "fixture_echo", payload: { text: "nested" }, spoof: true } }], + markers: ["caller-marker.json", "mcp-marker.json", "delegate-marker.json"], + setup: async (directory) => { + await setupPublicFacade(directory) + return await setupMCP(directory) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(selected ? ["delegate", "fixture_echo"] : ["delegate"]) + expect(result.markers["mcp-marker.json"]).toEqual( + selected ? { name: "echo", arguments: { text: "nested" } } : null, + ) + expect(result.markers["delegate-marker.json"]).toEqual( + selected ? { title: "", output: "MCP fixture result: nested", metadata: { truncated: false } } : null, + ) + const event = result.stdout + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)) + .find((event) => event.type === "tool_use" && event.part.tool === "delegate") + expect(result.markers["caller-marker.json"]).toMatchObject({ + sessionID: event.sessionID, + messageID: event.part.messageID, + agent: expect.any(String), + directory: path.dirname(result.command[3]), + worktree: "/", + abortPresent: true, + abortInitially: false, + }) + }, 20000) + test("public nested dispatch binds real caller identity metadata and abort; busy prompt cannot persist or replace selection", async () => { + const result = await run({ + label: "public-abort-busy", + runtime: "public-abort-busy", + calls: [{ name: "delegate", arguments: { name: "abortleaf", spoof: true, waitRunning: true } }, null], + markers: ["runtime-report.json", "caller-marker.json", "abort-ready.json", "abort-observed.json"], + setup: async (directory) => { + await setupPublicFacade(directory) + await Bun.write(path.join(directory, ".aictrl/tool/abortleaf.ts"), publicAbortLeaf) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(2) + expect(names(result.requests[0])).toEqual(["abortleaf", "delegate"]) + expect(names(result.requests[1])).toEqual(["read"]) + expect(result.markers["abort-ready.json"]).toEqual(result.markers["caller-marker.json"]) + expect(result.markers["abort-observed.json"]).toEqual({ aborted: true }) + expect(result.markers["runtime-report.json"]).toMatchObject({ + busy: expect.stringMatching(/busy/i), + users: [ + { toolSelection: { version: 1, allowlist: ["delegate", "abortleaf"] } }, + { toolSelection: { version: 1, allowlist: ["read"] } }, + ], + metadata: [ + { + tool: "delegate", + sessionID: expect.stringMatching(/^ses_/), + messageID: expect.stringMatching(/^msg_/), + state: { title: "Public delegate running", metadata: { facade: true } }, + }, + { + tool: "delegate", + sessionID: expect.stringMatching(/^ses_/), + messageID: expect.stringMatching(/^msg_/), + state: { title: "Public leaf waiting", metadata: { abortLeaf: true } }, + }, + ], + }) + const caller = result.markers["caller-marker.json"] as { sessionID: string; messageID: string } + const report = result.markers["runtime-report.json"] as { metadata: { sessionID: string; messageID: string }[] } + for (const part of report.metadata) + expect({ sessionID: part.sessionID, messageID: part.messageID }).toEqual({ + sessionID: caller.sessionID, + messageID: caller.messageID, + }) + }, 20000) + for (const item of [ + { label: "public-facade-unrestricted", selected: null, allowed: true }, + { label: "public-facade-positive", selected: ["delegate", "leaf"], allowed: true }, + { label: "public-facade-omitted", selected: ["delegate"], allowed: false }, + { + label: "public-facade-permission", + selected: ["delegate", "leaf"], + permission: { "*": "allow", leaf: "deny" }, + allowed: false, + }, + { + label: "public-facade-ask-denied", + selected: ["delegate", "leaf"], + permission: { "*": "allow", public_leaf: "deny" }, + allowed: false, + }, + { label: "public-facade-batch", selected: ["batch", "delegate", "leaf"], batch: true, allowed: true }, + ]) + test( + item.label, + async () => { + const call = { name: "delegate", arguments: { name: "leaf", payload: { text: " selected " } } } + const result = await run({ + label: item.label, + args: item.selected ? ["--tools", item.selected.join(",")] : [], + config: { + ...(item.permission ? { permission: item.permission } : {}), + ...(item.batch ? { experimental: { batch_tool: true } } : {}), + }, + markers: ["facade-marker.json", "leaf-marker.json", "delegate-marker.json"], + calls: [ + item.batch + ? { name: "batch", arguments: { tool_calls: [{ tool: call.name, parameters: call.arguments }] } } + : call, + ], + setup: setupPublicFacade, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + const expectedNames = + item.label === "public-facade-permission" + ? ["delegate"] + : item.selected + ? [...item.selected].sort() + : [...full, "delegate", "leaf"].sort() + expect(names(result.requests[0])).toEqual(expectedNames) + expect(result.markers["facade-marker.json"]).toEqual({ + names: expectedNames, + restricted: item.selected !== null, + frozen: true, + namesFrozen: true, + executeArity: 2, + assertArity: 1, + }) + expect(result.markers["leaf-marker.json"]).toEqual( + item.allowed ? { text: "selected", tag: "public-default" } : null, + ) + if (item.allowed) + expect(result.markers["delegate-marker.json"]).toMatchObject({ output: "Public leaf complete" }) + else expect(result.markers["delegate-marker.json"]).toBeNull() + }, + 20000, + ) +}) + +describe("custom work names remain distinct from native control provenance (#246)", () => { + test("native invalid repair remains available under empty work selection when permitted", async () => { + const result = await run({ + label: "identity-native-invalid", + args: ["--tools", ""], + calls: [{ name: "unregistered_fixture", arguments: {} }], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual([]) + expect(result.requests).toHaveLength(2) + expect(JSON.stringify(result.requests[1].messages)).toContain("The arguments provided to the tool are invalid:") + }, 20000) + test("permitted native StructuredOutput publishes under empty work selection", async () => { + const result = await run({ + label: "identity-native-structured", + args: ["--tools", "", "--output-schema", "schema.json"], + config: { permission: { "*": "deny", StructuredOutput: "allow" } }, + calls: [{ name: "StructuredOutput", arguments: { path: "result.json" } }], + setup: async (directory) => { + await Bun.write( + path.join(directory, "schema.json"), + JSON.stringify({ + type: "object", + properties: { result: { type: "string" } }, + required: ["result"], + additionalProperties: false, + }), + ) + await Bun.write(path.join(directory, "result.json"), JSON.stringify({ result: "accepted" })) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["StructuredOutput"]) + expect(result.stdout).toContain('"result":"accepted"') + }, 20000) + for (const name of ["invalid", "_noop", "StructuredOutput"]) { + test(`explicitly selected custom ${name} is declared and executes as work outside schema mode`, async () => { + const result = await run({ + label: `identity-selected-${name}`, + args: ["--tools", name], + markers: ["work-marker.json"], + calls: [{ name, arguments: {} }], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool", name + ".ts"), collision) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual([name]) + expect(result.markers["work-marker.json"]).toEqual({ executed: true }) + }, 20000) + } + test("unknown tool repair cannot dispatch selected custom invalid as a native error fallback", async () => { + const result = await run({ + label: "identity-unknown-repair", + args: ["--tools", "invalid"], + markers: ["work-marker.json"], + calls: [{ name: "unregistered_fixture", arguments: {} }], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/invalid.ts"), collision) + }, + }) + expect(result.requests.length).toBeGreaterThanOrEqual(1) + expect(result.markers["work-marker.json"]).toBeNull() + }, 20000) +}) + +describe("critical selector implementation boundaries (#246)", () => { + for (const name of ["invalid", "StructuredOutput"]) { + test(`empty selection excludes ordinary custom ${name} identity and execution`, async () => { + const result = await run({ + label: `critical-custom-${name}`, + args: ["--tools", ""], + markers: ["work-marker.json"], + calls: [{ name, arguments: {} }], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool", name + ".ts"), collision) + }, + }) + expect(result.requests.length).toBeGreaterThanOrEqual(1) + expect(names(result.requests[0])).not.toContain(name) + expect(result.markers["work-marker.json"]).toBeNull() + }, 20000) + } + + for (const [label, args] of [ + ["missing", {}], + ["wrong-type", { text: 42 }], + ] as const) { + test(`Batch validates ${label} raw custom args before hooks and execution`, async () => { + const result = await run({ + label: `critical-batch-${label}`, + args: ["--tools", "batch,strict"], + config: { experimental: { batch_tool: true } }, + markers: ["hook-marker.json", "custom-marker.json"], + calls: [{ name: "batch", arguments: { tool_calls: [{ tool: "strict", parameters: args }] } }], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/strict.ts"), custom) + const plugin = path.join(directory, "hooks.ts") + await Bun.write(plugin, hooks) + return { plugin: [new URL("file://" + plugin).href] } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(["batch", "strict"]) + expect(result.markers).toEqual({ "hook-marker.json": null, "custom-marker.json": null }) + }, 20000) + } + + test("Batch passes parsed trim and default values to actual custom hook and plugin", async () => { + const result = await run({ + label: "critical-batch-transform", + args: ["--tools", "batch,strict"], + config: { experimental: { batch_tool: true } }, + markers: ["hook-marker.json", "custom-marker.json"], + calls: [{ name: "batch", arguments: { tool_calls: [{ tool: "strict", parameters: { text: " trimmed " } }] } }], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/strict.ts"), custom) + const plugin = path.join(directory, "hooks.ts") + await Bun.write(plugin, hooks) + return { plugin: [new URL("file://" + plugin).href] } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.markers).toEqual({ + "hook-marker.json": { text: "trimmed", tag: "default-tag" }, + "custom-marker.json": { text: "trimmed", tag: "default-tag" }, + }) + }, 20000) + + const command = { + permission: { "*": "allow", task: "deny" }, + command: { inspect: { template: "Inspect harmless fixture.", agent: "explore", subtask: true } }, + } + test.skipIf(!process.env.SELECTOR_BASELINE_ENTRY)( + "pinned baseline permits explicit command subtask despite Task permission denial", + async () => { + const result = await run({ + label: "critical-command-baseline", + entry: baseline, + args: ["--command", "inspect"], + config: command, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.length).toBeGreaterThanOrEqual(1) + }, + 20000, + ) + for (const selected of [false, true]) { + test(`explicit command preserves manual Task bypass with selection ${selected ? "task" : "omitted"}`, async () => { + const result = await run({ + label: `critical-command-${selected ? "selected" : "default"}`, + args: ["--command", "inspect", ...(selected ? ["--tools", "task"] : [])], + config: command, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.length).toBeGreaterThanOrEqual(1) + }, 20000) + } + + for (const [label, source] of [ + ["baseline", baseline], + ["candidate", entry], + ] as const) { + test.skipIf(label === "baseline" && !process.env.SELECTOR_BASELINE_ENTRY)( + `${label} no-selector noReply records an unavailable model without inference`, + async () => { + const result = await run({ label: `critical-noreply-${label}`, entry: source, noReply: true }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toEqual([]) + expect(JSON.parse(result.stdout.trim())).toEqual({ + role: "user", + model: { providerID: "fixture", modelID: "unavailable-model" }, + saved: 1, + }) + }, + 20000, + ) + } +}) + +describe("remaining permission and input preparation contracts (#246)", () => { + for (const tool of ["edit", "write"] as const) + for (const denied of [false, true]) + test(`selected ${tool} canonical edit permission last-match ${denied ? "denies without changing bytes" : "allows after earlier denial"}`, async () => { + const before = '{"value":"before"}\n' + const after = '{"value":"after"}\n' + const result = await run({ + label: `mutation-${tool}-${denied ? "denied" : "allowed"}`, + args: ["--tools", `read,${tool}`], + files: ["target.json"], + calls: [ + { name: "read", arguments: { filePath: "target.json" } }, + { + name: tool, + arguments: + tool === "edit" + ? { filePath: "target.json", oldString: "before", newString: "after" } + : { filePath: "target.json", content: after }, + }, + ], + setup: async (directory) => { + await Bun.write(path.join(directory, "target.json"), before) + return { + permission: { + "*": "allow", + edit: denied ? { "*": "allow", "*/target.json": "deny" } : { "*/target.json": "deny", "*": "allow" }, + }, + } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual([tool, "read"].sort()) + expect(result.requests).toHaveLength(3) + expect(JSON.stringify(result.requests[1].messages)).toContain("before") + expect(result.files["target.json"]).toBe(denied ? before : after) + if (denied) expect(JSON.stringify(result.requests[2].messages)).toContain("rule which prevents you") + }, 20000) + for (const override of [false, true]) + test(`legacy tools false ${override ? "explicit permission allow takes precedence" : "cannot be regranted by selector"}`, async () => { + const result = await run({ + label: `legacy-selector-${override ? "override" : "deny"}`, + args: ["--tools", "read,bash"], + config: { + tools: { bash: false }, + ...(override ? { permission: { "*": "allow", bash: "allow" } } : { permission: { read: "allow" } }), + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(override ? ["bash", "read"] : ["read"]) + }, 20000) +}) + +describe("caller preparation and imported fresh prompt (#246)", () => { + for (const mode of ["resolved-file-empty", "command-file-empty", "command-shell-empty"]) + test(`${mode} preserves caller-directed content outside model work selection`, async () => { + const result = await run({ + label: mode, + runtime: mode, + config: { permission: { "*": "allow", read: "deny", bash: "deny" } }, + setup: async (directory) => { + const file = path.join(directory, "caller-input.txt") + await Bun.write(file, "Caller explicit at-file content 246") + return { + command: { + prepare: { + template: + mode === "command-shell-empty" + ? "Include !`printf 'Caller explicit shell content 246'`" + : `Inspect @${file}`, + description: "Explicit caller file preparation", + }, + }, + } + }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(1) + expect(names(result.requests[0])).toEqual([]) + expect(JSON.stringify(result.requests[0].messages)).toContain( + mode === "command-shell-empty" ? "Caller explicit shell content 246" : "Caller explicit at-file content 246", + ) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [{ toolSelection: { version: 1, allowlist: [] } }], + }) + }, 20000) + test("fresh omitted prompt after real CLI import resets to current config", async () => { + const result = await run({ + label: "import-fresh-reset", + runtime: "export-import-reset", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"], ["bash"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [{ toolSelection: { allowlist: ["read"] } }, { toolSelection: { allowlist: ["bash"] } }], + }) + }, 20000) +}) + +describe("phase cancellation and overlapping child isolation (#246)", () => { + for (const mode of ["cancel-record", "cancel-model"]) + test(`${mode} retains owner snapshot, rejects busy replacement and releases for fresh prompt`, async () => { + const result = await run({ + label: mode, + runtime: mode, + markers: ["runtime-report.json"], + setup: async (directory) => { + if (mode !== "cancel-record") return + const plugin = path.join(directory, "record-barrier.ts") + await Bun.write( + plugin, + `import {writeFile} from "node:fs/promises" +export default async()=>({"chat.message":async()=>{ + const root=new URL("./",import.meta.url) + if(await Bun.file(new URL("phase-release.json",root)).exists())return + await writeFile(new URL("phase-ready.json",root),"true") + const deadline=Date.now()+5000 + while(!await Bun.file(new URL("phase-release.json",root)).exists()){if(Date.now()>deadline)throw new Error("Record fixture barrier timeout");await Bun.sleep(5)} +}})`, + ) + return { plugin: [new URL("file://" + plugin).href] } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual(mode === "cancel-record" ? [["bash"]] : [["read"], ["bash"]]) + const report = result.markers["runtime-report.json"] as { ownerFailure?: string } + if (mode === "cancel-record") expect(report.ownerFailure).toContain("Impossible") + else expect(report.ownerFailure).toBeUndefined() + expect(result.markers["runtime-report.json"]).toMatchObject({ + before: mode === "cancel-record" ? 0 : 1, + busy: expect.stringContaining("busy"), + users: [{ toolSelection: { allowlist: ["read"] } }, { toolSelection: { allowlist: ["bash"] } }], + }) + }, 20000) + test("concurrent real Task siblings retain independent effective ceilings", async () => { + const script = (requests: Request[]): Call | null => { + const messages = requests.at(-1)?.messages ?? [] + if (messages.some((message) => message.role === "assistant")) return null + const text = String(messages.at(-1)?.content) + if (text.includes("Parent sibling read")) + return { ...childTask, arguments: { ...childTask.arguments, prompt: "Child sibling read" } } + if (text.includes("Parent sibling bash")) + return { ...childTask, arguments: { ...childTask.arguments, prompt: "Child sibling bash" } } + return null + } + const result = await run({ + label: "sibling-children", + runtime: "sibling-children", + calls: Array(6).fill(script), + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(6) + const children = result.requests.filter( + (request) => + request.messages?.at(-1)?.role === "user" && String(request.messages.at(-1)?.content).includes("Child sibling"), + ) + expect(children).toHaveLength(2) + for (const child of children) + expect(names(child)).toEqual(String(child.messages?.at(-1)?.content).includes("read") ? ["read"] : ["bash"]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + first: { children: [{ users: [{ toolSelection: { allowlist: ["read", "task"] } }] }] }, + second: { children: [{ users: [{ toolSelection: { allowlist: ["bash", "task"] } }] }] }, + }) + }, 20000) +}) + +test.skipIf(!process.env.SELECTOR_BASELINE_ENTRY)( + "baseline record-phase cancellation diagnostic", + async () => { + const result = await run({ + label: "baseline-record-cancel", + runtime: "cancel-record", + entry: baseline, + markers: ["runtime-report.json"], + setup: async (directory) => { + const plugin = path.join(directory, "record-barrier.ts") + await Bun.write( + plugin, + `import {writeFile} from "node:fs/promises" +export default async()=>({"chat.message":async()=>{ + const root=new URL("./",import.meta.url) + if(await Bun.file(new URL("phase-release.json",root)).exists())return + await writeFile(new URL("phase-ready.json",root),"true") + const deadline=Date.now()+5000 + while(!await Bun.file(new URL("phase-release.json",root)).exists()){if(Date.now()>deadline)throw new Error("Record fixture barrier timeout");await Bun.sleep(5)} +}})`, + ) + return { plugin: [new URL("file://" + plugin).href] } + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.markers["runtime-report.json"]).toMatchObject({ + before: 0, + busy: expect.stringContaining("busy"), + ownerFailure: expect.stringContaining("Impossible"), + }) + expect(result.requests).toHaveLength(1) + }, + 20000, +) + +test("overlapping actual Task resume cannot replace an active child snapshot", async () => { + const script = (requests: Request[]): Call | null => { + const last = requests.at(-1)?.messages?.at(-1) + if (last?.role !== "user") return null + const text = String(last.content) + const task_id = text.match(/ses_[a-zA-Z0-9]+/)?.[0] + const prompt = text.includes("Initial resume parent") + ? "Initial child" + : text.includes("Resume held parent") + ? "Held resumed child" + : text.includes("Overlap rejected parent") + ? "Rejected overlap child" + : text.includes("Fresh resumed parent") + ? "Fresh resumed child" + : undefined + return prompt + ? { ...childTask, arguments: { ...childTask.arguments, prompt, ...(task_id ? { task_id } : {}) } } + : null + } + const result = await run({ + label: "overlap-child-resume", + runtime: "overlap-child-resume", + calls: Array(11).fill(script), + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(11) + const held = result.requests.find( + (request) => + request.messages?.at(-1)?.role === "user" && + String(request.messages.at(-1)?.content).includes("Held resumed child"), + ) + const fresh = result.requests.find( + (request) => + request.messages?.at(-1)?.role === "user" && + String(request.messages.at(-1)?.content).includes("Fresh resumed child"), + ) + expect(held).toBeDefined() + expect(fresh).toBeDefined() + expect(names(held!)).toEqual(["read"]) + expect(names(fresh!)).toEqual(["bash"]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + during: [ + { toolSelection: { allowlist: ["bash", "read", "task"] } }, + { toolSelection: { allowlist: ["read", "task"] } }, + ], + after: [ + { toolSelection: { allowlist: ["bash", "read", "task"] } }, + { toolSelection: { allowlist: ["read", "task"] } }, + { toolSelection: { allowlist: ["bash", "task"] } }, + ], + }) + expect(JSON.stringify(result.requests)).toContain("busy") +}, 20000) + +describe("persisted process and MCP identity controls (#246)", () => { + test("fresh process replays saved ceiling then fresh omission resolves config", async () => { + const result = await run({ + label: "restart-snapshot", + runtime: "restart-snapshot", + config: { tool_allowlist: ["bash"] }, + markers: ["runtime-report.json"], + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests.map(names)).toEqual([["read"], ["bash"]]) + expect(result.markers["runtime-report.json"]).toMatchObject({ + users: [{ toolSelection: { allowlist: ["read"] } }, { toolSelection: { allowlist: ["bash"] } }], + }) + }, 20000) + for (const denied of [false, true]) + test(`MCP collision ${denied ? "keeps permission denial" : "declares and dispatches the same captured external identity"}`, async () => { + const result = await run({ + label: `mcp-collision-${denied ? "denied" : "positive"}`, + args: ["--tools", "fixture_echo"], + config: denied ? { permission: { "*": "allow", fixture_echo: "deny" } } : undefined, + calls: [{ name: "fixture_echo", arguments: { text: "collision" } }], + markers: ["mcp-marker.json", "work-marker.json"], + setup: async (directory) => { + await Bun.write(path.join(directory, ".aictrl/tool/fixture_echo.ts"), collision) + return await setupMCP(directory) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(names(result.requests[0])).toEqual(denied ? [] : ["fixture_echo"]) + expect(result.markers["work-marker.json"]).toBeNull() + expect(result.markers["mcp-marker.json"]).toEqual( + denied ? null : { name: "echo", arguments: { text: "collision" } }, + ) + if (!denied) { + expect(result.requests[0].tools?.[0].function.description).toContain( + "Original MCP Task Grep Read body preserved", + ) + expect(JSON.stringify(result.requests[1].messages)).toContain("MCP fixture result: collision") + } + }, 20000) +}) + +describe("feature flag and Batch independent runtime compatibility (#246)", () => { + test("flag-disabled Batch selector rejects before title or model inference", async () => { + const result = await run({ + label: "batch-flag-ineligible", + args: ["--tools", "batch"], + config: { agent: { title: { disable: false } } }, + }) + expect(result.exit, result.stderr + result.stdout).not.toBe(0) + expect(result.requests).toEqual([]) + expect(result.stdout + result.stderr).toContain("batch") + }, 20000) + test("allowed Batch runtime failure preserves independent permitted work", async () => { + const result = await run({ + label: "batch-independent-runtime", + args: ["--tools", "batch,failing,succeed"], + config: { experimental: { batch_tool: true } }, + calls: [ + { + name: "batch", + arguments: { + tool_calls: [ + { tool: "failing", parameters: {} }, + { tool: "succeed", parameters: {} }, + ], + }, + }, + ], + markers: ["failing-ran.json", "work-marker.json"], + setup: async (directory) => { + await Bun.write( + path.join(directory, ".aictrl/tool/failing.ts"), + `import {writeFile} from "node:fs/promises" +export default {description:"Actual permitted runtime failure",args:{},execute:async()=>{await writeFile(new URL("../../failing-ran.json",import.meta.url),"true");throw new Error("Independent runtime failure sentinel")}}`, + ) + await Bun.write(path.join(directory, ".aictrl/tool/succeed.ts"), collision) + }, + }) + expect(result.exit, result.stderr + result.stdout).toBe(0) + expect(result.requests).toHaveLength(2) + expect(result.markers).toEqual({ "failing-ran.json": true, "work-marker.json": { executed: true } }) + expect(JSON.stringify(result.requests[1].messages)).toContain("Independent runtime failure sentinel") + expect(JSON.stringify(result.requests[1].messages)).toContain("Custom work executed") + }, 20000) +}) + +test("forced CLI fixture deadline cannot pass as an expected negative exit", async () => { + await expect(run({ label: "forced-cli-deadline", runtime: "deadline-hang", deadlineMs: 20 })).rejects.toThrow( + /child deadline exceeded after 20ms; providerRequests=0/, + ) +}, 20000) diff --git a/packages/cli/test/fixture/tool-selection-runtime.ts b/packages/cli/test/fixture/tool-selection-runtime.ts new file mode 100644 index 00000000..083f391a --- /dev/null +++ b/packages/cli/test/fixture/tool-selection-runtime.ts @@ -0,0 +1,487 @@ +// Standalone child runtime: the parent installs its network guard before imports. +if (process.env.SELECTOR_RUNTIME_MODE === "deadline-hang") await Bun.sleep(10000) +const root = process.env.SELECTOR_RUNTIME_ROOT +if (!root) throw new Error("Missing isolated runtime root") +const { Instance } = (await import(root + "/project/instance.ts")) as typeof import("../../src/project/instance") +const { Session } = (await import(root + "/session/index.ts")) as typeof import("../../src/session") +const { SessionPrompt } = (await import(root + "/session/prompt.ts")) as typeof import("../../src/session/prompt") +const mode = process.env.SELECTOR_RUNTIME_MODE +try { + await Instance.provide({ + directory: process.cwd(), + async fn() { + const session = process.env.SELECTOR_SAVED_SESSION + ? await Session.get(process.env.SELECTOR_SAVED_SESSION) + : await Session.create({ title: "Selection runtime fixture" }) + const prompt = (toolAllowlist: string[], text: string) => + SessionPrompt.prompt({ + sessionID: session.id, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist, + parts: [{ type: "text", text }], + }) + const users = async (sessionID: string) => + (await Session.messages({ sessionID })).filter((item) => item.info.role === "user").map((item) => item.info) + const record = () => + SessionPrompt.prompt({ + sessionID: session.id, + noReply: true, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: ["read"], + parts: [{ type: "text", text: "Recorded read snapshot" }], + }) + const omitted = (sessionID: string, text: string) => + SessionPrompt.prompt({ + sessionID, + model: { providerID: "fixture", modelID: "fixture" }, + parts: [{ type: "text", text }], + }) + if (mode === "restart-replay") { + await SessionPrompt.loop({ sessionID: session.id }) + await omitted(session.id, "Fresh process omitted selector resolves current config") + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "restart-snapshot") { + await record() + const keys = [ + "PATH", + "SHELL", + "HOME", + "AICTRL_TEST_HOME", + "AICTRL_TEST_MANAGED_CONFIG_DIR", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + "AICTRL_CONFIG_CONTENT", + "AICTRL_DISABLE_DEFAULT_PLUGINS", + "AICTRL_DISABLE_AUTOCOMPACT", + "AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", + "SELECTOR_FIXTURE_ORIGIN", + "SELECTOR_FIXTURE_GUARD_LOG", + "SELECTOR_RUNTIME_ROOT", + "SELECTOR_RUNTIME_PRELOAD", + ] + const env = Object.fromEntries( + keys.flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]!]])), + ) + const child = Bun.spawn( + [ + process.execPath, + "run", + "--no-install", + "--no-env-file", + "--preload", + process.env.SELECTOR_RUNTIME_PRELOAD!, + "--conditions=browser", + import.meta.path, + ], + { + cwd: process.cwd(), + env: { ...env, SELECTOR_RUNTIME_MODE: "restart-replay", SELECTOR_SAVED_SESSION: session.id }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ) + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + if (exit !== 0) throw new Error("Restart fixture failed: " + stderr + stdout) + return + } + if (mode === "codex-reset") { + const call = (toolAllowlist?: string[]) => + SessionPrompt.prompt({ + sessionID: session.id, + model: { providerID: "openai", modelID: "gpt-5.2" }, + ...(toolAllowlist === undefined ? {} : { toolAllowlist }), + parts: [{ type: "text", text: "Codex selection fixture" }], + }) + await call() + await call(["read"]) + await call() + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "noop-history") { + await Bun.write("read-fixture.txt", "Noop history harmless content") + await prompt(["read"], "Read fixture first") + await prompt([], "No work tools with historical tool call") + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "resolved-file-empty" || mode === "command-file-empty" || mode === "command-shell-empty") { + if (mode === "resolved-file-empty") { + const parts = await SessionPrompt.resolvePromptParts("Inspect @" + process.cwd() + "/caller-input.txt") + await SessionPrompt.prompt({ + sessionID: session.id, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: [], + parts, + }) + } else { + await SessionPrompt.command({ + sessionID: session.id, + command: "prepare", + arguments: "", + model: "fixture/fixture", + toolAllowlist: [], + }) + } + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "snapshot-reset") { + await record() + await SessionPrompt.loop({ sessionID: session.id }) + await omitted(session.id, "Fresh omitted selector") + await prompt([], "Explicit empty selector") + await omitted(session.id, "Omitted selector after empty") + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "historical-missing") { + const { Identifier } = (await import(root + "/id/id.ts")) as typeof import("../../src/id/id") + const message = await Session.updateMessage({ + id: Identifier.ascending("message"), + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: "fixture", modelID: "fixture" }, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + sessionID: session.id, + messageID: message.id, + type: "text", + text: "Historical wire User without selector metadata", + }) + await SessionPrompt.loop({ sessionID: session.id }) + await omitted(session.id, "Fresh prompt resolves config") + await Bun.write("runtime-report.json", JSON.stringify({ users: await users(session.id) })) + return + } + if (mode === "fork-snapshot") { + await record() + const fork = await Session.fork({ sessionID: session.id }) + await SessionPrompt.loop({ sessionID: fork.id }) + await omitted(fork.id, "New fork prompt resolves config") + await Bun.write( + "runtime-report.json", + JSON.stringify({ original: await users(session.id), forked: await users(fork.id) }), + ) + return + } + if (mode === "export-import" || mode === "export-import-reset") { + await record() + const keys = [ + "PATH", + "SHELL", + "HOME", + "AICTRL_TEST_HOME", + "AICTRL_TEST_MANAGED_CONFIG_DIR", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + "AICTRL_CONFIG_CONTENT", + "AICTRL_DISABLE_DEFAULT_PLUGINS", + "AICTRL_DISABLE_AUTOCOMPACT", + "AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS", + "SELECTOR_FIXTURE_ORIGIN", + "SELECTOR_FIXTURE_GUARD_LOG", + ] + const env = Object.fromEntries( + keys.flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]!]])), + ) + const cli = async (args: string[]) => { + const child = Bun.spawn( + [ + process.execPath, + "run", + "--no-install", + "--no-env-file", + "--preload", + process.env.SELECTOR_RUNTIME_PRELOAD!, + "--conditions=browser", + root + "/index.ts", + ...args, + ], + { cwd: process.cwd(), env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ) + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + if (exit !== 0) throw new Error("Actual CLI " + args[0] + " failed: " + stderr) + return { stdout, stderr, exit } + } + const exported = await cli(["export", session.id]) + const data = JSON.parse(exported.stdout) + await Bun.write("session-export.json", exported.stdout) + await Session.remove(session.id) + const imported = await cli(["import", "session-export.json"]) + await SessionPrompt.loop({ sessionID: session.id }) + if (mode === "export-import-reset") await omitted(session.id, "Fresh imported prompt resolves config") + await Bun.write( + "runtime-report.json", + JSON.stringify({ + exported: data.messages + .filter((item: { info: { role: string } }) => item.info.role === "user") + .map((item: { info: unknown }) => item.info), + users: await users(session.id), + imported: imported.stdout, + }), + ) + return + } + if (mode === "compaction-snapshot") { + await record() + const { SessionCompaction } = (await import( + root + "/session/compaction.ts" + )) as typeof import("../../src/session/compaction") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: { providerID: "fixture", modelID: "fixture" }, + auto: true, + }) + await SessionPrompt.loop({ sessionID: session.id }) + await Bun.write( + "runtime-report.json", + JSON.stringify({ + users: await users(session.id), + parts: (await Session.messages({ sessionID: session.id })) + .flatMap((message) => message.parts) + .filter((part) => part.type === "compaction"), + }), + ) + return + } + if (mode === "command-snapshot") { + await SessionPrompt.command({ + sessionID: session.id, + command: "inspect", + arguments: "", + model: "fixture/fixture", + toolAllowlist: ["task", "read"], + }) + await Bun.write( + "runtime-report.json", + JSON.stringify({ + users: await users(session.id), + parts: (await Session.messages({ sessionID: session.id })) + .flatMap((message) => message.parts) + .filter((part) => part.type === "text" && part.synthetic), + }), + ) + return + } + if (mode === "cancel-record" || mode === "cancel-model") { + const pending = prompt(["read"], "Owner cancellation snapshot").then( + () => undefined, + (error) => String(error), + ) + const deadline = Date.now() + 5000 + while (!(await Bun.file("phase-ready.json").exists())) { + if (Date.now() > deadline) throw new Error("Phase barrier never ready") + await Bun.sleep(5) + } + const before = (await users(session.id)).length + let busy: string | undefined + try { + await prompt(["bash"], "Must not replace busy owner") + } catch (error) { + busy = String(error) + } + SessionPrompt.cancel(session.id) + await Bun.write("phase-release.json", "true") + const ownerFailure = await pending + await prompt(["bash"], "Fresh owner after cancellation") + await Bun.write( + "runtime-report.json", + JSON.stringify({ before, busy, ownerFailure, users: await users(session.id) }), + ) + return + } + if (mode === "overlap-child-resume") { + await prompt(["task", "read", "bash"], "Initial resume parent") + const [child] = await Session.children(session.id) + if (!child) throw new Error("Actual initial Task created no child") + const pending = prompt(["task", "read"], "Resume held parent " + child.id) + const deadline = Date.now() + 5000 + while (!(await Bun.file("phase-ready.json").exists())) { + if (Date.now() > deadline) throw new Error("Resume child barrier never reached") + await Bun.sleep(5) + } + const other = await Session.create({ title: "Overlapping Task caller" }) + await SessionPrompt.prompt({ + sessionID: other.id, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: ["task", "bash"], + parts: [{ type: "text", text: "Overlap rejected parent " + child.id }], + }) + const during = await users(child.id) + await Bun.write("phase-release.json", "true") + await pending + await prompt(["task", "bash"], "Fresh resumed parent " + child.id) + await Bun.write("runtime-report.json", JSON.stringify({ during, after: await users(child.id) })) + return + } + if (mode === "sibling-children") { + const second = await Session.create({ title: "Second sibling parent" }) + await Promise.all([ + prompt(["task", "read"], "Parent sibling read"), + SessionPrompt.prompt({ + sessionID: second.id, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: ["task", "bash"], + parts: [{ type: "text", text: "Parent sibling bash" }], + }), + ]) + const report = async (id: string) => ({ + users: await users(id), + children: await Promise.all( + (await Session.children(id)).map(async (child) => ({ id: child.id, users: await users(child.id) })), + ), + }) + await Bun.write( + "runtime-report.json", + JSON.stringify({ first: await report(session.id), second: await report(second.id) }), + ) + return + } + if (mode === "concurrent-sessions") { + const second = await Session.create({ title: "Concurrent second" }) + await Promise.all([ + prompt(["read"], "Concurrent read invocation"), + SessionPrompt.prompt({ + sessionID: second.id, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: ["bash"], + parts: [{ type: "text", text: "Concurrent bash invocation" }], + }), + ]) + await Bun.write( + "runtime-report.json", + JSON.stringify({ + first: { id: session.id, users: await users(session.id) }, + second: { id: second.id, users: await users(second.id) }, + }), + ) + return + } + if (mode === "metadata-registration") { + const { Bus } = (await import(root + "/bus/index.ts")) as typeof import("../../src/bus") + const { MessageV2 } = (await import( + root + "/session/message-v2.ts" + )) as typeof import("../../src/session/message-v2") + const metadata: unknown[] = [] + let immediate = false, + ready = false + const stop = Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => { + const part = event.properties.part + if (part.type !== "tool" || part.tool !== "probe" || part.state.status !== "running") return + await Bun.write("probe-running.json", "true") + if (part.state.metadata?.immediateProbe) immediate = true + if (part.state.metadata?.readyProbe) ready = true + if (part.state.metadata?.immediateProbe || part.state.metadata?.readyProbe) + metadata.push(JSON.parse(JSON.stringify(part))) + }) + await SessionPrompt.prompt({ + sessionID: session.id, + model: { providerID: "fixture", modelID: "fixture" }, + parts: [{ type: "text", text: "Probe existing metadata lifecycle" }], + }) + stop() + await Bun.write("runtime-report.json", JSON.stringify({ immediate, ready, metadata })) + return + } + if (mode === "public-abort-busy") { + const { Bus } = (await import(root + "/bus/index.ts")) as typeof import("../../src/bus") + const { MessageV2 } = (await import( + root + "/session/message-v2.ts" + )) as typeof import("../../src/session/message-v2") + const metadata: unknown[] = [] + const stop = Bus.subscribe(MessageV2.Event.PartUpdated, async (event) => { + const part = event.properties.part + if (part.type === "tool" && part.tool === "delegate" && part.state.status === "running") + await Bun.write("delegate-running.json", "true") + if ( + part.type === "tool" && + part.state.status === "running" && + (part.state.metadata?.facade || part.state.metadata?.abortLeaf) + ) + metadata.push(JSON.parse(JSON.stringify(part))) + }) + const pending = prompt(["delegate", "abortleaf"], "Wait for cancellation").catch((error) => ({ + failure: String(error), + })) + const deadline = Date.now() + 3000 + while (!(await Bun.file("abort-ready.json").exists())) { + if (Date.now() > deadline) throw new Error("Nested public abort leaf never reached readiness barrier") + await Bun.sleep(5) + } + let busy: string | undefined + try { + await prompt(["bash"], "Rejected concurrent prompt") + } catch (error) { + busy = String(error) + } + SessionPrompt.cancel(session.id) + await pending + stop() + await prompt(["read"], "Fresh prompt after cancellation") + await Bun.write("runtime-report.json", JSON.stringify({ busy, users: await users(session.id), metadata })) + return + } + if (mode === "resume-child" || mode === "resume-child-config") { + await prompt(["task", "read", "bash"], "First parent invocation") + await prompt(["task", "read"], "Second narrower parent invocation") + const children = await Session.children(session.id) + await Bun.write( + "runtime-report.json", + JSON.stringify({ + users: await users(session.id), + children: await Promise.all( + children.map(async (child) => ({ id: child.id, users: await users(child.id) })), + ), + }), + ) + return + } + if (mode === "child-widen") { + const child = await Session.create({ parentID: session.id, title: "Widening child" }) + let failure: string | undefined + try { + await SessionPrompt.child( + { + sessionID: child.id, + noReply: true, + model: { providerID: "fixture", modelID: "fixture" }, + toolAllowlist: ["bash"], + parts: [{ type: "text", text: "Do not widen" }], + }, + ["read"], + ) + } catch (error) { + failure = String(error) + } + await Bun.write("runtime-report.json", JSON.stringify({ failure, users: await users(child.id) })) + return + } + throw new Error("Unknown isolated runtime mode: " + mode) + }, + }) +} catch (error) { + console.error(error) + process.exitCode = 1 +} finally { + await Instance.disposeAll() +} diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 23aa512d..9a9eb786 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -14,6 +14,15 @@ export type ToolContext = { * Useful for generating stable relative paths (e.g. path.relative(worktree, absPath)). */ worktree: string + /** Checked request-local work-tool dispatch, available during a model run. + * This controls registered dispatch, not arbitrary plugin host code. + */ + selection?: { + readonly names: readonly string[] + readonly restricted: boolean + assert(name: string): void + execute(name: string, args: unknown): Promise<{ title: string; output: string; metadata: Record }> + } abort: AbortSignal metadata(input: { title?: string; metadata?: { [key: string]: any } }): void ask(input: AskInput): Promise diff --git a/packages/sdk/script/build.ts b/packages/sdk/script/build.ts index 6de0f3d0..4d9e3879 100755 --- a/packages/sdk/script/build.ts +++ b/packages/sdk/script/build.ts @@ -45,6 +45,8 @@ let source = await legacy.text() for (const name of [ "StepFinishPart", "AssistantMessage", + "UserMessage", + "Config", "OutputFormat", "EventSessionStructuredOutput", "EventSessionStructuredOutputRejected", @@ -82,5 +84,6 @@ await legacy.write(source) await $`bun prettier --write src/gen` await $`bun prettier --write src/v2` await $`rm -rf dist` -await $`bun tsc` +// dist was removed deliberately; a surviving composite cache must not skip emit. +await $`bun tsc --build --force` await $`rm openapi.json` diff --git a/packages/sdk/src/gen/types.gen.ts b/packages/sdk/src/gen/types.gen.ts index b44897b7..ee8ff3b7 100644 --- a/packages/sdk/src/gen/types.gen.ts +++ b/packages/sdk/src/gen/types.gen.ts @@ -44,28 +44,7 @@ export type FileDiff = { deletions: number } -export type UserMessage = { - id: string - sessionID: string - role: "user" - time: { - created: number - } - summary?: { - title?: string - body?: string - diffs: Array - } - agent: string - model: { - providerID: string - modelID: string - } - system?: string - tools?: { - [key: string]: boolean - } -} +export type UserMessage = import("../v2/gen/types.gen.js").UserMessage export type ProviderAuthError = { name: "ProviderAuthError" @@ -1143,213 +1122,7 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" -export type Config = { - /** - * JSON schema reference for configuration validation - */ - $schema?: string - /** - * Theme name to use for the interface - */ - theme?: string - keybinds?: KeybindsConfig - /** - * Log level - */ - logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" - /** - * TUI specific settings - */ - tui?: { - /** - * TUI scroll speed - */ - scroll_speed?: number - /** - * Scroll acceleration settings - */ - scroll_acceleration?: { - /** - * Enable scroll acceleration - */ - enabled: boolean - } - /** - * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column - */ - diff_style?: "auto" | "stacked" - } - /** - * Command configuration, see https://aictrl.ai/docs/commands - */ - command?: { - [key: string]: { - template: string - description?: string - agent?: string - model?: string - subtask?: boolean - } - } - watcher?: { - ignore?: Array - } - plugin?: Array - snapshot?: boolean - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ - share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ - autoshare?: boolean - /** - * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications - */ - autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ - disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ - enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ - model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ - small_model?: string - /** - * Custom username to display in conversations instead of system username - */ - username?: string - /** - * @deprecated Use `agent` field instead. - */ - mode?: { - build?: AgentConfig - plan?: AgentConfig - [key: string]: AgentConfig | undefined - } - /** - * Agent configuration, see https://aictrl.ai/docs/agent - */ - agent?: { - plan?: AgentConfig - build?: AgentConfig - general?: AgentConfig - explore?: AgentConfig - [key: string]: AgentConfig | undefined - } - /** - * Custom provider configurations and model overrides - */ - provider?: { - [key: string]: ProviderConfig - } - /** - * MCP (Model Context Protocol) server configurations - */ - mcp?: { - [key: string]: McpLocalConfig | McpRemoteConfig - } - formatter?: - | false - | { - [key: string]: { - disabled?: boolean - command?: Array - environment?: { - [key: string]: string - } - extensions?: Array - } - } - lsp?: - | false - | { - [key: string]: - | { - disabled: true - } - | { - command: Array - extensions?: Array - disabled?: boolean - env?: { - [key: string]: string - } - initialization?: { - [key: string]: unknown - } - } - } - /** - * Additional instruction files or patterns to include - */ - instructions?: Array - layout?: LayoutConfig - permission?: { - edit?: "ask" | "allow" | "deny" - bash?: - | ("ask" | "allow" | "deny") - | { - [key: string]: "ask" | "allow" | "deny" - } - webfetch?: "ask" | "allow" | "deny" - doom_loop?: "ask" | "allow" | "deny" - external_directory?: "ask" | "allow" | "deny" - } - tools?: { - [key: string]: boolean - } - enterprise?: { - /** - * Enterprise URL - */ - url?: string - } - experimental?: { - hook?: { - file_edited?: { - [key: string]: Array<{ - command: Array - environment?: { - [key: string]: string - } - }> - } - session_completed?: Array<{ - command: Array - environment?: { - [key: string]: string - } - }> - } - /** - * Number of retries for chat completions on failure - */ - chatMaxRetries?: number - disable_paste_summary?: boolean - /** - * Enable the batch tool - */ - batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ - openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ - primary_tools?: Array - } -} +export type Config = import("../v2/gen/types.gen.js").Config export type ToolIds = Array diff --git a/packages/sdk/src/v2/gen/types.gen.ts b/packages/sdk/src/v2/gen/types.gen.ts index 2d4a4979..017df7d0 100644 --- a/packages/sdk/src/v2/gen/types.gen.ts +++ b/packages/sdk/src/v2/gen/types.gen.ts @@ -154,6 +154,3297 @@ export type AssistantMessage = { finish?: string } +export type UserMessage = { + id: string + sessionID: string + role: "user" + time: { + created: number + } + format?: OutputFormat + agent: string + model: { + providerID: string + modelID: string + } + system?: string + tools?: { + [key: string]: boolean + } + toolSelection?: { + version: 1 + allowlist: Array | null + } + variant?: string +} + +export type Config = { + $schema?: string + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR" + command?: { + [key: string]: { + template: string + description?: string + agent?: string + model?: string + subtask?: boolean + } + } + skills?: { + paths?: Array + urls?: Array + allow?: Array + } + watcher?: unknown + plugin?: Array + snapshot?: boolean + share?: unknown + autoshare?: unknown + autoupdate?: boolean | "notify" + disabled_providers?: Array + enabled_providers?: Array + model?: string + small_model?: string + default_agent?: string + username?: string + mode?: { + build?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + [key: string]: + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | undefined + } + agent?: { + build?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + general?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + explore?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + title?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + summary?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + compaction?: { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + [key: string]: + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | { + model?: string + variant?: string + temperature?: number + top_p?: number + prompt?: string + tools?: { + [key: string]: boolean + } + disable?: boolean + description?: string + mode?: "subagent" | "primary" | "all" + hidden?: boolean + options?: { + [key: string]: unknown + } + color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" + steps?: number + maxSteps?: number + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + [key: string]: unknown + } + | undefined + } + provider?: { + [key: string]: { + api?: string + name?: string + env?: Array + id?: string + npm?: string + whitelist?: Array + blacklist?: Array + models?: { + [key: string]: { + id?: string + name?: string + family?: string + release_date?: string + attachment?: boolean + reasoning?: boolean + temperature?: boolean + tool_call?: boolean + interleaved?: + | true + | { + field: "reasoning_content" | "reasoning_details" + } + cost?: { + input: number + output: number + cache_read?: number + cache_write?: number + context_over_200k?: { + input: number + output: number + cache_read?: number + cache_write?: number + } + } + limit?: { + context: number + input?: number + output: number + } + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf"> + output: Array<"text" | "audio" | "image" | "video" | "pdf"> + } + experimental?: boolean + status?: "alpha" | "beta" | "deprecated" + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + provider?: { + npm?: string + api?: string + } + variants?: { + [key: string]: { + disabled?: boolean + [key: string]: unknown + } + } + } + } + options?: { + apiKey?: string + baseURL?: string + setCacheKey?: boolean + timeout?: number | false + [key: string]: unknown + } + } + } + mcp?: { + [key: string]: + | { + type: "local" + command: Array + environment?: { + [key: string]: string + } + enabled?: boolean + timeout?: number + } + | { + type: "remote" + url: string + enabled?: boolean + headers?: { + [key: string]: string + } + oauth?: + | { + clientId?: string + clientSecret?: string + scope?: string + } + | false + timeout?: number + } + | { + enabled: boolean + } + } + formatter?: + | false + | { + [key: string]: { + disabled?: boolean + command?: Array + environment?: { + [key: string]: string + } + extensions?: Array + } + } + lsp?: unknown + instructions?: Array + layout?: "auto" | "stretch" + permission?: + | { + __originalKeys?: Array + read?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + edit?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + glob?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + grep?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + list?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + bash?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + task?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + external_directory?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + webfetch?: "ask" | "allow" | "deny" + websearch?: "ask" | "allow" | "deny" + codesearch?: "ask" | "allow" | "deny" + lsp?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + doom_loop?: "ask" | "allow" | "deny" + skill?: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + [key: string]: + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | Array + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | "ask" + | "allow" + | "deny" + | "ask" + | "allow" + | "deny" + | { + [key: string]: "ask" | "allow" | "deny" + } + | undefined + } + | "ask" + | "allow" + | "deny" + tools?: { + [key: string]: boolean + } + tool_allowlist?: Array + enterprise?: unknown + compaction?: { + auto?: boolean + prune?: boolean + reserved?: number + } + experimental?: { + disable_paste_summary?: boolean + batch_tool?: boolean + openTelemetry?: boolean + primary_tools?: Array + continue_loop_on_deny?: boolean + mcp_timeout?: number + } +} + export type EventSessionStructuredOutput = { type: "session.structured_output" properties: { From 9088fa9ac7b9e010dfbab2dc32df62a92b983982 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 21 Oct 2026 04:05:27 +0100 Subject: [PATCH 2/4] ci: provision ripgrep for guarded selector tests --- .github/workflows/ci.yml | 6 ++++++ packages/cli/test/cli/run-tool-selection.test.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cc732ae..483c6c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,5 +31,11 @@ jobs: - name: Typecheck run: bun run typecheck + - name: Install ripgrep test prerequisite + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y ripgrep + rg --version + - name: Run Tests run: bun turbo test --concurrency=100% diff --git a/packages/cli/test/cli/run-tool-selection.test.ts b/packages/cli/test/cli/run-tool-selection.test.ts index 628cb4e8..4889619c 100644 --- a/packages/cli/test/cli/run-tool-selection.test.ts +++ b/packages/cli/test/cli/run-tool-selection.test.ts @@ -58,6 +58,12 @@ const guard = [ ].join("\n") async function run(input: Input) { + const fixturePath = [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter) + const ripgrep = Bun.which("rg", { PATH: fixturePath }) + if (!ripgrep) + throw new Error( + "Selector CLI fixtures require a real ripgrep executable on the child PATH. On Ubuntu install ripgrep, then verify rg --version; fixture network downloads are forbidden.", + ) await using tmp = await tmpdir() const requests: Request[] = [] const calls = [...(input.calls ?? [])] @@ -222,7 +228,7 @@ async function run(input: Input) { const process_ = Bun.spawn(command, { cwd: tmp.path, env: { - PATH: [path.dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"].join(path.delimiter), + PATH: fixturePath, SHELL: "/bin/bash", HOME: home, AICTRL_TEST_HOME: home, @@ -283,6 +289,7 @@ async function run(input: Input) { files[name] = (await file.exists()) ? await file.text() : null } const result = { + ripgrep, deadlineFired, deadlineMs, files, From ede414a0d5b66037225583e6990e3dc9d41edad9 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 21 Oct 2026 13:39:07 +0100 Subject: [PATCH 3/4] fix: preserve tool-selection failures and fixture cleanup --- packages/cli/src/cli/cmd/run.ts | 2 +- .../cli/test/cli/run-tool-selection.test.ts | 375 ++++++++++++------ .../cli/test/session/tool-guidance.test.ts | 261 ++++++++---- 3 files changed, 440 insertions(+), 198 deletions(-) diff --git a/packages/cli/src/cli/cmd/run.ts b/packages/cli/src/cli/cmd/run.ts index 319d7b85..292c0a5a 100644 --- a/packages/cli/src/cli/cmd/run.ts +++ b/packages/cli/src/cli/cmd/run.ts @@ -1213,7 +1213,7 @@ export const RunCommand = cmd({ invocation.phase("bootstrap") if (args.attach) { // This checkout has no receiver/capability contract that can enforce selection. - const configured = await Config.readToolSelection(process.cwd()) + const configured = await invocation.guard(() => Config.readToolSelection(process.cwd())) if (configured !== undefined) { await fail( "Work-tool selection is not supported with --attach: this client cannot verify server-side enforcement. Remove --tools and local tool_allowlist or run locally.", diff --git a/packages/cli/test/cli/run-tool-selection.test.ts b/packages/cli/test/cli/run-tool-selection.test.ts index 4889619c..43768870 100644 --- a/packages/cli/test/cli/run-tool-selection.test.ts +++ b/packages/cli/test/cli/run-tool-selection.test.ts @@ -146,126 +146,242 @@ async function run(input: Input) { ) }, }) - const config = { - enabled_providers: [input.codex ? "openai" : "fixture"], - provider: { - [input.codex ? "openai" : "fixture"]: { - npm: input.codex ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible", - options: { - apiKey: "fixture", - baseURL: `${server.url.origin}/v1`, - ...(input.proxy ? { litellmProxy: true } : {}), + const cleanup: (() => unknown | Promise)[] = [() => server.stop(true)] + let primaryFailure: { error: unknown } | undefined + try { + const config = { + enabled_providers: [input.codex ? "openai" : "fixture"], + provider: { + [input.codex ? "openai" : "fixture"]: { + npm: input.codex ? "@ai-sdk/openai" : "@ai-sdk/openai-compatible", + options: { + apiKey: "fixture", + baseURL: `${server.url.origin}/v1`, + ...(input.proxy ? { litellmProxy: true } : {}), + }, + ...(input.codex + ? {} + : { + models: Object.fromEntries( + ["fixture", "gpt-5"].map((id) => [ + id, + { name: id, tool_call: true, limit: { context: 200000, output: 1000 } }, + ]), + ), + }), }, - ...(input.codex - ? {} - : { - models: Object.fromEntries( - ["fixture", "gpt-5"].map((id) => [ - id, - { name: id, tool_call: true, limit: { context: 200000, output: 1000 } }, - ]), - ), - }), }, - }, - agent: { title: { disable: true } }, - permission: { "*": "allow" }, - ...input.config, - ...(extra ?? {}), - } - const home = path.join(tmp.path, "home") - const preload = path.join(tmp.path, "network-guard.ts") - const ledger = path.join(tmp.path, "network.jsonl") - await fs.mkdir(home) - await Bun.write(preload, guard) - await Bun.write(ledger, "") - const codexLog = path.join(tmp.path, "codex-captures.jsonl") - await Bun.write(codexLog, "") - if (input.codex) - await Bun.write( - path.join(tmp.path, "data/aictrl/auth.json"), - JSON.stringify({ - openai: { type: "oauth", access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 60000 }, - }), - ) - const selected = input.entry ?? entry - const program = path.join(tmp.path, "record-only.ts") - if (input.noReply) - await Bun.write( - program, - [ - "const root = process.env.SELECTOR_RUNTIME_ROOT", - 'const [{ Instance }, { Session }, { SessionPrompt }] = await Promise.all([import(root + "/project/instance.ts"), import(root + "/session/index.ts"), import(root + "/session/prompt.ts")])', - "try { await Instance.provide({ directory: process.cwd(), fn: async () => {", - ' const session = await Session.create({ title: "Record unavailable model" })', - ' const message = await SessionPrompt.prompt({ sessionID: session.id, noReply: true, model: { providerID: "fixture", modelID: "unavailable-model" }, parts: [{ type: "text", text: "Record only; do not run a model." }] })', - " console.log(JSON.stringify({ role: message.info.role, model: message.info.model, saved: (await Session.messages({ sessionID: session.id })).length }))", - "} }) } catch (error) { console.error(String(error)); process.exitCode = 1 }", - "await Instance.disposeAll()", - ].join("\n"), - ) - const command = [ - process.execPath, - "run", - "--preload", - preload, - "--conditions=browser", - ...(input.runtime - ? [path.resolve(import.meta.dir, "../fixture/tool-selection-runtime.ts")] - : input.noReply - ? [program] - : [ - selected, - "run", - "--format", - "json", - "--model", - input.codex ? "openai/gpt-5.2" : "fixture/" + (input.model ?? "fixture"), - ...(input.args ?? []), - ...(input.noMessage ? [] : ["Say fixture complete without tools."]), - ]), - ] - const process_ = Bun.spawn(command, { - cwd: tmp.path, - env: { - PATH: fixturePath, - SHELL: "/bin/bash", - HOME: home, - AICTRL_TEST_HOME: home, - AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(tmp.path, "managed"), - XDG_DATA_HOME: path.join(tmp.path, "data"), - XDG_CONFIG_HOME: path.join(tmp.path, "config"), - XDG_STATE_HOME: path.join(tmp.path, "state"), - XDG_CACHE_HOME: path.join(tmp.path, "cache"), - AICTRL_CONFIG_CONTENT: JSON.stringify(config), - AICTRL_DISABLE_DEFAULT_PLUGINS: "true", - AICTRL_DISABLE_AUTOCOMPACT: "true", - AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS: "5000", - SELECTOR_FIXTURE_ORIGIN: server.url.origin, - SELECTOR_FIXTURE_GUARD_LOG: ledger, - SELECTOR_FIXTURE_ZOD: import.meta.resolve("zod"), - SELECTOR_RUNTIME_ROOT: path.dirname(selected), - SELECTOR_RUNTIME_MODE: input.runtime ?? "", - SELECTOR_RUNTIME_PRELOAD: preload, - SELECTOR_FIXTURE_CODEX: String(!!input.codex), - SELECTOR_FIXTURE_CODEX_LOG: codexLog, - }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }) - const deadlineMs = input.deadlineMs ?? 15000 - let deadlineFired = false - const timeout = setTimeout(() => { - deadlineFired = true - process_.kill("SIGKILL") - }, deadlineMs) - try { - const [stdout, stderr, exit] = await Promise.all([ - new Response(process_.stdout).text(), - new Response(process_.stderr).text(), - process_.exited, - ]) + agent: { title: { disable: true } }, + permission: { "*": "allow" }, + ...input.config, + ...(extra ?? {}), + } + const home = path.join(tmp.path, "home") + const preload = path.join(tmp.path, "network-guard.ts") + const ledger = path.join(tmp.path, "network.jsonl") + await fs.mkdir(home) + await Bun.write(preload, guard) + await Bun.write(ledger, "") + const codexLog = path.join(tmp.path, "codex-captures.jsonl") + await Bun.write(codexLog, "") + if (input.codex) + await Bun.write( + path.join(tmp.path, "data/aictrl/auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-access", refresh: "fixture-refresh", expires: Date.now() + 60000 }, + }), + ) + const selected = input.entry ?? entry + const program = path.join(tmp.path, "record-only.ts") + if (input.noReply) + await Bun.write( + program, + [ + "const root = process.env.SELECTOR_RUNTIME_ROOT", + 'const [{ Instance }, { Session }, { SessionPrompt }] = await Promise.all([import(root + "/project/instance.ts"), import(root + "/session/index.ts"), import(root + "/session/prompt.ts")])', + "try { await Instance.provide({ directory: process.cwd(), fn: async () => {", + ' const session = await Session.create({ title: "Record unavailable model" })', + ' const message = await SessionPrompt.prompt({ sessionID: session.id, noReply: true, model: { providerID: "fixture", modelID: "unavailable-model" }, parts: [{ type: "text", text: "Record only; do not run a model." }] })', + " console.log(JSON.stringify({ role: message.info.role, model: message.info.model, saved: (await Session.messages({ sessionID: session.id })).length }))", + "} }) } catch (error) { console.error(String(error)); process.exitCode = 1 }", + "await Instance.disposeAll()", + ].join("\n"), + ) + const command = [ + process.execPath, + "run", + "--preload", + preload, + "--conditions=browser", + ...(input.runtime + ? [path.resolve(import.meta.dir, "../fixture/tool-selection-runtime.ts")] + : input.noReply + ? [program] + : [ + selected, + "run", + "--format", + "json", + "--model", + input.codex ? "openai/gpt-5.2" : "fixture/" + (input.model ?? "fixture"), + ...(input.args ?? []), + ...(input.noMessage ? [] : ["Say fixture complete without tools."]), + ]), + ] + const process_ = Bun.spawn(command, { + cwd: tmp.path, + env: { + PATH: fixturePath, + SHELL: "/bin/bash", + HOME: home, + AICTRL_TEST_HOME: home, + AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(tmp.path, "managed"), + XDG_DATA_HOME: path.join(tmp.path, "data"), + XDG_CONFIG_HOME: path.join(tmp.path, "config"), + XDG_STATE_HOME: path.join(tmp.path, "state"), + XDG_CACHE_HOME: path.join(tmp.path, "cache"), + AICTRL_CONFIG_CONTENT: JSON.stringify(config), + AICTRL_DISABLE_DEFAULT_PLUGINS: "true", + AICTRL_DISABLE_AUTOCOMPACT: "true", + AICTRL_MODEL_STREAM_IDLE_TIMEOUT_MS: "5000", + SELECTOR_FIXTURE_ORIGIN: server.url.origin, + SELECTOR_FIXTURE_GUARD_LOG: ledger, + SELECTOR_FIXTURE_ZOD: import.meta.resolve("zod"), + SELECTOR_RUNTIME_ROOT: path.dirname(selected), + SELECTOR_RUNTIME_MODE: input.runtime ?? "", + SELECTOR_RUNTIME_PRELOAD: preload, + SELECTOR_FIXTURE_CODEX: String(!!input.codex), + SELECTOR_FIXTURE_CODEX_LOG: codexLog, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: true, + }) + const deadlineMs = input.deadlineMs ?? 15000 + let deadlineFired = false + let timeout: ReturnType | undefined + const output = { stdout: "", stderr: "" } + const readers: ReadableStreamDefaultReader[] = [] + async function read(reader: (typeof readers)[number], name: keyof typeof output) { + const decoder = new TextDecoder() + while (true) { + const chunk = await reader.read() + if (chunk.done) break + output[name] += decoder.decode(chunk.value, { stream: true }) + } + output[name] += decoder.decode() + return output[name] + } + function signalGroup(signal: NodeJS.Signals | 0) { + try { + process.kill(-process_.pid, signal) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false + throw error + } + } + async function waitGroupGone(milliseconds: number) { + const until = Date.now() + milliseconds + while (signalGroup(0)) { + if (Date.now() >= until) return false + await Bun.sleep(10) + } + return true + } + let stopping: Promise | undefined + function stopGroup() { + return (stopping ??= (async () => { + const errors: unknown[] = [] + let gone = false + try { + gone = !signalGroup("SIGTERM") || (await waitGroupGone(200)) + } catch (error) { + errors.push(error) + } + if (!gone) { + try { + signalGroup("SIGKILL") + } catch (error) { + errors.push(error) + } + try { + if (!(await waitGroupGone(2000))) throw new Error("Owned selector process group remains after SIGKILL") + } catch (error) { + errors.push(error) + } + } + if (errors.length) throw new AggregateError(errors, "Owned selector group termination failed") + })()) + } + async function bounded(task: Promise, milliseconds: number, message: string): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + task, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), milliseconds) + }), + ]) + } finally { + clearTimeout(timer) + } + } + const drains: Promise[] = [] + cleanup.push(async () => { + clearTimeout(timeout) + const errors: unknown[] = [] + for (const operation of [ + () => stopGroup(), + () => bounded(process_.exited, 1000, "Owned selector child was not reaped after termination"), + ...readers.map((reader) => () => bounded(reader.cancel(), 500, "Owned selector pipe cancellation timed out")), + () => bounded(Promise.allSettled(drains), 500, "Owned selector output did not settle after cancellation"), + ]) { + try { + await operation() + } catch (error) { + errors.push(error) + } + } + for (const reader of readers) { + try { + reader.releaseLock() + } catch (error) { + errors.push(error) + } + } + if (errors.length) throw new AggregateError(errors, "Owned selector process cleanup failed") + }) + readers.push(process_.stdout.getReader()) + readers.push(process_.stderr.getReader()) + drains.push(read(readers[0], "stdout"), read(readers[1], "stderr")) + const exited = process_.exited.then(async (exit) => { + if (signalGroup(0)) { + try { + await stopGroup() + } catch (error) { + if (!deadlineFired) throw error + } + if (!deadlineFired) throw new Error("Owned selector descendants outlived the fixture child") + } + return exit + }) + const deadlineFailure = () => + new Error( + `${input.label}: child deadline exceeded after ${deadlineMs}ms; providerRequests=${requests.length}; stdout=${output.stdout.slice(-2000)}; stderr=${output.stderr.slice(-2000)}`, + ) + const deadline = new Promise((_, reject) => { + timeout = setTimeout(() => { + deadlineFired = true + // The deadline also interrupts collection when descendants retain the pipes. + void stopGroup().then( + () => reject(deadlineFailure()), + () => reject(deadlineFailure()), + ) + }, deadlineMs) + }) + const [stdout, stderr, exit] = await Promise.race([Promise.all([drains[0], drains[1], exited]), deadline]) clearTimeout(timeout) const network = (await Bun.file(ledger).text()) .split("\n") @@ -315,10 +431,27 @@ async function run(input: Input) { ) expect(network.filter((request) => !request.allowed)).toEqual([]) return result + } catch (error) { + primaryFailure = { error } + throw error } finally { - clearTimeout(timeout) - process_.kill("SIGKILL") - server.stop(true) + const errors: unknown[] = [] + for (const dispose of cleanup.reverse()) { + try { + await dispose() + } catch (error) { + errors.push(error) + } + } + if (errors.length) { + throw new AggregateError( + [...(primaryFailure ? [primaryFailure.error] : []), ...errors], + primaryFailure + ? `${input.label}: ${String(primaryFailure.error)}; fixture cleanup also failed` + : `${input.label}: fixture cleanup failed`, + primaryFailure ? { cause: primaryFailure.error } : undefined, + ) + } } } diff --git a/packages/cli/test/session/tool-guidance.test.ts b/packages/cli/test/session/tool-guidance.test.ts index 1c771d32..ea284b45 100644 --- a/packages/cli/test/session/tool-guidance.test.ts +++ b/packages/cli/test/session/tool-guidance.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test" +import { afterAll, beforeAll, describe, expect, spyOn, test, type Mock } from "bun:test" import path from "node:path" // Install before runtime imports: every model request is local or an intercepted @@ -149,66 +149,83 @@ async function fixture( } = {}, fn: (context: Fixture) => Promise, ) { - transport.captures = [] - transport.codex = options.codex ?? false const providerID = options.gpt || options.codex ? "openai" : "fixture" const modelID = providerID === "openai" ? "gpt-5.2" : "fixture" - const server = Bun.serve({ - hostname: "127.0.0.1", - port: 0, - async fetch(request) { - transport.captures.push((await request.json()) as Wire) - if (options.call && transport.captures.length === 1) { - const call = options.call() - return sse([ - { - id: "fixture-call", - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { - role: "assistant", - tool_calls: [ - { - index: 0, - id: "runtime-call", - type: "function", - function: { name: call.name, arguments: JSON.stringify(call.arguments) }, - }, - ], - }, - }, - ], - }, - { - id: "fixture-call", - object: "chat.completion.chunk", - choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], - }, - ]) - } - return providerID === "openai" ? responses() : chat() - }, - }) - transport.origin = server.url.origin - const previousAuth = options.codex ? await Auth.get("openai") : undefined - if (options.codex) - await Auth.set("openai", { - type: "oauth", - access: "fixture-access", - refresh: "fixture-refresh", - expires: Date.now() + 60_000, - }) + let server: Bun.Server | undefined + let previousAuth: { value: Awaited> } | undefined + let authSwapAttempted = false const original = LLM.stream let last: StreamInput | undefined let persisted: unknown const persistedCheckpoints: unknown[] = [] - const spy = spyOn(LLM, "stream").mockImplementation(async (input) => { - last = input - return original(input) - }) + let spy: Mock | undefined + let primaryFailure: { error: unknown } | undefined + const cleanupErrors: Error[] = [] + const diagnosticErrors: Error[] = [] + async function attempt(errors: Error[], operation: string, fn: () => unknown | Promise) { + try { + await fn() + } catch (cause) { + errors.push(new Error(operation, { cause })) + } + } try { + transport.captures = [] + transport.codex = options.codex ?? false + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + transport.captures.push((await request.json()) as Wire) + if (options.call && transport.captures.length === 1) { + const call = options.call() + return sse([ + { + id: "fixture-call", + object: "chat.completion.chunk", + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "runtime-call", + type: "function", + function: { name: call.name, arguments: JSON.stringify(call.arguments) }, + }, + ], + }, + }, + ], + }, + { + id: "fixture-call", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }, + ]) + } + return providerID === "openai" ? responses() : chat() + }, + }) + transport.origin = server.url.origin + if (options.codex) { + previousAuth = { value: await Auth.get("openai") } + authSwapAttempted = true + await Auth.set("openai", { + type: "oauth", + access: "fixture-access", + refresh: "fixture-refresh", + expires: Date.now() + 60_000, + }) + } + spy = spyOn(LLM, "stream") + spy.mockImplementation(async (input) => { + last = input + return original(input) + }) await using tmp = await tmpdir({ init: options.init, config: { @@ -305,37 +322,62 @@ async function fixture( try { await fn(context) } finally { - persisted = await context.messages() + await attempt(diagnosticErrors, "Failed to capture persisted fixture messages", async () => { + persisted = await context.messages() + }) } }, }) + } catch (error) { + primaryFailure = { error } } finally { + const acquiredSpy = spy + if (acquiredSpy) await attempt(cleanupErrors, "Failed to restore LLM.stream", () => acquiredSpy.mockRestore()) + const acquiredServer = server + if (acquiredServer) await attempt(cleanupErrors, "Failed to stop fixture server", () => acquiredServer.stop(true)) + await attempt(cleanupErrors, "Failed to reset fixture transport", () => { + transport.origin = "" + transport.codex = false + }) + if (authSwapAttempted && previousAuth) { + const snapshot = previousAuth.value + await attempt(cleanupErrors, "Failed to restore fixture auth", async () => { + if (snapshot) await Auth.set("openai", snapshot) + else await Auth.remove("openai") + }) + } const output = process.env.GUIDANCE_CAPTURE_DIR if (output) - await Bun.write( - path.join(output, `${label}.json`), - JSON.stringify( - { - label, - fixtureOptions: options, - codex: options.codex ?? false, - requests: transport.captures, - persisted, - persistedCheckpoints, - }, - null, - 2, + await attempt(diagnosticErrors, "Failed to write fixture capture", () => + Bun.write( + path.join(output, `${label}.json`), + JSON.stringify( + { + label, + fixtureOptions: options, + codex: options.codex ?? false, + requests: transport.captures, + persisted, + persistedCheckpoints, + }, + null, + 2, + ), ), ) - spy.mockRestore() - server.stop(true) - transport.origin = "" - transport.codex = false - if (options.codex) { - if (previousAuth) await Auth.set("openai", previousAuth) - else await Auth.remove("openai") - } } + if (cleanupErrors.length || diagnosticErrors.length) { + throw new AggregateError( + [ + ...(primaryFailure ? [primaryFailure.error] : []), + ...(cleanupErrors.length ? [new AggregateError(cleanupErrors, "Fixture cleanup failed")] : []), + ...(diagnosticErrors.length ? [new AggregateError(diagnosticErrors, "Fixture diagnostics failed")] : []), + ], + `Tool guidance fixture ${label} failed`, + primaryFailure ? { cause: primaryFailure.error } : undefined, + ) + } + if (primaryFailure) throw primaryFailure.error } beforeAll(() => HeadlessRun.enable()) @@ -344,6 +386,73 @@ afterAll(() => { globalThis.fetch = originalFetch }) +describe("guidance fixture teardown", () => { + for (const callbackFails of [false, true]) { + test(`capture failure restores fixture resources${callbackFails ? " and preserves the callback error" : ""}`, async () => { + await using tmp = await tmpdir() + const output = path.join(tmp.path, "capture-path-conflict") + await Bun.write(output, "owned regular file, not a capture directory") + const previousCapture = process.env.GUIDANCE_CAPTURE_DIR + const previousStream = LLM.stream + const previousAuth = await Auth.get("openai") + const primary = new Error("Owned callback failure") + let origin = "" + let failure: unknown + process.env.GUIDANCE_CAPTURE_DIR = output + try { + await fixture("teardown-capture-conflict", { codex: true }, async () => { + origin = transport.origin + if (callbackFails) throw primary + }) + } catch (error) { + failure = error + } finally { + if (previousCapture === undefined) delete process.env.GUIDANCE_CAPTURE_DIR + else process.env.GUIDANCE_CAPTURE_DIR = previousCapture + } + expect(failure).toBeInstanceOf(AggregateError) + const aggregate = failure as AggregateError + if (callbackFails) { + expect(aggregate.cause).toBe(primary) + expect(aggregate.errors[0]).toBe(primary) + } else expect(aggregate.cause).toBeUndefined() + const diagnostic = aggregate.errors.find( + (error) => error instanceof AggregateError && error.message === "Fixture diagnostics failed", + ) as AggregateError + expect(diagnostic).toBeInstanceOf(AggregateError) + expect(diagnostic.errors.some((error) => error.message === "Failed to write fixture capture")).toBe(true) + expect(LLM.stream).toBe(previousStream) + expect(transport.origin).toBe("") + expect(transport.codex).toBe(false) + expect(await Auth.get("openai")).toEqual(previousAuth) + expect(origin).toStartWith("http://127.0.0.1:") + await expect(originalFetch(origin)).rejects.toThrow() + }) + } + + test("successful capture is written after fixture resources are restored", async () => { + await using tmp = await tmpdir() + const previousCapture = process.env.GUIDANCE_CAPTURE_DIR + const previousStream = LLM.stream + const previousAuth = await Auth.get("openai") + process.env.GUIDANCE_CAPTURE_DIR = tmp.path + try { + await fixture("teardown-capture-success", { codex: true }, async () => {}) + const capture = await Bun.file(path.join(tmp.path, "teardown-capture-success.json")).json() + expect(capture.label).toBe("teardown-capture-success") + expect(capture.codex).toBe(true) + expect(capture.requests).toEqual([]) + expect(LLM.stream).toBe(previousStream) + expect(transport.origin).toBe("") + expect(transport.codex).toBe(false) + expect(await Auth.get("openai")).toEqual(previousAuth) + } finally { + if (previousCapture === undefined) delete process.env.GUIDANCE_CAPTURE_DIR + else process.env.GUIDANCE_CAPTURE_DIR = previousCapture + } + }) +}) + describe("actual request tool guidance (#244)", () => { test("full built-in catalog retains useful peer guidance", async () => { await fixture("full", {}, async (context) => { From 46f98d5993ad56ec033506d5e66f5da85ccc6179 Mon Sep 17 00:00:00 2001 From: Bulat Yapparov Date: Wed, 21 Oct 2026 14:48:32 +0100 Subject: [PATCH 4/4] fix: align selection contracts and reject cleanup false positives --- packages/cli/README.md | 6 ++++-- packages/cli/test/cli/run-tool-selection.test.ts | 13 +++++++++++-- packages/plugin/src/tool.ts | 11 ++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 812bb0f9..367fcf63 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -65,8 +65,10 @@ eligible native Task tool to be selected, without adding permission allow rules. are protocol/control tools, outside the work allowlist. Schema mode still needs permission to call `StructuredOutput`; selecting work tools never grants that permission. An empty work allowlist can therefore coexist with an explicitly -permitted StructuredOutput protocol tool. Custom tools with those names are still -work tools; protocol identity belongs to the harness-created definition. +permitted StructuredOutput protocol tool. Custom tools with protocol-like names +remain work tools, except that an active output schema reserves the +`StructuredOutput` wire name for the harness-created protocol tool. A custom +`StructuredOutput` is selectable as work only outside schema mode. Custom tools can use the optional typed `context.selection` facade to inspect `names`/`restricted`, assert availability, or call diff --git a/packages/cli/test/cli/run-tool-selection.test.ts b/packages/cli/test/cli/run-tool-selection.test.ts index 43768870..39da8682 100644 --- a/packages/cli/test/cli/run-tool-selection.test.ts +++ b/packages/cli/test/cli/run-tool-selection.test.ts @@ -1793,7 +1793,16 @@ export default {description:"Actual permitted runtime failure",args:{},execute:a }) test("forced CLI fixture deadline cannot pass as an expected negative exit", async () => { - await expect(run({ label: "forced-cli-deadline", runtime: "deadline-hang", deadlineMs: 20 })).rejects.toThrow( - /child deadline exceeded after 20ms; providerRequests=0/, + const failure: unknown = await run({ label: "forced-cli-deadline", runtime: "deadline-hang", deadlineMs: 20 }).catch( + (error: unknown) => error, ) + expect(failure).toBeInstanceOf(Error) + expect(failure).not.toBeInstanceOf(AggregateError) + expect(failure).toHaveProperty( + "message", + expect.stringMatching(/^forced-cli-deadline: child deadline exceeded after 20ms; providerRequests=0;/), + ) + expect(failure).not.toHaveProperty("message", expect.stringContaining("fixture cleanup")) + expect(failure).not.toHaveProperty("cause") + expect(failure).not.toHaveProperty("errors") }, 20000) diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index 9a9eb786..dc150e4c 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -1,3 +1,4 @@ +import type { CommandInput } from "@aictrl/sdk/v2" import { z } from "zod" export type ToolContext = { @@ -21,7 +22,15 @@ export type ToolContext = { readonly names: readonly string[] readonly restricted: boolean assert(name: string): void - execute(name: string, args: unknown): Promise<{ title: string; output: string; metadata: Record }> + execute( + name: string, + args: unknown, + ): Promise<{ + title: string + output: string + metadata: Record + attachments?: Omit[number], { type: "file" }>, "id">[] + }> } abort: AbortSignal metadata(input: { title?: string; metadata?: { [key: string]: any } }): void