@uchibeke/gallivant 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/harness.mjs
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// Harness adapters. Each one knows four things about its CLI: how to start a session, how to resume one, where the
|
|
2
|
+
// session id comes from, and how to read the outcome of a run from its JSONL output. Flags live here, not in config,
|
|
3
|
+
// so an operator never has to know them and an upgrade changes one file.
|
|
4
|
+
//
|
|
5
|
+
// Argument/session contracts checked against Claude Code 2.1.263 and Codex 0.158.0. Codex config overrides work for both `exec` and
|
|
6
|
+
// `exec resume`, whose flags differ. The gemini, opencode
|
|
7
|
+
// and goose session contracts were checked on 2026-09-25 against 0.59.0, 1.18.31 and 1.50.1.
|
|
8
|
+
// Coding/push evidence and current blocks are separate: docs/audit/harness-live-validation.md.
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { runtimeEnvironment } from "./runtime-environment.mjs";
|
|
12
|
+
import { codexPreparation } from "./preparation_codex.mjs";
|
|
13
|
+
import { agentsPreparation } from "./preparation_agents.mjs";
|
|
14
|
+
import { CLOUD_DISPATCH_UNAVAILABLE } from "./codex_cloud_config.mjs";
|
|
15
|
+
import { EXECUTION_TRANSPORTS } from "./execution_contract.mjs";
|
|
16
|
+
|
|
17
|
+
export const MIN_VERSIONS = { claude: "2.1.263", codex: "0.158.0", gemini: "0.59.0", opencode: "1.18.31", goose: "1.50.1" };
|
|
18
|
+
|
|
19
|
+
// workspace-write protects .git too: add, commit and push's local tracking updates need approval. `never`
|
|
20
|
+
// stranded real runs at index.lock and .git/config; saved allow rules only hid the problem for some commands.
|
|
21
|
+
// Keep the sandbox and route escalations to Codex's reviewer, so headless runs need no operator click.
|
|
22
|
+
const CODEX_OVERRIDES = [
|
|
23
|
+
"-c", 'approval_policy="on-request"',
|
|
24
|
+
"-c", 'approvals_reviewer="auto_review"',
|
|
25
|
+
"-c", 'sandbox_mode="workspace-write"',
|
|
26
|
+
"-c", "sandbox_workspace_write.network_access=true",
|
|
27
|
+
"--skip-git-repo-check",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// stream-json is real JSONL on both: gemini emits init/message/result, goose emits message/complete. Neither has
|
|
31
|
+
// a spend cap flag, so the wall-clock timeout is what bounds them, as with codex.
|
|
32
|
+
const GEMINI_COMMON = ["--output-format", "stream-json", "-p"];
|
|
33
|
+
const GOOSE_COMMON = ["--output-format", "stream-json"];
|
|
34
|
+
|
|
35
|
+
const claudeCommon = ({ budget, settings }) => [
|
|
36
|
+
"-p", "--permission-mode", "auto", "--permission-prompts", "none",
|
|
37
|
+
"--max-budget-usd", String(budget), "--settings", settings,
|
|
38
|
+
"--output-format", "stream-json", "--verbose",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** The shape claude, codex and gemini all spell a session id with. */
|
|
42
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
43
|
+
|
|
44
|
+
/** Last non-empty parsed JSONL line matching `pred`, scanning from the end. */
|
|
45
|
+
const findLast = (lines, pred) => {
|
|
46
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
47
|
+
const ev = parse(lines[i]);
|
|
48
|
+
if (ev && pred(ev)) return ev;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
const parse = l => { try { return JSON.parse(l); } catch { return null; } };
|
|
53
|
+
const events = lines => lines.map(parse).filter(Boolean);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why each denied call was denied, keyed by the tool_use_id that names the call.
|
|
57
|
+
*
|
|
58
|
+
* `result.permission_denials` says WHAT was denied (tool name and the whole input) and never says why. The reason is
|
|
59
|
+
* written somewhere else in the same stream: the hook denies, Claude turns the denial into the tool's result, and that
|
|
60
|
+
* `tool_result` carries the guardrail's own sentence. Both halves carry the same `tool_use_id`, so they join exactly
|
|
61
|
+
* and need no timestamp guessing.
|
|
62
|
+
*
|
|
63
|
+
* This is the whole fix for a run where APort blocked `grep -n ... web/src/lib/products.config.ts` on the pattern
|
|
64
|
+
* "Access to sensitive configuration files". The audit line names the pattern and not the path; the denial list names
|
|
65
|
+
* the path and not the pattern. An operator reading either one alone cannot tell a correct block from a broad one
|
|
66
|
+
* catching a legitimate read of a project source file, which is what that one was.
|
|
67
|
+
*/
|
|
68
|
+
const denialReasons = lines => {
|
|
69
|
+
const by = new Map();
|
|
70
|
+
for (const ev of events(lines)) {
|
|
71
|
+
if (ev.type !== "user") continue;
|
|
72
|
+
for (const b of ev.message?.content || []) {
|
|
73
|
+
if (b?.type !== "tool_result" || !b.is_error || !b.tool_use_id) continue;
|
|
74
|
+
const text = typeof b.content === "string" ? b.content : Array.isArray(b.content) ? b.content.map(c => (typeof c === "string" ? c : c?.text || "")).join(" ") : "";
|
|
75
|
+
// Only a guardrail refusal, never an ordinary tool error: a failing command's stderr is not a reason a call was
|
|
76
|
+
// denied, and putting it on a denial line would say the guardrail said something it did not.
|
|
77
|
+
const blocked = (ev.tool_result_meta || []).some(m => m?.non_execution_kind === "permission-rule") || /^APort denied\b/.test(text.trim());
|
|
78
|
+
if (blocked && text.trim()) by.set(String(b.tool_use_id), text.trim().replace(/\s+/g, " ").slice(0, 300));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return by;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// What an adapter has to do, and who is in. The loop cannot work without four things: start a run
|
|
85
|
+
// non-interactively, resume THAT session by id (not "the latest"), emit a machine-readable stream the run's
|
|
86
|
+
// outcome can be read from, and report its own session id so the PR body can carry it.
|
|
87
|
+
//
|
|
88
|
+
// Five harnesses meet that contract and are implemented below. Each round-trip was run on this machine on
|
|
89
|
+
// 2026-09-25 the same way: write a number in one process, resume that session id in a second process, assert the
|
|
90
|
+
// number came back. The versions seen were Gemini CLI 0.59.0, opencode 1.18.31 and goose 1.50.1.
|
|
91
|
+
//
|
|
92
|
+
// cursor-agent NOT ADDED, and the blocker is a login, not a missing capability. It has every flag this needs:
|
|
93
|
+
// `--print --output-format stream-json`, `--resume <chatId>`, and `create-chat` prints a UUID to
|
|
94
|
+
// pre-assign. It is simply not signed in here: a headless run draws the interactive "Press any key
|
|
95
|
+
// to sign in" screen and exits non-zero, so the event stream could not be observed. Adding it would
|
|
96
|
+
// mean guessing the shape of a stream nobody has seen. Sign in, capture one run, then write it.
|
|
97
|
+
//
|
|
98
|
+
// Adding one is a new entry in this object and its own tests: `HARNESSES` is the keys of this object, so there is no
|
|
99
|
+
// second list to keep in step. Nothing else names a harness. `signIn.fix` is the command the operator runs when the
|
|
100
|
+
// check fails, kept beside the check rather than in `doctor`, which used to tell three of these to run `codex login`.
|
|
101
|
+
export const adapters = {
|
|
102
|
+
claude: {
|
|
103
|
+
name: "claude",
|
|
104
|
+
preparation: {
|
|
105
|
+
version: 1, boundary: "claude-no-tools-v1", authority: "text-only",
|
|
106
|
+
// Built-ins, MCP discovery and commands all stay disabled. Flags precede
|
|
107
|
+
// the positional prompt so variadic tool/config options cannot swallow it.
|
|
108
|
+
render: spec => ["--restricted", "--tools", "", "--disallowedTools", "*", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", ...spec.args],
|
|
109
|
+
},
|
|
110
|
+
preparationEnv: ["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", "CLAUDE_CODE_SKIP_BEDROCK_AUTH", "CLAUDE_CODE_SKIP_VERTEX_AUTH", "CLAUDE_CODE_SKIP_FOUNDRY_AUTH", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"],
|
|
111
|
+
newSession: () => randomUUID(),
|
|
112
|
+
// `--session-id` takes a UUID and claude refuses anything else, so that is exactly what a block may carry.
|
|
113
|
+
isSession: s => UUID.test(s),
|
|
114
|
+
start: ({ session, prompt, budget, settings }) => ["--session-id", session, ...claudeCommon({ budget, settings }), prompt],
|
|
115
|
+
resume: ({ session, prompt, budget, settings }) => ["--resume", session, ...claudeCommon({ budget, settings }), prompt],
|
|
116
|
+
env: ({ configDir }) => (configDir ? { APORT_CLAUDE_CODE_CONFIG_DIR: configDir } : {}),
|
|
117
|
+
sessionFrom: lines => findLast(lines, ev => ev.type === "system" && ev.subtype === "init")?.session_id ?? null,
|
|
118
|
+
outcome(lines) {
|
|
119
|
+
const result = findLast(lines, ev => ev.type === "result");
|
|
120
|
+
const lastText = findLast(lines, ev => ev.type === "assistant")?.message?.content?.filter(c => c.type === "text").map(c => c.text).join("\n") ?? null;
|
|
121
|
+
if (!result) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no result event" };
|
|
122
|
+
const reasons = denialReasons(lines);
|
|
123
|
+
return {
|
|
124
|
+
ok: !result.is_error,
|
|
125
|
+
finished: true,
|
|
126
|
+
lastMessage: typeof result.result === "string" ? result.result : lastText,
|
|
127
|
+
// The reason is appended rather than replacing anything, so a denial whose reason is not in the stream reads
|
|
128
|
+
// exactly as it did before and nothing about the line pretends to know more than the log recorded.
|
|
129
|
+
denials: (result.permission_denials || []).map(d => {
|
|
130
|
+
const why = d.tool_use_id ? reasons.get(String(d.tool_use_id)) : null;
|
|
131
|
+
return `${d.tool_name}: ${JSON.stringify(d.tool_input).slice(0, 200)}${why ? `\n ${why}` : ""}`;
|
|
132
|
+
}),
|
|
133
|
+
cost: result.total_cost_usd ?? null,
|
|
134
|
+
error: result.is_error ? (result.result || result.subtype || "error") : null,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
signIn: { scope: "local-sign-in", args: ["auth", "status"], ok: (code, out) => code === 0 && /"loggedIn":\s*true/.test(out), fix: "claude auth login" },
|
|
138
|
+
version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
|
|
139
|
+
// --add-dir grants tool access to a directory; it does not widen what may be written, because writes stay
|
|
140
|
+
// inside the sandbox declared in the Gallivant settings file that --settings points at.
|
|
141
|
+
context: { render: paths => paths.flatMap(p => ["--add-dir", p]) },
|
|
142
|
+
attach: ({ session }) => `claude --resume ${session}`,
|
|
143
|
+
},
|
|
144
|
+
codex: {
|
|
145
|
+
name: "codex",
|
|
146
|
+
preparation: codexPreparation,
|
|
147
|
+
preparationEnv: ["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "CODEX_API_KEY", "CODEX_CA_CERTIFICATE"],
|
|
148
|
+
newSession: () => null,
|
|
149
|
+
// Thread ids read off `thread.started` are UUIDs.
|
|
150
|
+
isSession: s => UUID.test(s),
|
|
151
|
+
start: ({ prompt }) => ["exec", "--json", ...CODEX_OVERRIDES, prompt],
|
|
152
|
+
resume: ({ session, prompt }) => ["exec", "resume", session, "--json", ...CODEX_OVERRIDES, prompt],
|
|
153
|
+
env: () => ({}),
|
|
154
|
+
sessionFrom: lines => events(lines).find(ev => ev.type === "thread.started")?.thread_id ?? null,
|
|
155
|
+
outcome(lines) {
|
|
156
|
+
const evs = events(lines);
|
|
157
|
+
const failed = evs.findLast(ev => ev.type === "turn.failed" || ev.type === "error");
|
|
158
|
+
const completed = evs.findLast(ev => ev.type === "turn.completed");
|
|
159
|
+
const lastText = evs.findLast(ev => ev.type === "item.completed" && ev.item?.type === "agent_message")?.item?.text ?? null;
|
|
160
|
+
if (!completed && !failed) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no turn.completed event" };
|
|
161
|
+
return { ok: !failed, finished: true, lastMessage: lastText, denials: [], cost: null, error: failed ? (failed.error?.message || failed.message || "turn failed") : null };
|
|
162
|
+
},
|
|
163
|
+
signIn: { scope: "local-sign-in", args: ["login", "status"], ok: code => code === 0, fix: "codex login" },
|
|
164
|
+
version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
|
|
165
|
+
// Codex cannot grant read on ONE directory. Checked against 0.157.0 on 2026-09-25:
|
|
166
|
+
// --add-dir <DIR> its own help calls these "Additional directories that should be WRITABLE alongside the
|
|
167
|
+
// primary workspace". Write, not read.
|
|
168
|
+
// -c sandbox_permissions=["disk-full-read-access"] the only value the help documents, and it is the whole
|
|
169
|
+
// disk. A per-path spelling (disk-read-access=/x) parses and is then dropped: the run
|
|
170
|
+
// prints "ignoring 1 unrecognized configuration setting" and reads nothing extra.
|
|
171
|
+
// -c sandbox_workspace_write.readable_roots=[...] reported as ignored by name in that same warning.
|
|
172
|
+
// Full disk read to get one directory is a wider grant than the operator asked for, and ignoring the setting
|
|
173
|
+
// would leave them believing a run can read something it cannot. So the run is refused instead.
|
|
174
|
+
context: { refuse: "Codex has no per-directory read grant: --add-dir makes a directory writable, and the only read permission it documents is disk-full-read-access, the whole disk. Drop context_paths for this brief, or give it to a harness that can (claude)." },
|
|
175
|
+
attach: ({ session }) => `codex resume ${session}`,
|
|
176
|
+
},
|
|
177
|
+
// Gemini CLI. `--skip-trust` is mandatory for a daemon: without it a headless run in an untrusted directory
|
|
178
|
+
// refuses with "not running in a trusted directory" and the run is wasted. GEMINI_CLI_TRUST_WORKSPACE=true is
|
|
179
|
+
// the env equivalent; the flag is passed as well so the argv alone is enough to reproduce a run by hand.
|
|
180
|
+
//
|
|
181
|
+
// On --resume: the help text says it takes "latest" or an index number. That is incomplete. A session UUID
|
|
182
|
+
// works, and it is the only form that resumes a SPECIFIC session, which is the whole contract here. Verified by
|
|
183
|
+
// round-trip: a number written under a generated UUID in one process came back when a second process resumed
|
|
184
|
+
// that id. Do not "fix" this to an index; an index is relative to the session list and moves under the daemon.
|
|
185
|
+
gemini: {
|
|
186
|
+
name: "gemini",
|
|
187
|
+
preparationEnv: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_MODEL", "GOOGLE_GENAI_USE_VERTEXAI", "GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
|
|
188
|
+
newSession: () => randomUUID(),
|
|
189
|
+
isSession: s => UUID.test(s),
|
|
190
|
+
start: ({ session, prompt }) => ["--skip-trust", "--session-id", session, ...GEMINI_COMMON, prompt],
|
|
191
|
+
resume: ({ session, prompt }) => ["--skip-trust", "--resume", session, ...GEMINI_COMMON, prompt],
|
|
192
|
+
env: () => ({ GEMINI_CLI_TRUST_WORKSPACE: "true" }),
|
|
193
|
+
sessionFrom: lines => findLast(lines, ev => ev.type === "init")?.session_id ?? null,
|
|
194
|
+
outcome(lines) {
|
|
195
|
+
const result = findLast(lines, ev => ev.type === "result");
|
|
196
|
+
const lastText = findLast(lines, ev => ev.type === "message" && ev.role === "assistant")?.content ?? null;
|
|
197
|
+
if (!result) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no result event" };
|
|
198
|
+
const ok = result.status === "success";
|
|
199
|
+
return { ok, finished: true, lastMessage: lastText, denials: [], cost: null, error: ok ? null : (result.error?.message || result.status || "error") };
|
|
200
|
+
},
|
|
201
|
+
// Gemini CLI has no `auth status` subcommand: credentials come from a stored OAuth login or GEMINI_API_KEY
|
|
202
|
+
// and there is no command that reports on them without spending a call. So this only proves the binary runs.
|
|
203
|
+
// A run that is not authenticated fails on its first model call and shows up as a failed run, not as a
|
|
204
|
+
// failed probe. That is weaker than claude's or codex's sign-in check; `doctor` cannot pre-empt it.
|
|
205
|
+
signIn: { scope: "local-binary", args: ["--version"], ok: code => code === 0, fix: "gemini, then /auth, or set GEMINI_API_KEY" },
|
|
206
|
+
version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
|
|
207
|
+
// --include-directories looks like the flag and is not. It adds a directory to the WORKSPACE, and Gemini's
|
|
208
|
+
// own file-system tool reference says every file tool, write_file included, operates within the workspace
|
|
209
|
+
// root. The flag grants write as well as read, which is the one thing this setting must not do, and there is
|
|
210
|
+
// no read-only spelling. Refuse rather than escalate.
|
|
211
|
+
context: { refuse: "Gemini CLI has no read-only directory flag: --include-directories adds to the workspace, which is its write boundary as well as its read boundary. Drop context_paths for this brief, or give it to a harness that can (claude)." },
|
|
212
|
+
attach: ({ session }) => `gemini --resume ${session}`,
|
|
213
|
+
},
|
|
214
|
+
// opencode. `run` is the headless form and `-s <id>` continues a named session.
|
|
215
|
+
//
|
|
216
|
+
// A session id cannot be pre-assigned: ids look like `ses_f25fcd907ffesl1i92jsR2lh9I`, they are minted by the
|
|
217
|
+
// server, and `opencode session` offers only `list` and `delete`, no `create`. So newSession() returns null and
|
|
218
|
+
// the id is read back off the run, exactly as codex does. Every JSONL line carries `sessionID`, so the first
|
|
219
|
+
// line is enough.
|
|
220
|
+
//
|
|
221
|
+
// The earlier note that this was "gated on an APort target" no longer blocks anything: opencode run is plain
|
|
222
|
+
// stdout JSONL and the gate the daemon applies is its own deliverable check, which does not care which CLI
|
|
223
|
+
// produced the run. Nothing in the APort hook path is opencode-specific.
|
|
224
|
+
opencode: {
|
|
225
|
+
name: "opencode",
|
|
226
|
+
preparationEnv: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "DEEPSEEK_API_KEY", "MISTRAL_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT"],
|
|
227
|
+
newSession: () => null,
|
|
228
|
+
// Server-minted, and they look like `ses_f25fcd907ffesl1i92jsR2lh9I`: the prefix plus a case-sensitive token.
|
|
229
|
+
isSession: s => /^ses_[A-Za-z0-9]{8,64}$/.test(s),
|
|
230
|
+
start: ({ prompt }) => ["run", "--format", "json", prompt],
|
|
231
|
+
resume: ({ session, prompt }) => ["run", "--session", session, "--format", "json", prompt],
|
|
232
|
+
env: () => ({}),
|
|
233
|
+
sessionFrom: lines => events(lines).find(ev => ev.sessionID)?.sessionID ?? null,
|
|
234
|
+
outcome(lines) {
|
|
235
|
+
const evs = events(lines);
|
|
236
|
+
const finish = evs.findLast(ev => ev.type === "step_finish");
|
|
237
|
+
const steps = evs.filter(ev => ev.type === "step_finish");
|
|
238
|
+
const errored = evs.findLast(ev => ev.type === "error");
|
|
239
|
+
const lastText = evs.findLast(ev => ev.type === "text")?.part?.text ?? null;
|
|
240
|
+
if (!finish && !errored) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no step_finish event" };
|
|
241
|
+
// `reason` is "stop" on a clean turn; anything else (an aborted or length-capped turn) is not a success.
|
|
242
|
+
const reason = finish?.part?.reason ?? null;
|
|
243
|
+
const ok = !errored && reason === "stop";
|
|
244
|
+
return {
|
|
245
|
+
ok, finished: true, lastMessage: lastText, denials: [],
|
|
246
|
+
// Cost belongs to each step, not just the final answer. A coding turn can have many tool steps.
|
|
247
|
+
// Missing step costs mean an unknown total; a provider-reported zero remains a real zero.
|
|
248
|
+
cost: steps.length && steps.every(ev => typeof ev.part?.cost === "number" && Number.isFinite(ev.part.cost))
|
|
249
|
+
? steps.reduce((total, ev) => total + ev.part.cost, 0) : null,
|
|
250
|
+
error: ok ? null : (errored?.error?.message || errored?.message || (reason ? `turn ended with reason "${reason}"` : "turn failed")),
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
// `auth list` prints the stored credentials and ends with "N credentials"; zero of them means no provider is
|
|
254
|
+
// configured and every run would fail at the first model call.
|
|
255
|
+
signIn: { scope: "local-credentials", args: ["auth", "list"], ok: (code, out) => code === 0 && !/\b0 credentials\b/.test(out) && /credential/i.test(out), fix: "opencode auth login" },
|
|
256
|
+
version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
|
|
257
|
+
// No flag adds a second directory: `--dir` moves the working directory, it does not add to it. opencode is
|
|
258
|
+
// not sandboxed by Gallivant either, so there is nothing to widen and nothing to refuse. The setting has no
|
|
259
|
+
// effect here, and `doctor` says so rather than leaving the operator to guess.
|
|
260
|
+
context: null,
|
|
261
|
+
attach: ({ session }) => `opencode run -s ${session}`,
|
|
262
|
+
},
|
|
263
|
+
// goose. Two flags look like they name a session and only one of them does both halves of the job:
|
|
264
|
+
// `--session-id <id>` is resume-only and refuses with "--session-id can only be used with --resume flag", so it
|
|
265
|
+
// cannot create the session the daemon will later come back to. `-n/--name <NAME>` names a new session AND,
|
|
266
|
+
// with `-r/--resume`, resumes that specific one. So the adapter uses -n for both and never uses --session-id.
|
|
267
|
+
// Verified by round-trip against a generated name. Resuming a name that does not exist prints "No session found
|
|
268
|
+
// with name ..." and exits non-zero, which `outcome` reports as unfinished rather than as a silent success.
|
|
269
|
+
//
|
|
270
|
+
// The name is a session id here, so it has to be safe as a filename: goose derives the session file from it.
|
|
271
|
+
goose: {
|
|
272
|
+
name: "goose",
|
|
273
|
+
// Authentication is selected only after resolving the effective provider.
|
|
274
|
+
preparationEnv: ["GOOSE_PROVIDER", "GOOSE_MODEL"],
|
|
275
|
+
newSession: () => `work-${randomUUID()}`,
|
|
276
|
+
// The name the daemon minted, and goose derives the session FILE from it, so it must stay one safe path segment.
|
|
277
|
+
isSession: s => /^(?:work|gallivant)-[0-9a-zA-Z-]{1,64}$/.test(s) && !s.includes(".."),
|
|
278
|
+
start: ({ session, prompt }) => ["run", "--name", session, ...GOOSE_COMMON, "-t", prompt],
|
|
279
|
+
resume: ({ session, prompt }) => ["run", "--resume", "--name", session, ...GOOSE_COMMON, "-t", prompt],
|
|
280
|
+
// GOOSE_MODE is how goose bounds tool calls; there is no CLI flag for it. "approve" would hang a headless run
|
|
281
|
+
// waiting for a human, "chat" cannot edit files at all. "smart_approve" is the bounded setting that still lets
|
|
282
|
+
// a run work, and it is set here rather than left to the operator's ~/.config/goose/config.yaml so a run never
|
|
283
|
+
// inherits a looser mode from the machine.
|
|
284
|
+
env: () => ({ GOOSE_MODE: "smart_approve" }),
|
|
285
|
+
// goose does not print the session name back in its JSONL; the daemon already knows it because it generated
|
|
286
|
+
// it, so there is nothing to parse and nothing to guess.
|
|
287
|
+
sessionFrom: () => null,
|
|
288
|
+
outcome(lines) {
|
|
289
|
+
const evs = events(lines);
|
|
290
|
+
const complete = evs.findLast(ev => ev.type === "complete");
|
|
291
|
+
const errored = evs.findLast(ev => ev.type === "error");
|
|
292
|
+
// goose streams an assistant reply as several `message` events, so the last one is a fragment, not the
|
|
293
|
+
// answer. Verified: resuming a session that had been told 8585 produced "858" then "5" as two events.
|
|
294
|
+
// Joining every assistant chunk is the only way to read the reply back whole.
|
|
295
|
+
const chunks = evs.filter(ev => ev.type === "message" && ev.message?.role === "assistant")
|
|
296
|
+
.flatMap(ev => (ev.message.content || []).filter(c => c.type === "text" && typeof c.text === "string").map(c => c.text));
|
|
297
|
+
const text = chunks.join("");
|
|
298
|
+
const lastText = text || null;
|
|
299
|
+
// Goose 1.50.1 emits provider failures as a visible assistant message followed by `complete`, with exit 0.
|
|
300
|
+
// This is the CLI's failure envelope, not a successful model answer (observed with a provider HTTP 404).
|
|
301
|
+
// Its prefix must begin a native text block, not an example embedded in model prose. Read forward from
|
|
302
|
+
// those boundaries in the joined stream so split prefixes and failures after earlier output still work.
|
|
303
|
+
let providerError = null;
|
|
304
|
+
let offset = 0;
|
|
305
|
+
for (const chunk of chunks) {
|
|
306
|
+
if (text.startsWith("Ran into this error: Request failed:", offset)) {
|
|
307
|
+
providerError = text.slice(offset + "Ran into this error: ".length);
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
offset += chunk.length;
|
|
311
|
+
}
|
|
312
|
+
// Authentication failures can instead be typed content blocks, also followed by `complete` and exit 0.
|
|
313
|
+
const contentError = evs.flatMap(ev => ev.message?.content || []).find(c => c.type === "error");
|
|
314
|
+
const credits = evs.flatMap(ev => ev.message?.content || []).find(c => c.type === "systemNotification" && c.notificationType === "creditsExhausted");
|
|
315
|
+
if (!complete && !errored) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no complete event" };
|
|
316
|
+
return {
|
|
317
|
+
ok: !errored && !providerError && !contentError && !credits, finished: true, lastMessage: lastText, denials: [],
|
|
318
|
+
cost: complete?.cost_usd ?? null,
|
|
319
|
+
error: errored ? (errored.error?.message || errored.message || "run failed") : providerError || (contentError ? contentError.message || contentError.kind || "provider error" : null) || (credits ? `creditsExhausted: ${credits.msg || "provider credits exhausted"}` : null),
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
// Like gemini: goose keeps provider keys in ~/.config/goose and has no status command that checks them
|
|
323
|
+
// without making a call, so this proves only that the binary runs. Same weaker guarantee, same consequence.
|
|
324
|
+
signIn: { scope: "local-binary", args: ["--version"], ok: code => code === 0, fix: "goose configure" },
|
|
325
|
+
version: { args: ["--version"], parse: out => (out.match(/(\d+\.\d+\.\d+)/) || [])[1] },
|
|
326
|
+
// `goose run` has no directory flag at all. Same as opencode: no effect rather than a refusal.
|
|
327
|
+
context: null,
|
|
328
|
+
attach: ({ session }) => `goose run --resume --name ${session} -s`,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// This is the capability registry; CLI adapters above remain its compatibility view.
|
|
333
|
+
// Remote providers implement transport; the coordinator owns review decisions.
|
|
334
|
+
export const executionRegistry = Object.freeze({
|
|
335
|
+
...Object.fromEntries(Object.entries(adapters).map(([name, adapter]) => [name, Object.freeze({
|
|
336
|
+
name, adapter, available: true, transport: EXECUTION_TRANSPORTS[name], authentication: "operator-cli",
|
|
337
|
+
environments: ["isolated-checkout"], continuation: "native-session", observability: ["jsonl", "process"],
|
|
338
|
+
controls: { cancel: true, interrupt: true }, cost: { source: name === "claude" ? "provider-usd" : "unknown-or-provider", hardCap: name === "claude" },
|
|
339
|
+
policy: name === "codex" ? ["workspace-write", "auto-review"] : name === "claude" ? ["configured-hooks"] : [],
|
|
340
|
+
preparation: adapter.preparation || null,
|
|
341
|
+
})])),
|
|
342
|
+
"openai-agents": Object.freeze({ name: "openai-agents", available: true, isSession: value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value), transport: EXECUTION_TRANSPORTS["openai-agents"], authentication: "project-api-key", environments: ["openai_hosted"], continuation: "native-session", observability: ["events", "saved-items", "polling"], controls: { cancel: true, interrupt: false }, cost: { source: "api-usage-and-hosted-compute", hardCap: false }, policy: [], preparation: agentsPreparation }),
|
|
343
|
+
"codex-cloud": Object.freeze({ name: "codex-cloud", available: false, reason: CLOUD_DISPATCH_UNAVAILABLE.reason, fix: CLOUD_DISPATCH_UNAVAILABLE.fix, isSession: value => typeof value === "string" && /^task_[A-Za-z0-9_-]{1,200}$/.test(value), transport: EXECUTION_TRANSPORTS["codex-cloud"], authentication: "operator-chatgpt", environments: [], continuation: "successor-task", observability: ["polling", "summary", "diff", "task-link"], controls: { cancel: false, interrupt: false }, cost: { source: "chatgpt-account", hardCap: false }, policy: [], preparation: null, liveQualified: false }),
|
|
344
|
+
});
|
|
345
|
+
export const capabilitiesFor = name => {
|
|
346
|
+
const c = executionRegistry[name];
|
|
347
|
+
if (!c) throw new Error(`unknown execution provider ${name}`);
|
|
348
|
+
return c;
|
|
349
|
+
};
|
|
350
|
+
export const executionCapabilities = () => Object.values(executionRegistry).map(({ adapter, isSession, preparation, ...capabilities }) => ({
|
|
351
|
+
...capabilities, preparation: preparation ? { version: preparation.version, boundary: preparation.boundary, authority: preparation.authority, ...(preparation.environment ? { environment: preparation.environment } : {}) } : null,
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
export const preparationCapability = (provider, capabilities = capabilitiesFor(provider)) => capabilities.preparation || null;
|
|
355
|
+
export const preparationReceipt = (provider, capabilities = capabilitiesFor(provider), account = "operator") => {
|
|
356
|
+
const p = preparationCapability(provider, capabilities);
|
|
357
|
+
if (!p || p.version !== 1 || !p.boundary || !p.authority) {
|
|
358
|
+
const error = new Error(`${provider} preparation is unavailable: its adapter has no enforced read/write boundary or tool-free mode; use a hand-authored PRD with ready/start until an isolated preparation adapter is supported; no provider call was made`);
|
|
359
|
+
error.code = "TASK_PREPARATION_BOUNDARY";
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
return { version: 1, provider, account, boundary: p.boundary, authority: p.authority };
|
|
363
|
+
};
|
|
364
|
+
export function isPreparationReceipt(provider, receipt, capabilities = capabilitiesFor(provider), account = "operator") {
|
|
365
|
+
const p = preparationCapability(provider, capabilities);
|
|
366
|
+
if (!p || p.version !== 1) return false;
|
|
367
|
+
// Version-1 intake stored only the boundary string. Its selected provider must
|
|
368
|
+
// still declare that exact contract; another adapter's boundary cannot qualify it.
|
|
369
|
+
if (typeof receipt === "string") return receipt === p.boundary;
|
|
370
|
+
return receipt?.version === 1 && receipt.provider === provider && receipt.account === account && receipt.boundary === p.boundary && receipt.authority === p.authority;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export const adapterFor = name => {
|
|
374
|
+
const a = adapters[name];
|
|
375
|
+
if (!a) throw new Error(`unknown harness "${name}"; known: ${Object.keys(adapters).join(", ")}`);
|
|
376
|
+
return a;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Refuse any argv that switches a harness's own safety off. The daemon never needs these, and the check is on the
|
|
381
|
+
* resolved argv rather than on each adapter so a flag cannot arrive from a prompt, a config value or a future edit
|
|
382
|
+
* to one adapter without passing here first.
|
|
383
|
+
*
|
|
384
|
+
* What the equivalent of --dangerously-skip-permissions is for each harness, as of the versions above:
|
|
385
|
+
*
|
|
386
|
+
* claude --dangerously-skip-permissions
|
|
387
|
+
* codex --dangerously-bypass-approvals-and-sandbox, and the same thing spelled as a config override:
|
|
388
|
+
* approval policy alone does not remove the sandbox, but sandbox_mode="danger-full-
|
|
389
|
+
* access" removes the sandbox, so the override VALUE is checked too, not just the flag name.
|
|
390
|
+
* gemini -y / --yolo, and --approval-mode yolo, which are the same switch by two spellings.
|
|
391
|
+
* opencode --auto, whose own help calls it "auto-approve permissions that are not explicitly denied
|
|
392
|
+
* (dangerous!)".
|
|
393
|
+
* goose no CLI flag at all. goose is bounded by the GOOSE_MODE env var, so there is nothing in argv to
|
|
394
|
+
* refuse; the adapter pins GOOSE_MODE=smart_approve instead. GOOSE_MODE is checked in assertSafeEnv.
|
|
395
|
+
*/
|
|
396
|
+
const DANGEROUS_FLAGS = new Set(["-y", "--yolo", "--auto"]);
|
|
397
|
+
const DANGEROUS_VALUES = [/^sandbox_mode=.*danger/i, /^yolo$/i];
|
|
398
|
+
// A flag whose value makes it dangerous, and the values that do. Both spellings are one rule: a CLI that accepts
|
|
399
|
+
// `--flag value` accepts `--flag=value`, so a list of literal spellings refused one and let the other through.
|
|
400
|
+
const DANGEROUS_WITH_VALUE = [
|
|
401
|
+
["--approval-mode", /^yolo$/i],
|
|
402
|
+
["--sandbox", /danger/i],
|
|
403
|
+
["-s", /danger/i],
|
|
404
|
+
["--yolo", /^(true|1|yes)$/i],
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
export function assertSafeArgv(args) {
|
|
408
|
+
for (let i = 0; i < args.length; i++) {
|
|
409
|
+
const a = String(args[i]);
|
|
410
|
+
if (/^--dangerously-/.test(a) || DANGEROUS_FLAGS.has(a)) throw new Error(`refusing to run with ${a}`);
|
|
411
|
+
for (const [flag, bad] of DANGEROUS_WITH_VALUE) {
|
|
412
|
+
// `--flag value` is two argv elements, `--flag=value` is one. Refuse both, or the `=` form is a way past this.
|
|
413
|
+
const eq = a.startsWith(`${flag}=`) ? a.slice(flag.length + 1) : null;
|
|
414
|
+
if (eq !== null ? bad.test(eq) : a === flag && bad.test(String(args[i + 1] ?? ""))) throw new Error(`refusing to run with ${a}${eq === null ? ` ${args[i + 1]}` : ""}`);
|
|
415
|
+
}
|
|
416
|
+
if (DANGEROUS_VALUES.some(re => re.test(a))) throw new Error(`refusing to run with ${a}`);
|
|
417
|
+
}
|
|
418
|
+
return args;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Whether a string is a session id this harness could have minted. The block already holds every session it reads
|
|
423
|
+
* off GitHub to this rule (`lib/block.mjs`); `register --session` is the one source that did not, and its value goes
|
|
424
|
+
* straight into argv position 2 after `--resume` or `--name`. `assertSafeArgv` cannot save it: it matches flag
|
|
425
|
+
* spellings, and a value in an unexpected position is not a spelling. So the flag and the block agree from here.
|
|
426
|
+
*/
|
|
427
|
+
export const isSessionFor = (harness, session) => (executionRegistry[harness]?.isSession || adapters[harness]?.isSession)?.(String(session ?? "")) === true;
|
|
428
|
+
|
|
429
|
+
/** The same refusal for the environment, because goose's permission mode is an env var and not a flag. */
|
|
430
|
+
export function assertSafeEnv(env) {
|
|
431
|
+
const mode = env.GOOSE_MODE;
|
|
432
|
+
if (mode && !["smart_approve", "approve", "chat"].includes(mode)) throw new Error(`refusing to run with GOOSE_MODE=${mode}`);
|
|
433
|
+
return env;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* How a harness handles extra read-only directories: "grant" when it has a flag for exactly that, "refuse" when
|
|
438
|
+
* its nearest flag would also grant write or would grant the whole disk, "none" when it has no such flag at all.
|
|
439
|
+
* `doctor` prints this so an operator with a list and the wrong harness is told, not left to guess.
|
|
440
|
+
*/
|
|
441
|
+
export function contextSupport(harness) {
|
|
442
|
+
const c = adapterFor(harness).context;
|
|
443
|
+
if (!c) return { kind: "none", detail: `${harness} has no flag that adds a directory; context_paths has no effect on its runs` };
|
|
444
|
+
if (c.refuse) return { kind: "refuse", detail: c.refuse };
|
|
445
|
+
return { kind: "grant", detail: `${harness} grants read on each path and writes stay bounded as before` };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* A pure launch description: { cmd, args, env, runtimeSettings? }. `mode` is "start" or "resume".
|
|
450
|
+
* Prompts are passed as the final argv element; both CLIs accept that and it keeps stdin free.
|
|
451
|
+
*
|
|
452
|
+
* `contextPaths` are extra directories the run may READ, already resolved and validated by
|
|
453
|
+
* `resolveContextPaths`/`validateContextPaths` in lib/config.mjs. An empty list renders nothing, so a run with no
|
|
454
|
+
* extras has exactly the argv it had before this existed. A harness that cannot grant read on a specific directory
|
|
455
|
+
* without also granting write, or without granting the whole disk, refuses the run rather than escalating; one with
|
|
456
|
+
* no directory flag at all ignores the list, because there is nothing to widen and a failed run would help nobody.
|
|
457
|
+
*/
|
|
458
|
+
export function buildRun({ harness, config, mode, session, prompt, cwd, contextPaths = [], runtimeSetup = true }) {
|
|
459
|
+
const a = adapterFor(harness);
|
|
460
|
+
const h = config.harness[harness];
|
|
461
|
+
if (!h?.bin) throw new Error(`harness.${harness}.bin is not configured`);
|
|
462
|
+
// The shell may resolve another installation. A real plugin validation launched an old PATH Codex from a
|
|
463
|
+
// healthy newer parent and misdiagnosed its model rejection as an account problem. Carry the exact executable
|
|
464
|
+
// on every dispatch, including resumes; changing PATH alone would not survive a login shell's startup files.
|
|
465
|
+
const runtime = [
|
|
466
|
+
`Gallivant launched this session with the configured ${harness} executable: ${JSON.stringify(h.bin)}.`,
|
|
467
|
+
"For task-authorized tests of this harness, invoke that exact executable, quoted as one path, instead of assuming the bare command on PATH is the same installation. This does not authorize nested jobs, model/provider changes, extra paid calls or weaker permissions. Keep the task's existing scope and security settings.",
|
|
468
|
+
"Before substantial implementation or retrying blocked work, check the brief's mandatory external prerequisites: required host/plugin support, model access and any browser or capture surface. Use the available native tools and the smallest authorized check; version/sign-in checks alone do not prove model access or installed-plugin behavior. If a required capability is unavailable, report the observed failure, affected criteria and exact remedy early. Continue useful independent work, preserve unmet attestations, and do not repeat unchanged blocked calls or replace required live evidence with fixtures.",
|
|
469
|
+
].join("\n");
|
|
470
|
+
const ctx = { session, prompt: `${runtime}\n\n${prompt}`, budget: config.max_budget_usd, settings: h.settings, cwd };
|
|
471
|
+
const base = mode === "start" ? a.start(ctx) : a.resume(ctx);
|
|
472
|
+
const args = assertSafeArgv(withContext(a, base, contextPaths));
|
|
473
|
+
const env = assertSafeEnv({ ...runtimeEnvironment(process.env, process.execPath, cwd), ...a.env({ configDir: h.settings ? dirname(h.settings) : null }) });
|
|
474
|
+
// Construction runs before cloning and again when managed input changes. Only
|
|
475
|
+
// the runner may materialize settings, once it owns an actual launch directory.
|
|
476
|
+
return { cmd: h.bin, args, env, ...(harness === "claude" && runtimeSetup && h.settings
|
|
477
|
+
? { runtimeSettings: { settings: h.settings, node: process.execPath } } : {}) };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// A workspace and a private HOME are not a filesystem boundary. The existing
|
|
481
|
+
// implementation adapters remain unchanged; preparation needs a separate,
|
|
482
|
+
// enforceable contract before untrusted context can reach a model with tools.
|
|
483
|
+
// Compatibility export for old integrations; lifecycle gates use the selected
|
|
484
|
+
// capability and versioned receipt, never this adapter-specific constant.
|
|
485
|
+
export const PREPARATION_BOUNDARY = "claude-no-tools-v1";
|
|
486
|
+
export function buildPreparationRun(options, capabilities = capabilitiesFor(options.harness)) {
|
|
487
|
+
const receipt = preparationReceipt(options.harness, capabilities);
|
|
488
|
+
const capability = preparationCapability(options.harness, capabilities);
|
|
489
|
+
if (typeof capability.render !== "function") throw new Error("CLI preparation capability requires an enforced argv renderer");
|
|
490
|
+
// Tool-free preparation must not install executable session hooks or create
|
|
491
|
+
// settings snapshots outside its separately enforced preparation boundary.
|
|
492
|
+
const spec = buildRun({ ...options, runtimeSetup: false });
|
|
493
|
+
spec.args = assertSafeArgv(capability.render(spec));
|
|
494
|
+
return { ...spec, boundary: receipt.boundary, configure: capability.configure, minimumVersion: capability.minimumVersion };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* The adapter's argv with the read-only directories folded in. The flags go BEFORE the prompt, which every adapter
|
|
499
|
+
* passes last, so nothing turns a path into part of the brief.
|
|
500
|
+
*/
|
|
501
|
+
/**
|
|
502
|
+
* Insert a harness's context-path flags, keeping the prompt last.
|
|
503
|
+
*
|
|
504
|
+
* The flags go at the FRONT, not immediately before the prompt. `--add-dir` takes a variadic list, so
|
|
505
|
+
* `--add-dir <dir> <prompt>` reads the prompt as another directory and Claude Code then refuses the run with
|
|
506
|
+
* "Input must be provided either through stdin or as a prompt argument when using --print". That is a real failure
|
|
507
|
+
* seen on a live run: the session started, the hook fired, and the harness exited before doing any work. Putting the
|
|
508
|
+
* flags ahead of everything leaves the prompt adjacent to nothing that can swallow it.
|
|
509
|
+
*/
|
|
510
|
+
function withContext(a, args, paths) {
|
|
511
|
+
if (!paths.length) return args;
|
|
512
|
+
if (!a.context) return args; // no flag exists: the list has no effect here
|
|
513
|
+
if (a.context.refuse) throw new Error(`refusing to run ${a.name} with context_paths: ${a.context.refuse}`);
|
|
514
|
+
return [...a.context.render(paths), ...args];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export const compareVersions = (a, b) => {
|
|
518
|
+
const pa = String(a).split(".").map(Number), pb = String(b).split(".").map(Number);
|
|
519
|
+
for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); }
|
|
520
|
+
return 0;
|
|
521
|
+
};
|