@proagentstore/cli 0.4.57 → 0.4.58
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-acts.js +7 -7
- package/dist/browser-runner/coding/engine-adapter.js +186 -0
- package/dist/browser-runner/coding/engine-usage.js +27 -3
- package/dist/browser-runner/coding/github-browse.js +796 -0
- package/dist/browser-runner/coding/handlers.js +1 -1
- package/dist/browser-runner/coding/headless.js +86 -120
- package/dist/browser-runner/coding/inspect.js +188 -13
- package/dist/browser-runner/coding/repo.js +44 -1
- package/dist/browser-runner/coding/runtime.js +14 -2
- package/dist/browser-runner/server.js +103 -0
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
const HANDLERS = {
|
|
15
15
|
claude: { clientType: "claude", cliCommand: "claude --dangerously-skip-permissions", envVar: "ANTHROPIC_API_KEY" },
|
|
16
16
|
gemini: { clientType: "gemini", cliCommand: "gemini --approval-mode yolo --skip-trust --prompt", envVar: "GEMINI_API_KEY" },
|
|
17
|
-
codex: { clientType: "codex", cliCommand: "codex exec --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
|
|
17
|
+
codex: { clientType: "codex", cliCommand: "codex exec --json --sandbox danger-full-access", envVar: "OPENAI_API_KEY" },
|
|
18
18
|
grok: { clientType: "grok", cliCommand: "grok --permission-mode bypassPermissions -p", envVar: "XAI_API_KEY" },
|
|
19
19
|
generic: { clientType: "generic", cliCommand: "bash", envVar: "" },
|
|
20
20
|
};
|
|
@@ -10,6 +10,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { handlerFor } from "./handlers.js";
|
|
12
12
|
import { resolveEngineAuth } from "./engine-auth.js";
|
|
13
|
+
import { engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning } from "./engine-adapter.js";
|
|
14
|
+
export { buildClaudeArgs } from "./engine-adapter.js";
|
|
13
15
|
/**
|
|
14
16
|
* How many un-drained usage records a session holds (#267).
|
|
15
17
|
*
|
|
@@ -72,6 +74,7 @@ export class HeadlessSession {
|
|
|
72
74
|
pendingSeed = null;
|
|
73
75
|
/** "stream-json" for Claude (structured) · "raw" for any other CLI (stdout capture). */
|
|
74
76
|
mode;
|
|
77
|
+
adapter;
|
|
75
78
|
cmdBin;
|
|
76
79
|
cmdArgs;
|
|
77
80
|
binName;
|
|
@@ -176,6 +179,14 @@ export class HeadlessSession {
|
|
|
176
179
|
get ghGuard() {
|
|
177
180
|
return ghGuardStatus(this.config.ghScope, mergeEnv(process.env, this.config.env), this.config.ghGuardRoot);
|
|
178
181
|
}
|
|
182
|
+
/** Whether this process is running through structured events or plain stdout (#731). */
|
|
183
|
+
get engineMode() {
|
|
184
|
+
return engineInvocationModeFromAdapter(this.mode);
|
|
185
|
+
}
|
|
186
|
+
/** A named warning only when a structured-capable engine is actually running raw (#731). */
|
|
187
|
+
get engineModeWarning() {
|
|
188
|
+
return engineInvocationWarning(this.config.clientType, this.engineMode);
|
|
189
|
+
}
|
|
179
190
|
/**
|
|
180
191
|
* Did this engine launch with a conversation to continue (#408)?
|
|
181
192
|
*
|
|
@@ -189,7 +200,7 @@ export class HeadlessSession {
|
|
|
189
200
|
* flag and {@link buildClaudeArgs} is only reached in stream-json mode.
|
|
190
201
|
*/
|
|
191
202
|
get resumedConversation() {
|
|
192
|
-
return this.mode === "stream-json" && this.claudeSessionId !== null;
|
|
203
|
+
return this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null;
|
|
193
204
|
}
|
|
194
205
|
/**
|
|
195
206
|
* Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
|
|
@@ -206,13 +217,10 @@ export class HeadlessSession {
|
|
|
206
217
|
this.config = config;
|
|
207
218
|
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
208
219
|
// Our own key first, the cloud's nominated predecessor second. See `resumeFrom`.
|
|
209
|
-
this.claudeSessionId =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// so an engine that found its own conversation drops it unread rather than being handed a
|
|
214
|
-
// summary of the conversation it is already in.
|
|
215
|
-
this.pendingSeed = this.resumedConversation ? null : config.seed?.trim() || null;
|
|
220
|
+
this.claudeSessionId =
|
|
221
|
+
config.clientType === "claude"
|
|
222
|
+
? readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null)
|
|
223
|
+
: null;
|
|
216
224
|
const { bin, args } = parseCommand(config.command);
|
|
217
225
|
// When no explicit command is configured, fall back to THIS engine's default
|
|
218
226
|
// command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
|
|
@@ -223,6 +231,12 @@ export class HeadlessSession {
|
|
|
223
231
|
// Use the configured command's args when a command was given (bin set), else the
|
|
224
232
|
// engine default's args.
|
|
225
233
|
this.cmdArgs = bin ? args : fallback.args;
|
|
234
|
+
this.adapter = engineAdapterFor(config.clientType, this.cmdArgs);
|
|
235
|
+
this.mode = this.adapter.mode;
|
|
236
|
+
// AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
|
|
237
|
+
// so an engine that found its own conversation drops it unread rather than being handed a
|
|
238
|
+
// summary of the conversation it is already in.
|
|
239
|
+
this.pendingSeed = this.resumedConversation ? null : config.seed?.trim() || null;
|
|
226
240
|
this.binName = (this.cmdBin.split("/").pop() || this.cmdBin) || "cli";
|
|
227
241
|
}
|
|
228
242
|
/**
|
|
@@ -287,7 +301,7 @@ export class HeadlessSession {
|
|
|
287
301
|
// The ceiling that stops a wedged process is armed in `runOneShot` — it ENDS the turn
|
|
288
302
|
// rather than relabelling a live one as idle, which is the same mistake in slower form.
|
|
289
303
|
if (this.oneShot)
|
|
290
|
-
return this.procAlive ? "thinking" : "idle";
|
|
304
|
+
return this.mode === "stream-json" ? this.run : this.procAlive ? "thinking" : "idle";
|
|
291
305
|
// Below: a PERSISTENT non-Claude engine — alive between turns, so exit says nothing about
|
|
292
306
|
// a turn and idle must be inferred. None ships today; every raw engine is one-shot. The
|
|
293
307
|
// gate is `!oneShot` rather than `mode === "raw"` because the latter now means the
|
|
@@ -339,7 +353,7 @@ export class HeadlessSession {
|
|
|
339
353
|
* multi-turn, which is why it survived the migration untouched.
|
|
340
354
|
*/
|
|
341
355
|
get oneShot() {
|
|
342
|
-
return this.
|
|
356
|
+
return !this.adapter.persistent;
|
|
343
357
|
}
|
|
344
358
|
start() {
|
|
345
359
|
// Starting always un-stops: `stop()` is what ends a one-shot session, so a (re)start
|
|
@@ -354,10 +368,7 @@ export class HeadlessSession {
|
|
|
354
368
|
}
|
|
355
369
|
if (this.procAlive)
|
|
356
370
|
return;
|
|
357
|
-
|
|
358
|
-
// (e.g. --model) without letting them clobber or orphan-value our flags. raw:
|
|
359
|
-
// run exactly what the user configured and capture stdout.
|
|
360
|
-
const args = this.mode === "stream-json" ? buildClaudeArgs(this.cmdArgs, this.claudeSessionId) : [...this.cmdArgs];
|
|
371
|
+
const args = this.adapter.buildLaunchArgs(this.cmdArgs, this.claudeSessionId);
|
|
361
372
|
const proc = spawn(this.cmdBin, args, {
|
|
362
373
|
cwd: this.config.workDir,
|
|
363
374
|
env: this.spawnEnv,
|
|
@@ -427,7 +438,7 @@ export class HeadlessSession {
|
|
|
427
438
|
// Brief first, instruction second, and never the other way round: it is background for
|
|
428
439
|
// the request, and an engine that reads the request last acts on the request.
|
|
429
440
|
const withSeed = seed ? `${seed}\n\n${sent}` : sent;
|
|
430
|
-
if (this.
|
|
441
|
+
if (this.adapter.persistent) {
|
|
431
442
|
// `role` stays "user" — the only role this protocol accepts, which is why the
|
|
432
443
|
// disambiguation rides in the text instead (#505).
|
|
433
444
|
const msg = JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: withSeed }] } });
|
|
@@ -462,7 +473,7 @@ export class HeadlessSession {
|
|
|
462
473
|
// Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
|
|
463
474
|
// this turn produced (#545).
|
|
464
475
|
this.turnLastLine = "";
|
|
465
|
-
const proc = spawn(this.cmdBin,
|
|
476
|
+
const proc = spawn(this.cmdBin, this.adapter.buildTurnArgs(this.cmdArgs, text), {
|
|
466
477
|
cwd: this.config.workDir,
|
|
467
478
|
env: this.spawnEnv,
|
|
468
479
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -619,79 +630,65 @@ export class HeadlessSession {
|
|
|
619
630
|
this.transcript = this.transcript.slice(-3000);
|
|
620
631
|
}
|
|
621
632
|
handle(line) {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
629
|
-
switch (ev.type) {
|
|
630
|
-
case "system":
|
|
631
|
-
if (ev.subtype === "init" && ev.session_id) {
|
|
632
|
-
this.claudeSessionId = ev.session_id;
|
|
633
|
-
writeState(this.config.statePath, this.config.id, ev.session_id);
|
|
634
|
-
}
|
|
635
|
-
break;
|
|
636
|
-
case "assistant":
|
|
637
|
-
for (const block of ev.message?.content ?? []) {
|
|
638
|
-
if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
639
|
-
this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
|
|
640
|
-
}
|
|
641
|
-
else if (block.type === "tool_use") {
|
|
642
|
-
const name = String(block.name ?? "tool");
|
|
643
|
-
this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
|
|
644
|
-
// The result arrives in a LATER event carrying only `tool_use_id`, and how much
|
|
645
|
-
// of it reaches the pane depends on which tool it was (#700) — so the name is
|
|
646
|
-
// remembered here and read back in `settleAct`'s sibling branch below.
|
|
647
|
-
if (typeof block.id === "string" && block.id)
|
|
648
|
-
this.toolNames.set(block.id, name);
|
|
649
|
-
this.noteAct(block);
|
|
633
|
+
for (const ev of this.adapter.parseLine(line)) {
|
|
634
|
+
switch (ev.kind) {
|
|
635
|
+
case "session":
|
|
636
|
+
if (this.config.clientType === "claude") {
|
|
637
|
+
this.claudeSessionId = ev.sessionId;
|
|
638
|
+
writeState(this.config.statePath, this.config.id, ev.sessionId);
|
|
650
639
|
}
|
|
640
|
+
break;
|
|
641
|
+
case "assistant_text":
|
|
642
|
+
this.push(`[${stamp()}] ${ev.text}`); // timestamped agent reply
|
|
643
|
+
break;
|
|
644
|
+
case "tool_use":
|
|
645
|
+
this.push(`⚙ ${ev.name} ${shortInput(ev.input)}`); // ⚙
|
|
646
|
+
// The result arrives in a LATER event carrying only `tool_use_id`, and how much
|
|
647
|
+
// of it reaches the pane depends on which tool it was (#700) — so the name is
|
|
648
|
+
// remembered here and read back in `settleAct`'s sibling branch below.
|
|
649
|
+
if (ev.id)
|
|
650
|
+
this.toolNames.set(ev.id, ev.name);
|
|
651
|
+
this.noteAct(ev.block);
|
|
652
|
+
break;
|
|
653
|
+
case "tool_result": {
|
|
654
|
+
// `""` when the call was not seen (a pane that began mid-turn, a runner restart):
|
|
655
|
+
// an unknown tool takes the conservative budget rather than the generous one.
|
|
656
|
+
const tool = this.toolNames.get(ev.toolUseId) ?? "";
|
|
657
|
+
this.toolNames.delete(ev.toolUseId);
|
|
658
|
+
this.push(renderToolResult(toolResultMark(ev.block), ev.content, tool)); // ↳✓ / ↳✗ (#597)
|
|
659
|
+
this.settleAct(ev.block);
|
|
660
|
+
break;
|
|
651
661
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
662
|
+
case "turn_end": {
|
|
663
|
+
const failure = ev.isError ? ev.result : "";
|
|
664
|
+
if (failure)
|
|
665
|
+
this.push(`[error] ${failure}`);
|
|
666
|
+
// The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
|
|
667
|
+
// per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
|
|
668
|
+
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
669
|
+
// this the field would exist for three engines and silently not for the flagship.
|
|
670
|
+
this.turnReport = turnReportFromResult(ev.isError, failure);
|
|
671
|
+
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
672
|
+
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
673
|
+
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
674
|
+
const usage = parseEngineUsage(ev.raw, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
|
|
675
|
+
if (usage) {
|
|
676
|
+
this.pendingUsage.push(usage);
|
|
677
|
+
if (this.pendingUsage.length > MAX_PENDING_USAGE)
|
|
678
|
+
this.pendingUsage.shift();
|
|
663
679
|
}
|
|
680
|
+
// The turn ended, so no further `tool_result` is coming for anything still waiting.
|
|
681
|
+
// Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
|
|
682
|
+
// never saw whether it worked" is a materially different claim from silence, and
|
|
683
|
+
// silence is what a supervisor would read as "it did nothing".
|
|
684
|
+
this.flushAwaitingActs();
|
|
685
|
+
this.toolNames.clear(); // no further result is coming for anything still named here
|
|
686
|
+
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
687
|
+
break;
|
|
664
688
|
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const failure = ev.is_error ? String(ev.result ?? ev.subtype ?? "failed") : "";
|
|
668
|
-
if (failure)
|
|
669
|
-
this.push(`[error] ${failure}`);
|
|
670
|
-
// The structured path's ANALOGUE of a non-zero exit (#545). Claude has no process
|
|
671
|
-
// per turn, so `exitCode` is honestly null and the verdict comes from the protocol's
|
|
672
|
-
// own `is_error` — the same claim, in the words the engine states it in. Without
|
|
673
|
-
// this the field would exist for three engines and silently not for the flagship.
|
|
674
|
-
this.turnReport = turnReportFromResult(ev.is_error === true, failure);
|
|
675
|
-
// The same event that ends the turn also reports what the turn COST (#267). It was
|
|
676
|
-
// parsed and thrown away, which is why Engine spend was absent from the ledger.
|
|
677
|
-
// An errored turn still burned tokens, so this is recorded regardless of is_error.
|
|
678
|
-
const usage = parseEngineUsage(ev, `${this.config.id}:${this.usageRunId}:${this.usageSeq++}`);
|
|
679
|
-
if (usage) {
|
|
680
|
-
this.pendingUsage.push(usage);
|
|
681
|
-
if (this.pendingUsage.length > MAX_PENDING_USAGE)
|
|
682
|
-
this.pendingUsage.shift();
|
|
683
|
-
}
|
|
684
|
-
// The turn ended, so no further `tool_result` is coming for anything still waiting.
|
|
685
|
-
// Publish it with an UNKNOWN outcome rather than dropping it: "it ran this and we
|
|
686
|
-
// never saw whether it worked" is a materially different claim from silence, and
|
|
687
|
-
// silence is what a supervisor would read as "it did nothing".
|
|
688
|
-
this.flushAwaitingActs();
|
|
689
|
-
this.toolNames.clear(); // no further result is coming for anything still named here
|
|
690
|
-
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
691
|
-
break;
|
|
689
|
+
default:
|
|
690
|
+
break;
|
|
692
691
|
}
|
|
693
|
-
default:
|
|
694
|
-
break;
|
|
695
692
|
}
|
|
696
693
|
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
697
694
|
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
@@ -724,10 +721,11 @@ export class HeadlessSession {
|
|
|
724
721
|
* from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
|
|
725
722
|
* the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
|
|
726
723
|
*
|
|
727
|
-
* This path (and `noteAct`) is reachable ONLY from
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
* would be the temporal guess `pull-attribution.ts`
|
|
724
|
+
* This path (and `noteAct`) is reachable ONLY from structured adapter events. Claude emits
|
|
725
|
+
* `assistant` → `tool_use`, `user` → `tool_result`; Codex `exec --json` emits
|
|
726
|
+
* `command_execution`, which the adapter normalizes to the same shape. Raw engines have no such
|
|
727
|
+
* framing, so scraping their transcript would be the temporal guess `pull-attribution.ts`
|
|
728
|
+
* refuses.
|
|
731
729
|
*/
|
|
732
730
|
settleAct(block) {
|
|
733
731
|
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
@@ -844,38 +842,6 @@ export function parseCommand(command) {
|
|
|
844
842
|
}
|
|
845
843
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
846
844
|
}
|
|
847
|
-
/** Structural flags PAGS owns for the Claude stream-json engine — a user command
|
|
848
|
-
* must not override or duplicate these (and must not orphan their values). */
|
|
849
|
-
const RESERVED_CLAUDE_FLAGS = new Set(["-p", "--print", "--input-format", "--output-format", "--verbose", "--resume"]);
|
|
850
|
-
/**
|
|
851
|
-
* Build Claude's argv: our structural stream-json flags + the user's extra args
|
|
852
|
-
* (e.g. `--model`), with reserved flags (and their values) stripped so the user
|
|
853
|
-
* can't clobber the protocol or leave an orphaned positional. `--resume` is added
|
|
854
|
-
* last from our persisted session id, never from the user's command.
|
|
855
|
-
*/
|
|
856
|
-
export function buildClaudeArgs(userArgs, resumeId) {
|
|
857
|
-
const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"];
|
|
858
|
-
for (let i = 0; i < userArgs.length; i++) {
|
|
859
|
-
const a = userArgs[i];
|
|
860
|
-
if (RESERVED_CLAUDE_FLAGS.has(a)) {
|
|
861
|
-
// drop the flag AND its value (when the next token isn't itself a flag)
|
|
862
|
-
if (i + 1 < userArgs.length && !userArgs[i + 1].startsWith("-"))
|
|
863
|
-
i++;
|
|
864
|
-
continue;
|
|
865
|
-
}
|
|
866
|
-
// Push every user token as-is. A previous `!args.includes(a)` dedup silently
|
|
867
|
-
// dropped a REPEATED flag token (e.g. the 2nd `--add-dir` in `--add-dir /a
|
|
868
|
-
// --add-dir /b`), which orphaned its value (`/b` became a stray positional).
|
|
869
|
-
// Our own structural flags are already protected via RESERVED_CLAUDE_FLAGS, so
|
|
870
|
-
// no dedup is needed here.
|
|
871
|
-
args.push(a);
|
|
872
|
-
}
|
|
873
|
-
if (!args.includes("--dangerously-skip-permissions"))
|
|
874
|
-
args.push("--dangerously-skip-permissions");
|
|
875
|
-
if (resumeId)
|
|
876
|
-
args.push("--resume", resumeId);
|
|
877
|
-
return args;
|
|
878
|
-
}
|
|
879
845
|
function loadFile(path) {
|
|
880
846
|
if (!path || !existsSync(path))
|
|
881
847
|
return {};
|
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { relative, resolve, sep } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Is `dir` inside a git WORK TREE — not merely "does it contain `.git`" (#785)?
|
|
6
|
+
*
|
|
7
|
+
* Three functions in this file gated on `existsSync(resolve(workDir, ".git"))`, while
|
|
8
|
+
* `checkWorkdir` (repo.ts, #405) deliberately asks `git rev-parse --is-inside-work-tree` because
|
|
9
|
+
* `~/dev/monorepo/apps/thing` is a perfectly good workdir with no `.git` of its own. So the
|
|
10
|
+
* staleness check called a subdirectory workdir healthy and `repo_git` in the same folder answered
|
|
11
|
+
* "not a git repo". One question, one answer: this is the gate every git-running function uses.
|
|
12
|
+
*
|
|
13
|
+
* A missing directory, a missing git binary and a plain folder all read as `false` — every one
|
|
14
|
+
* of them makes the git command that follows fail, and "not a git repo" is the message
|
|
15
|
+
* `saysNotAGitRepo` (workers/api/src/lib/repo-state.ts) already matches on the cloud side.
|
|
16
|
+
*/
|
|
17
|
+
export function insideWorkTree(dir) {
|
|
18
|
+
try {
|
|
19
|
+
const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
20
|
+
cwd: dir,
|
|
21
|
+
encoding: "utf-8",
|
|
22
|
+
timeout: 10_000,
|
|
23
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
24
|
+
});
|
|
25
|
+
return out.trim() === "true";
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The one gate. The message is load-bearing: the cloud matches "not a git repo" (#548). */
|
|
32
|
+
function requireWorkTree(workDir) {
|
|
33
|
+
if (!insideWorkTree(workDir))
|
|
34
|
+
throw new InspectError("not a git repo");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Environment for any git command that may touch the NETWORK (#785).
|
|
38
|
+
*
|
|
39
|
+
* A fetch that hits an expired credential must fail, not hang the runner's request loop on a
|
|
40
|
+
* password prompt nobody can see: `GIT_TERMINAL_PROMPT=0` for https, `BatchMode=yes` for ssh.
|
|
41
|
+
* The user's own `GIT_SSH_COMMAND` wins when set — it may carry a key or a proxy we must keep.
|
|
42
|
+
*/
|
|
43
|
+
function networkGitEnv() {
|
|
44
|
+
return {
|
|
45
|
+
...process.env,
|
|
46
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
47
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
4
50
|
/**
|
|
5
51
|
* Read-only code inspection for the coding runtime — the "eyes" the Co-pilot/Chat use
|
|
6
52
|
* to GROUND their answers in the real repo (read a file, `git diff`, list the tree)
|
|
@@ -34,10 +80,33 @@ export function resolveInside(root, rel, opts = {}) {
|
|
|
34
80
|
}
|
|
35
81
|
return abs;
|
|
36
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* A revision a caller may name (#785). One shape, checked once, used by every command that takes
|
|
85
|
+
* a `ref`.
|
|
86
|
+
*
|
|
87
|
+
* The rule that matters is the FIRST character: git reads a leading `-` as a flag, so a ref may not
|
|
88
|
+
* start with one, and then no character class below can become an option. Everything else git
|
|
89
|
+
* accepts as a revision is allowed — a sha, `HEAD~3`, `origin/main`, `v1.2`, `main..feature`,
|
|
90
|
+
* `@{u}` — because all of those are READS and the argv around them is fixed.
|
|
91
|
+
*/
|
|
92
|
+
const REF_PATTERN = /^[A-Za-z0-9_@][A-Za-z0-9._/^~@{}-]{0,127}$/;
|
|
93
|
+
export function validateRef(ref) {
|
|
94
|
+
const r = ref.trim();
|
|
95
|
+
if (!r)
|
|
96
|
+
throw new InspectError("`ref` is empty");
|
|
97
|
+
if (!REF_PATTERN.test(r))
|
|
98
|
+
throw new InspectError(`\`ref\` is not a valid git revision: ${ref.slice(0, 64)}`);
|
|
99
|
+
return r;
|
|
100
|
+
}
|
|
37
101
|
/** Map a whitelisted command enum to a fixed git argv. `path` (already validated by the
|
|
38
|
-
* caller via resolveInside) is only ever appended after a literal `--` separator
|
|
102
|
+
* caller via resolveInside) is only ever appended after a literal `--` separator, and `ref`
|
|
103
|
+
* (already validated by `validateRef`) only ever lands where git expects a revision. */
|
|
39
104
|
export function gitArgv(cmd, opts = {}) {
|
|
40
105
|
const clampN = Math.max(1, Math.min(200, Math.floor(opts.n ?? 20)));
|
|
106
|
+
const path = opts.relPath ? ["--", opts.relPath] : [];
|
|
107
|
+
// `ref` is a REVISION and belongs before `--`; `path` is a PATHSPEC and belongs after it. That
|
|
108
|
+
// ordering is what keeps the two from being confused for each other by git, whatever they hold.
|
|
109
|
+
const rev = opts.ref ? [opts.ref] : [];
|
|
41
110
|
switch (cmd) {
|
|
42
111
|
case "status":
|
|
43
112
|
// `--branch` adds ONE header line (`## main...origin/main [ahead 1]`). Without it a
|
|
@@ -48,7 +117,7 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
48
117
|
// `path` reaches this one too (#508). Narrowing every command rather than four of the
|
|
49
118
|
// five is what lets the tool description say "it applies" with no caveat — and a
|
|
50
119
|
// caveat is what a model has to reason about and can get wrong.
|
|
51
|
-
return
|
|
120
|
+
return ["status", "--short", "--branch", ...path];
|
|
52
121
|
// `path` used to reach exactly ONE of these five (#508). It is advertised on the tool as
|
|
53
122
|
// "Limit the command to one file or folder", `runRepoGit` resolves and validates it, and
|
|
54
123
|
// then four of the five branches dropped it on the floor — so
|
|
@@ -60,13 +129,24 @@ export function gitArgv(cmd, opts = {}) {
|
|
|
60
129
|
// Every one of these is git's own `--` pathspec discipline, unchanged: the validated path
|
|
61
130
|
// is appended after a literal separator and can never be read as a flag or a revision.
|
|
62
131
|
case "diff":
|
|
63
|
-
return
|
|
132
|
+
return ["diff", ...rev, ...path];
|
|
64
133
|
case "diff-stat":
|
|
65
|
-
return
|
|
134
|
+
return ["diff", "--stat", ...rev, ...path];
|
|
135
|
+
// `ref` on `log` is what #785 reached for as `git log -1 <sha>` and found silently ignored:
|
|
136
|
+
// the input never existed, so the tool answered the canned 20-line list and nothing said
|
|
137
|
+
// the argument had gone nowhere. Now `{cmd:"log", ref, n:1}` is that command.
|
|
66
138
|
case "log":
|
|
67
|
-
return
|
|
139
|
+
return ["log", "--oneline", "-n", String(clampN), ...rev, ...path];
|
|
68
140
|
case "ls-files":
|
|
69
|
-
return
|
|
141
|
+
return ["ls-files", ...path];
|
|
142
|
+
// `show` is `--stat <sha>`, the other thing #785 tried: what ONE commit changed. `--stat`
|
|
143
|
+
// rather than the patch, because the patch of an arbitrary commit is unbounded and the
|
|
144
|
+
// file list is what a reader deciding whether to `diff` one file actually needs. A `show`
|
|
145
|
+
// with no ref would show HEAD, which is a guess dressed as an answer — required instead.
|
|
146
|
+
case "show":
|
|
147
|
+
if (!rev.length)
|
|
148
|
+
throw new InspectError("`show` needs a `ref` — the commit to describe");
|
|
149
|
+
return ["show", "--stat", "--format=medium", ...rev, ...path];
|
|
70
150
|
default:
|
|
71
151
|
throw new InspectError(`unsupported git command: ${cmd}`);
|
|
72
152
|
}
|
|
@@ -96,16 +176,20 @@ export function readRepoFile(workDir, relPath, maxBytes) {
|
|
|
96
176
|
}
|
|
97
177
|
/** Run a whitelisted read-only git command in the repo. Never uses a shell. */
|
|
98
178
|
export function runRepoGit(workDir, cmd, opts = {}) {
|
|
99
|
-
|
|
100
|
-
throw new InspectError("not a git repo");
|
|
179
|
+
requireWorkTree(workDir);
|
|
101
180
|
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
102
|
-
const
|
|
181
|
+
const ref = opts.ref ? validateRef(opts.ref) : undefined;
|
|
182
|
+
const argv = gitArgv(cmd, { relPath, n: opts.n, ref });
|
|
103
183
|
// Did the path the caller asked for actually reach git? Reported rather than assumed, because
|
|
104
184
|
// a runner is a SEPARATE release from the cloud that calls it: before #508 four of the five
|
|
105
185
|
// commands ignored `path` silently, and the answer — the whole repository — was indistinguishable
|
|
106
186
|
// from a correct one. An older runner omits this field entirely, which is what lets the cloud
|
|
107
187
|
// say "your machine ignored the filter" instead of relaying a wrong answer as a right one.
|
|
108
188
|
const pathApplied = relPath !== undefined && argv.includes(relPath);
|
|
189
|
+
// Same idiom for `ref` (#785): a runner older than this drops it, and the cloud tells the caller
|
|
190
|
+
// so — the alternative is the exact silence the issue reported, a `log -1 <sha>` answered by
|
|
191
|
+
// the newest twenty commits with nothing to say the sha went nowhere.
|
|
192
|
+
const refApplied = ref !== undefined && argv.includes(ref);
|
|
109
193
|
let out = "";
|
|
110
194
|
try {
|
|
111
195
|
out = execFileSync("git", argv, { cwd: workDir, encoding: "utf-8", timeout: 10_000, maxBuffer: 4 * 1024 * 1024 });
|
|
@@ -119,13 +203,13 @@ export function runRepoGit(workDir, cmd, opts = {}) {
|
|
|
119
203
|
}
|
|
120
204
|
const cap = opts.maxBytes ?? 64 * 1024;
|
|
121
205
|
const truncated = out.length > cap;
|
|
122
|
-
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied };
|
|
206
|
+
return { cmd, output: truncated ? out.slice(0, cap) : out, truncated, pathApplied, refApplied };
|
|
123
207
|
}
|
|
124
208
|
/** Read the repo's `origin` remote URL — used to auto-associate a local checkout with its
|
|
125
209
|
* GitHub repo (so build status can query Actions). Fixed argv, no shell, no user input;
|
|
126
210
|
* returns null when it's not a git repo or has no `origin` remote. */
|
|
127
211
|
export function readGitRemoteOrigin(workDir) {
|
|
128
|
-
if (!
|
|
212
|
+
if (!insideWorkTree(workDir))
|
|
129
213
|
return null;
|
|
130
214
|
try {
|
|
131
215
|
const out = execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
|
@@ -178,8 +262,7 @@ const SEARCH_PER_FILE = 5;
|
|
|
178
262
|
const SEARCH_MAX_LINE = 160;
|
|
179
263
|
const SEARCH_MAX_PATTERN = 200;
|
|
180
264
|
export function repoSearch(workDir, opts) {
|
|
181
|
-
|
|
182
|
-
throw new InspectError("not a git repo");
|
|
265
|
+
requireWorkTree(workDir);
|
|
183
266
|
const pattern = (opts.pattern ?? "").trim();
|
|
184
267
|
if (!pattern)
|
|
185
268
|
throw new InspectError("a search `pattern` is required");
|
|
@@ -306,3 +389,95 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
306
389
|
}
|
|
307
390
|
return { root: relPath, entries, truncated, truncatedByDepth, depthCap };
|
|
308
391
|
}
|
|
392
|
+
export const SYNC_FETCH_TTL_MS = 60_000;
|
|
393
|
+
/** Per work-tree root: when the last fetch attempt was made and how it went. */
|
|
394
|
+
const fetchLog = new Map();
|
|
395
|
+
/** Test seam: forget every cached fetch. */
|
|
396
|
+
export function resetSyncCache() {
|
|
397
|
+
fetchLog.clear();
|
|
398
|
+
}
|
|
399
|
+
export function repoSync(workDir, opts = {}) {
|
|
400
|
+
requireWorkTree(workDir);
|
|
401
|
+
const now = opts.now ?? Date.now;
|
|
402
|
+
const git = (args, timeout = 10_000) => execFileSync("git", args, { cwd: workDir, encoding: "utf-8", timeout, stdio: ["ignore", "pipe", "pipe"], env: networkGitEnv() }).trim();
|
|
403
|
+
const tryGit = (args, timeout) => {
|
|
404
|
+
try {
|
|
405
|
+
return git(args, timeout);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const root = tryGit(["rev-parse", "--show-toplevel"]) ?? resolve(workDir);
|
|
412
|
+
const abbrev = tryGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
413
|
+
const branch = abbrev && abbrev !== "HEAD" ? abbrev : null;
|
|
414
|
+
// The upstream git itself records for the branch, else the same-named branch on `origin` if
|
|
415
|
+
// that ref exists, else the configured branch on `origin`. Named rather than assumed: a
|
|
416
|
+
// `main` checkout whose upstream is `upstream/main` must be compared against THAT.
|
|
417
|
+
let upstream = tryGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
418
|
+
if (!upstream) {
|
|
419
|
+
for (const candidate of [branch, opts.branch].filter((b) => Boolean(b))) {
|
|
420
|
+
if (tryGit(["rev-parse", "--verify", "--quiet", `origin/${candidate}`]) !== null) {
|
|
421
|
+
upstream = `origin/${candidate}`;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const remote = upstream?.includes("/") ? upstream.slice(0, upstream.indexOf("/")) : "origin";
|
|
427
|
+
const entry = fetchLog.get(root);
|
|
428
|
+
const t = now();
|
|
429
|
+
if (opts.forceFetch || !entry || t - entry.at > SYNC_FETCH_TTL_MS) {
|
|
430
|
+
try {
|
|
431
|
+
// `--quiet` so a successful fetch prints nothing to stderr worth parsing; 20s because a
|
|
432
|
+
// fetch is a network call and the relay's read timeout is above that.
|
|
433
|
+
git(["fetch", "--quiet", remote], 20_000);
|
|
434
|
+
fetchLog.set(root, { at: t, error: null, okAt: t });
|
|
435
|
+
}
|
|
436
|
+
catch (e) {
|
|
437
|
+
const err = e;
|
|
438
|
+
// git's stderr is several lines and the diagnosis is the FIRST `fatal:`/`error:` one —
|
|
439
|
+
// the tail is boilerplate ("and the repository exists.") that names nothing.
|
|
440
|
+
const lines = String(err.stderr || err.message || "")
|
|
441
|
+
.split("\n")
|
|
442
|
+
.map((l) => l.trim())
|
|
443
|
+
.filter(Boolean);
|
|
444
|
+
const detail = lines.find((l) => /^(fatal|error):/i.test(l)) ?? lines[0] ?? "fetch failed";
|
|
445
|
+
fetchLog.set(root, { at: t, error: detail.replace(/^(fatal|error):\s*/i, "").slice(0, 200), okAt: entry?.okAt ?? null });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const fetchState = fetchLog.get(root) ?? { at: t, error: null, okAt: null };
|
|
449
|
+
// Upstream may only exist AFTER the first fetch of a fresh clone — look once more.
|
|
450
|
+
if (!upstream) {
|
|
451
|
+
for (const candidate of [branch, opts.branch].filter((b) => Boolean(b))) {
|
|
452
|
+
if (tryGit(["rev-parse", "--verify", "--quiet", `origin/${candidate}`]) !== null) {
|
|
453
|
+
upstream = `origin/${candidate}`;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const localHead = tryGit(["rev-parse", "HEAD"]);
|
|
459
|
+
const remoteHead = upstream ? tryGit(["rev-parse", upstream]) : null;
|
|
460
|
+
let ahead = null;
|
|
461
|
+
let behind = null;
|
|
462
|
+
if (upstream && remoteHead) {
|
|
463
|
+
const counts = tryGit(["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
|
|
464
|
+
const m = counts?.match(/^(\d+)\s+(\d+)$/);
|
|
465
|
+
if (m) {
|
|
466
|
+
ahead = Number.parseInt(m[1], 10);
|
|
467
|
+
behind = Number.parseInt(m[2], 10);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
checked: true,
|
|
472
|
+
path: root,
|
|
473
|
+
branch,
|
|
474
|
+
upstream,
|
|
475
|
+
localHead,
|
|
476
|
+
remoteHead,
|
|
477
|
+
ahead,
|
|
478
|
+
behind,
|
|
479
|
+
fetched: fetchState.okAt !== null && t - fetchState.okAt <= SYNC_FETCH_TTL_MS,
|
|
480
|
+
fetchedAt: fetchState.okAt,
|
|
481
|
+
fetchError: fetchState.error,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
@@ -72,6 +72,49 @@ export function authenticatedCloneUrl(cloneUrl, token, username) {
|
|
|
72
72
|
const user = encodeURIComponent(username || "x-access-token");
|
|
73
73
|
return cloneUrl.replace(/^https:\/\//i, `https://${user}:${encodeURIComponent(token)}@`);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Parse a GitHub SSH welcome banner into an identity name.
|
|
77
|
+
*
|
|
78
|
+
* GitHub writes to stderr on a successful `ssh -T git@github.com`:
|
|
79
|
+
* `Hi <name>! You've successfully authenticated, but GitHub does not provide shell access.`
|
|
80
|
+
*
|
|
81
|
+
* For a deploy key the name is `<org>/<repo>`. For a user account it is the login.
|
|
82
|
+
* This function is PURE so it can be unit-tested without a network.
|
|
83
|
+
*/
|
|
84
|
+
export function parseSshIdentity(raw) {
|
|
85
|
+
// The banner arrives on stderr; strip ANSI color/style sequences (`ESC[...m`) that some SSH
|
|
86
|
+
// versions prepend. The ESC byte is expressed via String.fromCharCode rather than as a literal
|
|
87
|
+
// in a regex pattern, because Biome's noControlCharactersInRegex rule rejects control characters
|
|
88
|
+
// in regex literals (same rule that transcript-lines.ts and tmux.ts suppress with biome-ignore).
|
|
89
|
+
const ESC = String.fromCharCode(27);
|
|
90
|
+
const clean = raw.split(ESC).join("").replace(/\[[0-9;]*m/g, "").trim();
|
|
91
|
+
const m = clean.match(/^Hi\s+([^!]+)!/m);
|
|
92
|
+
return m ? m[1].trim() : null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ask the machine what git identity SSH presents for `host`.
|
|
96
|
+
*
|
|
97
|
+
* Uses `BatchMode=yes` so it never prompts for a passphrase, and `ConnectTimeout=5` so a
|
|
98
|
+
* firewall that drops packets (rather than refusing) does not stall the diagnostics response.
|
|
99
|
+
* `StrictHostKeyChecking=accept-new` avoids an interactive prompt on first connection.
|
|
100
|
+
*
|
|
101
|
+
* Never throws — every failure is an identity of `null`, because this is a transparency probe
|
|
102
|
+
* and a network hiccup must not make the diagnostics endpoint useless.
|
|
103
|
+
*/
|
|
104
|
+
export function probeGitSshIdentity(host) {
|
|
105
|
+
// `ssh -T` exits non-zero (1) even on success — GitHub's welcome message deliberately closes
|
|
106
|
+
// the connection without a shell. `spawnSync` is used rather than `execFileSync` so a non-zero
|
|
107
|
+
// exit does not throw; the content of stderr is what matters, not the exit code.
|
|
108
|
+
const result = spawnSync("ssh", ["-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=accept-new", "-o", "ConnectTimeout=5", `git@${host}`], {
|
|
109
|
+
encoding: "utf-8",
|
|
110
|
+
timeout: 10_000,
|
|
111
|
+
});
|
|
112
|
+
// stderr carries the welcome message; stdout is empty for a normal `ssh -T`.
|
|
113
|
+
const raw = String(result.stderr ?? "").slice(0, 500);
|
|
114
|
+
const identity = parseSshIdentity(raw);
|
|
115
|
+
const isDeployKey = identity === null ? null : identity.includes("/");
|
|
116
|
+
return { checked: true, host, identity, isDeployKey, raw };
|
|
117
|
+
}
|
|
75
118
|
/**
|
|
76
119
|
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
77
120
|
* — an existing checkout is left alone (no clobber). For private repos the cloud
|