@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.
@@ -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
- * Merge the platform's resolved engine env over the machine's, where an EMPTY value means
8
- * REMOVE rather than "set to empty".
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, mergeEnv(process.env, this.config.env));
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: mergeEnv(process.env, this.config.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 to the agent (it acts on it). */
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
- const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
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(text);
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: mergeEnv(process.env, this.config.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
- session.input(action.text);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.55",
3
+ "version": "0.4.56",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",