@proagentstore/cli 0.4.55 → 0.4.56
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/dist/browser-runner/coding/engine-env.js +46 -0
- package/dist/browser-runner/coding/gh-guard.js +272 -0
- package/dist/browser-runner/coding/headless.js +23 -28
- package/dist/browser-runner/coding/runtime.js +6 -1
- package/dist/browser-runner/coding/turn-author.js +86 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What environment the Engine's process actually gets.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `headless.ts` when #679 added the second decision made at this seam. There are
|
|
5
|
+
* now two, they are asked at the same three call sites (both spawns and `authResolved`), and
|
|
6
|
+
* `headless.ts` sat one line under its size pin — so the concern gets a file rather than the pin
|
|
7
|
+
* getting a raise. `engine-auth.ts` READS this env; this builds it.
|
|
8
|
+
*
|
|
9
|
+
* 1. {@link mergeEnv} — the platform's overlay over the machine's, where empty means REMOVE.
|
|
10
|
+
* 2. {@link engineSpawnEnv} — that, plus the `gh` guard ahead of the real binary on `PATH`.
|
|
11
|
+
*/
|
|
12
|
+
import { ghGuardEnv } from "./gh-guard.js";
|
|
13
|
+
/**
|
|
14
|
+
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
15
|
+
* REMOVE rather than "set to empty".
|
|
16
|
+
*
|
|
17
|
+
* Needed because the machine env is inherited wholesale: a developer with ANTHROPIC_API_KEY in
|
|
18
|
+
* their shell handed it to every engine, and Claude Code prefers an API key over the
|
|
19
|
+
* subscription token — so choosing "subscription" injected CLAUDE_CODE_OAUTH_TOKEN and then
|
|
20
|
+
* silently lost, billing per token anyway. Without a way to express removal the setting could
|
|
21
|
+
* not mean what it said.
|
|
22
|
+
*/
|
|
23
|
+
export function mergeEnv(base, overlay) {
|
|
24
|
+
const out = { ...base };
|
|
25
|
+
for (const [k, v] of Object.entries(overlay ?? {})) {
|
|
26
|
+
if (v === "")
|
|
27
|
+
delete out[k];
|
|
28
|
+
else
|
|
29
|
+
out[k] = v;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The env a turn is spawned with: the merged env, with the `gh` write guard on `PATH` when the
|
|
35
|
+
* platform named a scope for this session (#679).
|
|
36
|
+
*
|
|
37
|
+
* ONE expression, called everywhere the question is asked — including `authResolved`, whose whole
|
|
38
|
+
* contract is that it reports the env the next turn is spawned with rather than the configured
|
|
39
|
+
* intent. Two expressions here is how that guarantee quietly stops being true.
|
|
40
|
+
*
|
|
41
|
+
* `ghGuardEnv` returns its input untouched whenever the guard cannot be installed, so this is safe
|
|
42
|
+
* in front of every spawn: the failure mode is the machine's own `gh`, never a missing one.
|
|
43
|
+
*/
|
|
44
|
+
export function engineSpawnEnv(overlay, ghScope, ghGuardRoot) {
|
|
45
|
+
return ghGuardEnv(mergeEnv(process.env, overlay), ghScope, ghGuardRoot);
|
|
46
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment for the Engine's `gh` writes (#679, absorbing #676).
|
|
3
|
+
*
|
|
4
|
+
* ── The defect
|
|
5
|
+
*
|
|
6
|
+
* `headless.ts` spawns the Engine with `env: mergeEnv(process.env, this.config.env)` — twice, at
|
|
7
|
+
* the persistent stream-json spawn and again in `runOneShot`. The Engine therefore inherits the
|
|
8
|
+
* machine's ENTIRE environment, and with it whatever `git` and `gh` credentials that machine
|
|
9
|
+
* holds. `coding_diagnostics` has been saying so out loud since #676:
|
|
10
|
+
*
|
|
11
|
+
* "A write naming a repository outside this list halts the run and is recorded. The first such
|
|
12
|
+
* write still LANDS — the engine uses this machine's own git and gh credentials, which are
|
|
13
|
+
* not scoped."
|
|
14
|
+
*
|
|
15
|
+
* Detection after the fact is not containment. This is the containment half.
|
|
16
|
+
*
|
|
17
|
+
* ── What this is, and what it deliberately is NOT
|
|
18
|
+
*
|
|
19
|
+
* It is an **allowlist gate on `gh`**, installed as a shim earlier on the Engine's `PATH` than the
|
|
20
|
+
* real binary. A `gh` invocation whose verb is in the closed write vocabulary AND which names a
|
|
21
|
+
* repository outside this session's registered scope is refused before it runs, with the refused
|
|
22
|
+
* repository in stderr. Everything else — every read, every write to the session's own repo — is
|
|
23
|
+
* `exec`'d straight through to the real `gh`, on the machine's own credentials, unchanged.
|
|
24
|
+
*
|
|
25
|
+
* It is **not a credential broker**, and the sketch's "route a repo-scoped installation token for
|
|
26
|
+
* the allowed case" half is deliberately rejected:
|
|
27
|
+
*
|
|
28
|
+
* - The token that would be routed is a GitHub-App installation token minted in the cloud
|
|
29
|
+
* (`workers/api/src/lib/git-credentials.ts`) and delivered once at session start, for the
|
|
30
|
+
* clone. It lives about an hour; a coding session does not. Swapping the machine's working
|
|
31
|
+
* login for it would turn a containment feature into an outage partway through long runs.
|
|
32
|
+
* - It would BREAK the one property this ticket says must survive: a cross-repo `gh pr view
|
|
33
|
+
* --repo <another org>/<repo>` is a different installation, so an installation token cannot
|
|
34
|
+
* read it. The machine's own login can, and does today.
|
|
35
|
+
* - It buys nothing. The harm is the wrong-repo write LANDING, and refusing to exec stops that
|
|
36
|
+
* strictly earlier than handing over a credential that would have failed.
|
|
37
|
+
*
|
|
38
|
+
* It is also **not a change to the machine's git configuration.** No `insteadOf`, no
|
|
39
|
+
* `credential.helper`, no `GIT_SSH_COMMAND`. The owner drives his own work through a custom
|
|
40
|
+
* `github-personal` SSH host alias, and forcing https to gate an agent would take over
|
|
41
|
+
* configuration he uses personally.
|
|
42
|
+
*
|
|
43
|
+
* ── What remains open, and is stated rather than hidden
|
|
44
|
+
*
|
|
45
|
+
* 1. **`git push` is not gated at all.** Remotes here are SSH (`git@github-personal:…`), and
|
|
46
|
+
* `repo.ts` says why in code: *"Only https carries a credential: git ignores userinfo on an
|
|
47
|
+
* ssh URL"*. There is no token to withhold, so no credential decision can reach it.
|
|
48
|
+
* 2. **A `PATH` shim is bypassable.** `/opt/homebrew/bin/gh` skips it entirely, and the Engine
|
|
49
|
+
* has been observed re-exporting its own environment (`export GH_CONFIG_DIR=…`). This raises
|
|
50
|
+
* the cost of a wrong-repo write; it does not make one impossible.
|
|
51
|
+
* 3. **`gh api graphql` is not classified.** A mutation could travel in the query text, and
|
|
52
|
+
* deciding that would mean pattern-matching free prose — the judgement this codebase refuses
|
|
53
|
+
* to make in a gate, because a false positive that halts a run costs more than the finding.
|
|
54
|
+
* Explicit `gh api --method POST|PATCH|PUT|DELETE` with a `/repos/{owner}/{repo}` path IS
|
|
55
|
+
* classified.
|
|
56
|
+
* 4. **A write that names no repository is not gated.** `gh` then resolves the target from the
|
|
57
|
+
* working directory's remote, which the shim cannot see without running git itself. In a
|
|
58
|
+
* session that is the workdir the platform registered — but the Engine has a shell, so `cd`
|
|
59
|
+
* into another checkout on this machine reaches that repository instead. Refusing every
|
|
60
|
+
* unqualified write was rejected: it is the ordinary shape of `gh pr create` inside the repo
|
|
61
|
+
* the session owns, so it would break the common case to narrow an uncommon one.
|
|
62
|
+
*
|
|
63
|
+
* All three are reported through {@link ghGuardReport} → `coding_diagnostics`, which is where the
|
|
64
|
+
* `enforcement: "acts-observed-halt"` value they replace used to be. A gate that overstates itself
|
|
65
|
+
* is worse than no gate.
|
|
66
|
+
*/
|
|
67
|
+
import { createHash } from "node:crypto";
|
|
68
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
69
|
+
import { homedir } from "node:os";
|
|
70
|
+
import { join } from "node:path";
|
|
71
|
+
/**
|
|
72
|
+
* The closed WRITE vocabulary — `<command> <subcommand>` pairs that change something on GitHub.
|
|
73
|
+
*
|
|
74
|
+
* A denylist rather than an allowlist, which inverts `repo-write.ts`'s shape, and the inversion is
|
|
75
|
+
* the point rather than an oversight: `gh`'s read surface is enormous and open-ended (`gh api`,
|
|
76
|
+
* `gh run`, `gh release download`, `gh search`, third-party extensions), so an allowlist of reads
|
|
77
|
+
* would refuse working commands nobody enumerated and the Engine would be broken in ways that look
|
|
78
|
+
* like the platform, not like a policy. What has to be complete here is the list of verbs that
|
|
79
|
+
* WRITE, and that list is small, stable and reviewable. Adding to it is a code review.
|
|
80
|
+
*
|
|
81
|
+
* `gh gist` is absent on purpose: a gist belongs to no repository, so there is nothing to scope it
|
|
82
|
+
* against and refusing it would be theatre.
|
|
83
|
+
*/
|
|
84
|
+
export const GH_WRITE_VERBS = [
|
|
85
|
+
"pr create", "pr merge", "pr close", "pr reopen", "pr edit", "pr comment", "pr review", "pr ready", "pr lock", "pr unlock",
|
|
86
|
+
"issue create", "issue close", "issue reopen", "issue edit", "issue comment", "issue delete", "issue lock", "issue unlock", "issue pin", "issue unpin", "issue transfer",
|
|
87
|
+
"release create", "release delete", "release edit", "release upload",
|
|
88
|
+
"repo create", "repo delete", "repo edit", "repo rename", "repo archive", "repo unarchive", "repo sync", "repo fork", "repo deploy-key",
|
|
89
|
+
"workflow enable", "workflow disable", "workflow run",
|
|
90
|
+
"run cancel", "run rerun", "run delete",
|
|
91
|
+
"secret set", "secret delete",
|
|
92
|
+
"variable set", "variable delete",
|
|
93
|
+
"label create", "label delete", "label edit", "label clone",
|
|
94
|
+
"cache delete",
|
|
95
|
+
"ruleset check",
|
|
96
|
+
];
|
|
97
|
+
/** The exit status the shim refuses with — distinct from `gh`'s own 1, so a refusal is telling. */
|
|
98
|
+
export const GH_REFUSED_EXIT = 3;
|
|
99
|
+
/** The marker a refusal always carries, so the reason is greppable in a pane full of tool output. */
|
|
100
|
+
export const GH_REFUSED_MARKER = "pags: refused";
|
|
101
|
+
/** Where the generated shims live. Under the runner's existing config root, not a new location. */
|
|
102
|
+
export function ghGuardRoot() {
|
|
103
|
+
return join(homedir(), ".config", "proagentstore", "gh-guard");
|
|
104
|
+
}
|
|
105
|
+
/** `owner/repo`, lower-cased, with any `host/` prefix or `.git` suffix removed. */
|
|
106
|
+
export function normalizeRepo(raw) {
|
|
107
|
+
const parts = raw
|
|
108
|
+
.trim()
|
|
109
|
+
.replace(/^https?:\/\//i, "")
|
|
110
|
+
.replace(/\.git$/i, "")
|
|
111
|
+
.split("/")
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
return parts.slice(-2).join("/").toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The `gh` the shim delegates to: the first executable named `gh` on `path`, skipping anything
|
|
117
|
+
* under `guardRoot` — otherwise a shim installed on a previous spawn could be resolved as the
|
|
118
|
+
* "real" one and the second generation would exec the first, forever.
|
|
119
|
+
*/
|
|
120
|
+
export function findRealGh(path, guardRoot) {
|
|
121
|
+
for (const dir of (path ?? "").split(":")) {
|
|
122
|
+
if (!dir || (guardRoot && dir.startsWith(guardRoot)))
|
|
123
|
+
continue;
|
|
124
|
+
const candidate = join(dir, "gh");
|
|
125
|
+
try {
|
|
126
|
+
accessSync(candidate, constants.X_OK);
|
|
127
|
+
return candidate;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* not here, or not executable — keep looking */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The shim itself, as text.
|
|
137
|
+
*
|
|
138
|
+
* POSIX `sh` and not a Node script, because this runs on every `gh` the Engine invokes and it must
|
|
139
|
+
* not depend on resolving a module out of a bundle whose layout differs between the monorepo
|
|
140
|
+
* (`src/*.ts` under tsx) and the published CLI (`dist/browser-runner/*.js`). The logic is small
|
|
141
|
+
* enough to be read in one screen, and its tests execute THIS text rather than a TypeScript twin
|
|
142
|
+
* of it — so what is verified is the artifact that ships.
|
|
143
|
+
*
|
|
144
|
+
* Every unclassified path ends in `exec`, so a bug here degrades to today's behaviour rather than
|
|
145
|
+
* to a broken `gh`.
|
|
146
|
+
*/
|
|
147
|
+
export function renderGhShim(opts) {
|
|
148
|
+
const scope = opts.scope.map(normalizeRepo).filter(Boolean).join(" ");
|
|
149
|
+
// `case` patterns, one per write verb. Generated from the constant so the shim and the
|
|
150
|
+
// reviewable list can never disagree.
|
|
151
|
+
const verbs = GH_WRITE_VERBS.map((v) => ` ${sq(v)}) write=1 ;;`).join("\n");
|
|
152
|
+
return `#!/bin/sh
|
|
153
|
+
# ProAgentStore gh guard (#679) — GENERATED. Source of truth:
|
|
154
|
+
# packages/browser-runner/src/coding/gh-guard.ts. Editing this file widens what the agent may
|
|
155
|
+
# write to; that is a bypass, not a configuration.
|
|
156
|
+
REAL=${sq(opts.realGh)}
|
|
157
|
+
SCOPE=${sq(scope)}
|
|
158
|
+
|
|
159
|
+
cmd=""; sub=""; repo=""; method=""; want=""
|
|
160
|
+
for a in "$@"; do
|
|
161
|
+
if [ "$want" = "repo" ]; then repo="$a"; want=""; continue; fi
|
|
162
|
+
if [ "$want" = "method" ]; then method="$a"; want=""; continue; fi
|
|
163
|
+
case "$a" in
|
|
164
|
+
--repo=*) repo=\${a#--repo=} ;;
|
|
165
|
+
--method=*) method=\${a#--method=} ;;
|
|
166
|
+
-R|--repo) want="repo" ;;
|
|
167
|
+
-X|--method) want="method" ;;
|
|
168
|
+
-*) : ;;
|
|
169
|
+
*) if [ -z "$cmd" ]; then cmd="$a"; elif [ -z "$sub" ]; then sub="$a"; fi ;;
|
|
170
|
+
esac
|
|
171
|
+
done
|
|
172
|
+
|
|
173
|
+
write=0
|
|
174
|
+
case "$cmd $sub" in
|
|
175
|
+
${verbs}
|
|
176
|
+
esac
|
|
177
|
+
# \`gh api\` is the generic escape hatch: only an explicitly mutating method counts, and the target
|
|
178
|
+
# comes out of the /repos/{owner}/{repo} path. \`gh api graphql\` is NOT classified — see the module.
|
|
179
|
+
if [ "$cmd" = "api" ]; then
|
|
180
|
+
m=$(printf '%s' "$method" | tr '[:lower:]' '[:upper:]')
|
|
181
|
+
case "$m" in POST|PATCH|PUT|DELETE) write=1 ;; esac
|
|
182
|
+
if [ -z "$repo" ]; then
|
|
183
|
+
repo=$(printf '%s' "$sub" | sed -n 's|^/\\{0,1\\}repos/\\([^/]*\\)/\\([^/]*\\).*|\\1/\\2|p')
|
|
184
|
+
fi
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
if [ "$write" = "1" ] && [ -n "$repo" ]; then
|
|
188
|
+
want_repo=$(printf '%s' "$repo" | sed 's|^https\\{0,1\\}://||; s|\\.git$||' | awk -F/ '{ if (NF>1) print $(NF-1)"/"$NF; else print $0 }' | tr '[:upper:]' '[:lower:]')
|
|
189
|
+
allowed=0
|
|
190
|
+
for r in $SCOPE; do
|
|
191
|
+
if [ "$r" = "$want_repo" ]; then allowed=1; fi
|
|
192
|
+
done
|
|
193
|
+
if [ "$allowed" = "0" ]; then
|
|
194
|
+
echo "${GH_REFUSED_MARKER}: \\\`gh $cmd $sub\\\` writes to $want_repo, which this agent session is not registered for (allowed: $SCOPE)." >&2
|
|
195
|
+
echo "Ask the owner to register that repository with the agent, or do the write in a session that owns it. Reads are not affected." >&2
|
|
196
|
+
exit ${GH_REFUSED_EXIT}
|
|
197
|
+
fi
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
exec "$REAL" "$@"
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
/** Single-quote for `sh`: the only character that needs care inside `'…'` is `'` itself. */
|
|
204
|
+
function sq(value) {
|
|
205
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
206
|
+
}
|
|
207
|
+
/** The gaps, in one place, so the runner and `coding_diagnostics` cannot drift apart. */
|
|
208
|
+
export const GH_GUARD_GAPS = [
|
|
209
|
+
"`git push` is not gated — this machine's remotes are SSH, so there is no credential to withhold.",
|
|
210
|
+
"A PATH shim is bypassable: invoking `/opt/homebrew/bin/gh` by absolute path, or re-exporting PATH, skips it.",
|
|
211
|
+
"`gh api graphql` is not classified — a mutation can travel inside the query text.",
|
|
212
|
+
"A write with no `--repo` is not gated: gh resolves the target from the working directory, so a `cd` into another checkout on this machine reaches it.",
|
|
213
|
+
];
|
|
214
|
+
/** Report for a session with no guard installed. */
|
|
215
|
+
export function ghGuardReport(scope, reason) {
|
|
216
|
+
return { installed: !reason, scope, ...(reason ? { reason } : {}), gaps: [...GH_GUARD_GAPS] };
|
|
217
|
+
}
|
|
218
|
+
/** Installed shims, keyed by (scope, real gh) — writing the same file per turn would be silly. */
|
|
219
|
+
const installed = new Map();
|
|
220
|
+
/**
|
|
221
|
+
* Install (idempotently) a shim for `scope` and return its directory, or `null` with the reason.
|
|
222
|
+
*
|
|
223
|
+
* Keyed by a hash of what the shim CONTAINS, so a scope change lands in a different directory and
|
|
224
|
+
* a stale shim can never be handed a scope it was not generated for.
|
|
225
|
+
*/
|
|
226
|
+
export function installGhGuard(scope, env, root = ghGuardRoot()) {
|
|
227
|
+
const repos = scope.map(normalizeRepo).filter(Boolean);
|
|
228
|
+
// No scope is not "allow nothing" — it is "the platform did not say", and a guard that refused
|
|
229
|
+
// every write on that basis would break every session an older cloud starts.
|
|
230
|
+
if (repos.length === 0)
|
|
231
|
+
return { reason: "no-scope" };
|
|
232
|
+
const realGh = findRealGh(env.PATH, root);
|
|
233
|
+
if (!realGh)
|
|
234
|
+
return { reason: "gh-not-found" };
|
|
235
|
+
const script = renderGhShim({ realGh, scope: repos });
|
|
236
|
+
const key = createHash("sha256").update(`${root}${script}`).digest("hex").slice(0, 16);
|
|
237
|
+
const cached = installed.get(key);
|
|
238
|
+
if (cached)
|
|
239
|
+
return { dir: cached };
|
|
240
|
+
const dir = join(root, key);
|
|
241
|
+
try {
|
|
242
|
+
mkdirSync(dir, { recursive: true });
|
|
243
|
+
const shim = join(dir, "gh");
|
|
244
|
+
if (!existsSync(shim))
|
|
245
|
+
writeFileSync(shim, script, { mode: 0o755 });
|
|
246
|
+
installed.set(key, dir);
|
|
247
|
+
return { dir };
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Fail OPEN and say so. A runner that cannot write to its own config dir must still be able
|
|
251
|
+
// to run the Engine; silently producing a `gh` that does not exist would be far worse.
|
|
252
|
+
return { reason: "install-failed" };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The Engine's spawn env, with the guard ahead of the real `gh` on `PATH`.
|
|
257
|
+
*
|
|
258
|
+
* Returns `env` untouched whenever the guard could not be installed, which is what makes this safe
|
|
259
|
+
* to put in front of every spawn: the failure mode is today's behaviour, never a broken `gh`.
|
|
260
|
+
*/
|
|
261
|
+
export function ghGuardEnv(env, scope, root) {
|
|
262
|
+
const out = installGhGuard(scope ?? [], env, root);
|
|
263
|
+
if (!("dir" in out))
|
|
264
|
+
return env;
|
|
265
|
+
return { ...env, PATH: `${out.dir}:${env.PATH ?? ""}` };
|
|
266
|
+
}
|
|
267
|
+
/** What to report for a session started with `scope`, derived from the same call the spawn made. */
|
|
268
|
+
export function ghGuardStatus(scope, env, root) {
|
|
269
|
+
const repos = (scope ?? []).map(normalizeRepo).filter(Boolean);
|
|
270
|
+
const out = installGhGuard(scope ?? [], env, root);
|
|
271
|
+
return "dir" in out ? ghGuardReport(repos) : ghGuardReport(repos, out.reason);
|
|
272
|
+
}
|
|
@@ -3,26 +3,9 @@ import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk
|
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
4
|
import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
5
5
|
import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
* Needed because the machine env is inherited wholesale: a developer with ANTHROPIC_API_KEY in
|
|
11
|
-
* their shell handed it to every engine, and Claude Code prefers an API key over the
|
|
12
|
-
* subscription token — so choosing "subscription" injected CLAUDE_CODE_OAUTH_TOKEN and then
|
|
13
|
-
* silently lost, billing per token anyway. Without a way to express removal the setting could
|
|
14
|
-
* not mean what it said.
|
|
15
|
-
*/
|
|
16
|
-
export function mergeEnv(base, overlay) {
|
|
17
|
-
const out = { ...base };
|
|
18
|
-
for (const [k, v] of Object.entries(overlay ?? {})) {
|
|
19
|
-
if (v === "")
|
|
20
|
-
delete out[k];
|
|
21
|
-
else
|
|
22
|
-
out[k] = v;
|
|
23
|
-
}
|
|
24
|
-
return out;
|
|
25
|
-
}
|
|
6
|
+
import { authoredTurn, authorTag } from "./turn-author.js";
|
|
7
|
+
import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
|
|
8
|
+
import { ghGuardStatus } from "./gh-guard.js";
|
|
26
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
10
|
import { dirname, join } from "node:path";
|
|
28
11
|
import { handlerFor } from "./handlers.js";
|
|
@@ -173,7 +156,15 @@ export class HeadlessSession {
|
|
|
173
156
|
* Presence only: no key or token value leaves this class.
|
|
174
157
|
*/
|
|
175
158
|
get authResolved() {
|
|
176
|
-
return resolveEngineAuth(this.config.clientType,
|
|
159
|
+
return resolveEngineAuth(this.config.clientType, this.spawnEnv);
|
|
160
|
+
}
|
|
161
|
+
/** The env every turn is spawned with — one expression, three call sites. See engine-env.ts. */
|
|
162
|
+
get spawnEnv() {
|
|
163
|
+
return engineSpawnEnv(this.config.env, this.config.ghScope, this.config.ghGuardRoot);
|
|
164
|
+
}
|
|
165
|
+
/** What the `gh` guard actually did on this machine (#679) — reported, never assumed. */
|
|
166
|
+
get ghGuard() {
|
|
167
|
+
return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
|
|
177
168
|
}
|
|
178
169
|
/**
|
|
179
170
|
* Did this engine launch with a conversation to continue (#408)?
|
|
@@ -344,7 +335,7 @@ export class HeadlessSession {
|
|
|
344
335
|
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
345
336
|
const proc = spawn(this.cmdBin, args, {
|
|
346
337
|
cwd: this.config.workDir,
|
|
347
|
-
env:
|
|
338
|
+
env: this.spawnEnv,
|
|
348
339
|
stdio: ["pipe", "pipe", "pipe"],
|
|
349
340
|
});
|
|
350
341
|
this.proc = proc;
|
|
@@ -386,11 +377,13 @@ export class HeadlessSession {
|
|
|
386
377
|
}
|
|
387
378
|
});
|
|
388
379
|
}
|
|
389
|
-
/** Send a user turn
|
|
390
|
-
input(text) {
|
|
380
|
+
/** Send a user turn. `author` names who wrote it, because `role` cannot — see turn-author.ts (#505). */
|
|
381
|
+
input(text, opts = {}) {
|
|
382
|
+
// The Engine reads the preamble, the pane the short marker; the instruction stays evidence.
|
|
383
|
+
const sent = authoredTurn(text, opts.author);
|
|
391
384
|
if (!this.alive)
|
|
392
385
|
this.start();
|
|
393
|
-
this.push(`\n❯ [${stamp()}] ${text}`); // ❯ — your turn, timestamped
|
|
386
|
+
this.push(`\n❯ [${stamp()}] ${authorTag(opts.author)}${text}`); // ❯ — your turn, timestamped
|
|
394
387
|
this.run = "thinking";
|
|
395
388
|
const now = Date.now();
|
|
396
389
|
this.lastOutputAt = now;
|
|
@@ -398,13 +391,15 @@ export class HeadlessSession {
|
|
|
398
391
|
this.sawOutputSinceInput = false; // arm the persistent-raw idle heuristic for THIS turn
|
|
399
392
|
try {
|
|
400
393
|
if (this.mode === "stream-json") {
|
|
401
|
-
|
|
394
|
+
// `role` stays "user" — the only role this protocol accepts, which is why the
|
|
395
|
+
// disambiguation rides in the text instead (#505).
|
|
396
|
+
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: sent }] } });
|
|
402
397
|
this.proc?.stdin?.write(`${msg}\n`);
|
|
403
398
|
}
|
|
404
399
|
else {
|
|
405
400
|
// Raw CLI: spawn THIS turn. See `oneShot` — there is no persistent process to
|
|
406
401
|
// write to, because a non-interactive binary would never have read it.
|
|
407
|
-
this.runOneShot(
|
|
402
|
+
this.runOneShot(sent);
|
|
408
403
|
}
|
|
409
404
|
}
|
|
410
405
|
catch {
|
|
@@ -432,7 +427,7 @@ export class HeadlessSession {
|
|
|
432
427
|
this.turnLastLine = "";
|
|
433
428
|
const proc = spawn(this.cmdBin, [...this.cmdArgs, text], {
|
|
434
429
|
cwd: this.config.workDir,
|
|
435
|
-
env:
|
|
430
|
+
env: this.spawnEnv,
|
|
436
431
|
stdio: ["ignore", "pipe", "pipe"],
|
|
437
432
|
});
|
|
438
433
|
// A turn already running is aborted before its replacement starts: `input()` accepts a turn
|
|
@@ -5,6 +5,7 @@ import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
|
5
5
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoTree, runRepoGit } from "./inspect.js";
|
|
6
6
|
import { switchRepoBranch } from "./repo-write.js";
|
|
7
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
|
+
import { asTurnAuthor } from "./turn-author.js";
|
|
8
9
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
9
10
|
const MAX_PANE = 64 * 1024;
|
|
10
11
|
export class CodingRuntime {
|
|
@@ -116,6 +117,7 @@ export class CodingRuntime {
|
|
|
116
117
|
env: input.env,
|
|
117
118
|
statePath: defaultStatePath(this.reposBaseDir),
|
|
118
119
|
resumeFrom: input.resumeFrom,
|
|
120
|
+
ghScope: input.ghScope,
|
|
119
121
|
bin: input.bin,
|
|
120
122
|
});
|
|
121
123
|
this.sessions.set(input.sessionId, session);
|
|
@@ -163,7 +165,9 @@ export class CodingRuntime {
|
|
|
163
165
|
const session = this.require(sessionId);
|
|
164
166
|
switch (action.kind) {
|
|
165
167
|
case "message":
|
|
166
|
-
|
|
168
|
+
// Narrowed rather than trusted: this runner is a published package any caller can
|
|
169
|
+
// POST to, and an unrecognised author must read as "unstated", not become a label.
|
|
170
|
+
session.input(action.text, { author: asTurnAuthor(action.author) });
|
|
167
171
|
break;
|
|
168
172
|
case "keys": {
|
|
169
173
|
// A snapshot is no longer the whole answer (#448). `key()` records the attempt and
|
|
@@ -243,6 +247,7 @@ export class CodingRuntime {
|
|
|
243
247
|
takeover: this.takeovers.has(sessionId),
|
|
244
248
|
authResolved: s.authResolved,
|
|
245
249
|
engineRuntime: s.engineRuntime,
|
|
250
|
+
ghGuard: s.ghGuard,
|
|
246
251
|
}));
|
|
247
252
|
}
|
|
248
253
|
// ── Human takeover (the "stuck" handoff) ────────────────────────────────
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who wrote the turn the Engine is about to receive (#505, criterion 3).
|
|
3
|
+
*
|
|
4
|
+
* ── The defect
|
|
5
|
+
*
|
|
6
|
+
* `HeadlessSession.input` writes every turn to Claude Code as
|
|
7
|
+
* `{ type: "user", message: { role: "user", … } }`. That is the only shape the stream-json input
|
|
8
|
+
* protocol accepts — the role vocabulary is `user`/`assistant`, there is no third value to move
|
|
9
|
+
* to, and an Engine that stops receiving instructions is far worse than the defect — so the
|
|
10
|
+
* framing cannot be fixed by changing the role. The consequence is that the Engine calls whoever
|
|
11
|
+
* is driving it "the user", and **whoever is driving it is usually the Pilot, not a person.**
|
|
12
|
+
*
|
|
13
|
+
* On 2026-08-11 that produced a completion message telling the owner he had been warned and had
|
|
14
|
+
* "explicitly chosen" to bump the wrong `pubspec.yaml`. `instance_messages` shows he sent nothing
|
|
15
|
+
* in that window (22:31:20 and 22:47:54 bracket it); the Engine's objection was correct, and
|
|
16
|
+
* overriding it broke the deploy. Nothing had lied: the Engine said "the user", the Pilot read
|
|
17
|
+
* that back, and "the user" is the only word the protocol gave it.
|
|
18
|
+
*
|
|
19
|
+
* ── What this module does, and what it deliberately does not
|
|
20
|
+
*
|
|
21
|
+
* It **names the author** on the two surfaces the naming collision travels through:
|
|
22
|
+
*
|
|
23
|
+
* - {@link authoredTurn} — a one-line preamble the Engine reads BEFORE the instruction, so the
|
|
24
|
+
* Engine's own prose ("you asked me to…") is at least anchored to something that says what
|
|
25
|
+
* "you" is. The instruction follows **verbatim**, after a blank line. #505's standing promise
|
|
26
|
+
* is *annotate, never rewrite*: the instruction is the evidence, and truncating or rephrasing
|
|
27
|
+
* it would destroy the only record of what was actually sent.
|
|
28
|
+
* - {@link authorTag} — a two-word marker on the transcript line, which is what the owner reads
|
|
29
|
+
* in the Terminal view and what the Pilot re-reads through `/coding/capture`. It is short
|
|
30
|
+
* because that pane is a fixed character budget the Pilot pays for on every decision; a
|
|
31
|
+
* 150-character preamble repeated on every turn would evict real output from the window the
|
|
32
|
+
* Pilot can see. The pane has never been a wire log — it already renders `❯ [12:03:04] …`,
|
|
33
|
+
* which is not sent either — so a compact marker there is consistent with what that line is.
|
|
34
|
+
*
|
|
35
|
+
* ── Why only the Pilot is named
|
|
36
|
+
*
|
|
37
|
+
* The vocabulary is **closed at one member on purpose**, the way `repo-write.ts` closes its verb
|
|
38
|
+
* list. `POST /coding/act` is a shared door: the console's manual `/message` route, MCP's
|
|
39
|
+
* `coding_session_message`, the Overseer's delegation and the agent's own `drive_claude` tool all
|
|
40
|
+
* arrive through it, and none of them declares an author. An absent author therefore means
|
|
41
|
+
* "nobody said", which is exactly what it renders as — nothing. Adding a `"human"` member would
|
|
42
|
+
* turn silence into a claim the runner cannot support: it could only ever be set by the callers
|
|
43
|
+
* that already bothered to be explicit, so an unlabelled human turn and an unlabelled machine turn
|
|
44
|
+
* would still be indistinguishable while the labelling implied otherwise.
|
|
45
|
+
*
|
|
46
|
+
* A runner older than this change ignores the field entirely and behaves exactly as before; a
|
|
47
|
+
* cloud older than this change sends no author and the bytes are byte-identical to today. Both
|
|
48
|
+
* directions degrade to the status quo rather than to a wrong label.
|
|
49
|
+
*/
|
|
50
|
+
/** The authors this runner will accept over the wire. Anything else is treated as unstated. */
|
|
51
|
+
const AUTHORS = ["pilot"];
|
|
52
|
+
/**
|
|
53
|
+
* Narrow an untrusted wire value to a {@link TurnAuthor}.
|
|
54
|
+
*
|
|
55
|
+
* The runner is a published npm package that any caller can POST to, so the field arrives as
|
|
56
|
+
* `unknown` and an unrecognised value must read as "unstated" rather than becoming a label.
|
|
57
|
+
*/
|
|
58
|
+
export function asTurnAuthor(value) {
|
|
59
|
+
return typeof value === "string" && AUTHORS.includes(value) ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The preamble the Engine reads. Kept to one sentence pair: it is prepended to EVERY Pilot turn,
|
|
63
|
+
* so its length is a per-turn token cost on every engine, on every run.
|
|
64
|
+
*
|
|
65
|
+
* It states only what the runner actually knows — the caller declared this turn's author — and
|
|
66
|
+
* makes no claim about whether a human is watching, because the console does surface the Pilot's
|
|
67
|
+
* step lines in the owner's chat and "no person has seen this" would be false there.
|
|
68
|
+
*/
|
|
69
|
+
const PILOT_PREAMBLE = '[pags] This turn was written by the Pilot, an automated orchestrator, not typed by a person. "The user" in this conversation means the Pilot.';
|
|
70
|
+
/**
|
|
71
|
+
* The instruction as the Engine receives it: the author's preamble, a blank line, then the
|
|
72
|
+
* caller's text **unchanged**.
|
|
73
|
+
*
|
|
74
|
+
* Returns the input untouched when the author is unstated, so the only turns whose bytes change
|
|
75
|
+
* are the ones the platform can actually name.
|
|
76
|
+
*/
|
|
77
|
+
export function authoredTurn(text, author) {
|
|
78
|
+
return author === "pilot" ? `${PILOT_PREAMBLE}\n\n${text}` : text;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The transcript marker, already spaced for concatenation (`""` when the author is unstated), so
|
|
82
|
+
* a reader of the pane can tell a Pilot turn from a turn a person typed.
|
|
83
|
+
*/
|
|
84
|
+
export function authorTag(author) {
|
|
85
|
+
return author === "pilot" ? "(pilot) " : "";
|
|
86
|
+
}
|