@kylecheng3146/agent-ops 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/packages/cli/src/args.js +49 -5
  2. package/dist/packages/cli/src/bin.js +17 -35
  3. package/dist/packages/cli/src/cli.js +27 -2
  4. package/dist/packages/cli/src/commands/hook.js +4 -1
  5. package/dist/packages/cli/src/commands/init.js +1 -0
  6. package/dist/packages/cli/src/commands/parallel.js +16 -3
  7. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  8. package/dist/packages/cli/src/commands/update.js +1 -0
  9. package/dist/packages/cli/src/context.js +28 -3
  10. package/dist/packages/cli/src/hook-process.js +14 -3
  11. package/dist/packages/cli/src/public-plan.js +32 -2
  12. package/dist/runtime/src/adapters/claude/config.js +1 -1
  13. package/dist/runtime/src/adapters/claude/input.js +17 -0
  14. package/dist/runtime/src/adapters/claude/output.js +9 -0
  15. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  16. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  17. package/dist/runtime/src/hooks/dispatch.js +5 -0
  18. package/dist/runtime/src/install/apply.js +2 -0
  19. package/dist/runtime/src/install/doctor.js +12 -1
  20. package/dist/runtime/src/install/harness.js +16 -5
  21. package/dist/runtime/src/install/plan.js +9 -0
  22. package/dist/runtime/src/install/preauth.js +265 -0
  23. package/dist/runtime/src/install/probes.js +32 -0
  24. package/dist/runtime/src/install/uninstall.js +29 -11
  25. package/dist/runtime/src/install/update.js +1 -0
  26. package/dist/runtime/src/parallel/enter.js +43 -0
  27. package/dist/runtime/src/parallel/finish.js +2 -1
  28. package/dist/runtime/src/parallel/guard.js +20 -1
  29. package/dist/runtime/src/parallel/manage.js +38 -0
  30. package/dist/runtime/src/parallel/service.js +3 -1
  31. package/dist/runtime/src/review/execute.js +15 -7
  32. package/dist/runtime/src/review/host-sandbox.js +45 -1
  33. package/dist/runtime/src/review/probe.js +44 -12
  34. package/dist/runtime/src/review/render.js +9 -0
  35. package/docs/en/guides/configuration.md +61 -4
  36. package/docs/zh-TW/guides/configuration.md +49 -1
  37. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { FileTransaction } from "../fs/transaction.js";
8
8
  import { planHookRemoval } from "./hooks.js";
9
+ import { applyPreauth, planPreauth } from "./preauth.js";
9
10
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
10
11
  import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
11
12
  import { isHarnessId } from "./harness.js";
@@ -207,7 +208,7 @@ async function planArtifactRemoval(root, artifact) {
207
208
  expectedHash: current.hash
208
209
  };
209
210
  }
210
- async function createFullUninstallPlan(root) {
211
+ async function createFullUninstallPlan(root, options = {}) {
211
212
  const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
212
213
  if (currentManifest === null) {
213
214
  return {
@@ -262,11 +263,19 @@ async function createFullUninstallPlan(root) {
262
263
  path: MANIFEST_PATH,
263
264
  expectedHash: currentManifest.hash
264
265
  });
266
+ const preauthorization = await planPreauth({
267
+ root,
268
+ scope: manifest.scope,
269
+ harness: manifest.harness,
270
+ desired: false,
271
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
272
+ });
265
273
  return {
266
274
  installed: true,
267
275
  manifest,
268
276
  manifestHash: currentManifest.hash,
269
- operations
277
+ operations,
278
+ ...(preauthorization.length === 0 ? {} : { preauthorization })
270
279
  };
271
280
  }
272
281
  async function planLoopMarkerUpdate(root, marker, expected, remaining) {
@@ -300,7 +309,7 @@ async function planLoopMarkerUpdate(root, marker, expected, remaining) {
300
309
  }
301
310
  };
302
311
  }
303
- async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses) {
312
+ async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, planOptions = {}) {
304
313
  if (selectedHarnesses.length === 0 ||
305
314
  selectedHarnesses.some((id) => !isHarnessId(id)) ||
306
315
  new Set(selectedHarnesses).size !== selectedHarnesses.length ||
@@ -310,7 +319,7 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
310
319
  const selected = selectedSet(selectedHarnesses);
311
320
  const remaining = manifest.harness.filter((id) => !selected.has(id));
312
321
  if (remaining.length === 0) {
313
- return await createFullUninstallPlan(root);
322
+ return await createFullUninstallPlan(root, planOptions);
314
323
  }
315
324
  const operations = [];
316
325
  const removedArtifacts = manifest.artifacts.filter((artifact) => shouldRemove(artifactOwners(manifest, artifact), selected));
@@ -398,26 +407,34 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
398
407
  content: formatInstallManifest(resultingManifest),
399
408
  expectedHash: currentManifest.hash
400
409
  });
410
+ const preauthorization = await planPreauth({
411
+ root,
412
+ scope: manifest.scope,
413
+ harness: selectedHarnesses,
414
+ desired: false,
415
+ ...(planOptions.codexHome === undefined ? {} : { codexHome: planOptions.codexHome })
416
+ });
401
417
  return {
402
418
  installed: true,
403
419
  manifest,
404
420
  manifestHash: currentManifest.hash,
405
421
  operations,
406
422
  selectedHarnesses,
407
- resultingManifest
423
+ resultingManifest,
424
+ ...(preauthorization.length === 0 ? {} : { preauthorization })
408
425
  };
409
426
  }
410
- export async function createUninstallPlan(root, selectedHarnesses) {
427
+ export async function createUninstallPlan(root, selectedHarnesses, options = {}) {
411
428
  if (selectedHarnesses === undefined) {
412
- return await createFullUninstallPlan(root);
429
+ return await createFullUninstallPlan(root, options);
413
430
  }
414
431
  const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
415
432
  if (currentManifest === null) {
416
- return await createFullUninstallPlan(root);
433
+ return await createFullUninstallPlan(root, options);
417
434
  }
418
435
  const manifest = parseInstallManifest(currentManifest.content);
419
436
  const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
420
- return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses);
437
+ return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, options);
421
438
  }
422
439
  function allowedPaths(plan) {
423
440
  if (plan.manifest === null) {
@@ -559,9 +576,9 @@ async function validateUninstalled(root, manifest, resultingManifest) {
559
576
  throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", "Installation manifest still exists.");
560
577
  }
561
578
  }
562
- export async function applyUninstallPlan(root, plan) {
579
+ export async function applyUninstallPlan(root, plan, options = {}) {
563
580
  assertUninstallPlan(plan);
564
- const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses);
581
+ const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses, options);
565
582
  if (JSON.stringify(currentPlan) !== JSON.stringify(plan)) {
566
583
  throw new AgentOpsError("INVALID_UNINSTALL_PLAN", "Uninstall plan no longer matches the current managed installation.");
567
584
  }
@@ -571,4 +588,5 @@ export async function applyUninstallPlan(root, plan) {
571
588
  }
572
589
  const transaction = new FileTransaction(root);
573
590
  await transaction.apply({ operations: plan.operations }, async () => await validateUninstalled(root, manifest, plan.resultingManifest));
591
+ await applyPreauth(plan.preauthorization ?? []);
574
592
  }
@@ -124,6 +124,7 @@ export async function createUpdatePlan(options) {
124
124
  value: configPreview.migrated,
125
125
  sourceHash: configPreview.sourceHash
126
126
  },
127
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
127
128
  ...(options.worktree === undefined
128
129
  ? {}
129
130
  : { worktree: options.worktree })
@@ -0,0 +1,43 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { canonicalPath } from "./guard.js";
4
+ import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
5
+ /**
6
+ * Whether Claude's EnterWorktree may switch this session into `path`. Only the
7
+ * worktree agent-ops recorded for the session is answered for the host, so an
8
+ * allow never lets the session move its permission root to a directory it did
9
+ * not get from `worktree add`.
10
+ */
11
+ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
12
+ if (sessionId === undefined) {
13
+ return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
14
+ }
15
+ const directory = join(mainRoot, WORKTREE_DIRECTORY);
16
+ const target = await canonicalPath(path);
17
+ const name = relative(directory, target).split(sep)[0] ?? "";
18
+ const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
19
+ if (inside && (await readWorktreeRecord(target))?.sessionId === sessionId) {
20
+ return {
21
+ action: "continue",
22
+ status: "PASS",
23
+ code: "WORKTREE_ENTER_ALLOWED",
24
+ remedy: `${target} is the worktree agent-ops recorded for this session.`,
25
+ decision: "allow"
26
+ };
27
+ }
28
+ const owned = [];
29
+ for (const entry of await readdir(directory).catch(() => [])) {
30
+ if ((await readWorktreeRecord(join(directory, entry)))?.sessionId === sessionId) {
31
+ owned.push(join(directory, entry));
32
+ }
33
+ }
34
+ return {
35
+ action: "block",
36
+ status: "FAIL",
37
+ code: "WORKTREE_ENTER_DENIED",
38
+ remedy: `EnterWorktree may enter only a worktree this session owns. ` +
39
+ (owned.length > 0
40
+ ? `Yours: ${owned.join(", ")}.`
41
+ : `None yet: run agent-ops worktree add <name> --session ${sessionId} from ${mainRoot}.`)
42
+ };
43
+ }
@@ -251,7 +251,8 @@ export async function finishWorktree(deps, options) {
251
251
  await deps.tasks(record.path, base).complete(task.task.id, {});
252
252
  }
253
253
  catch (error) {
254
- throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)}`);
254
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
255
+ "A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
255
256
  }
256
257
  }
257
258
  const worktreeGate = await deps.gate(record.path, worktreeConfig);
@@ -1,6 +1,6 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative, sep } from "node:path";
3
- import { insideWorktreeDirectory } from "./service.js";
3
+ import { insideWorktreeDirectory, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
4
4
  /** The main checkout of the repository `runner` runs in, or null. */
5
5
  export async function resolveMainRoot(runner) {
6
6
  const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
@@ -47,6 +47,25 @@ ensureWorktree) {
47
47
  for (const path of paths) {
48
48
  const target = await canonicalPath(path);
49
49
  const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
50
+ if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
51
+ // A worktree belongs to the session that made it: writing into another
52
+ // session's mixes two tasks' changes into one review and one merge.
53
+ const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
54
+ const owner = (await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name)))?.sessionId;
55
+ if (owner !== undefined && owner !== sessionId) {
56
+ let own = "";
57
+ if (ensureWorktree !== undefined) {
58
+ own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
59
+ }
60
+ return {
61
+ action: "block",
62
+ status: "FAIL",
63
+ code: "WORKTREE_OWNED_BY_OTHER_SESSION",
64
+ remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
65
+ `this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
66
+ };
67
+ }
68
+ }
50
69
  if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
51
70
  let failure = "";
52
71
  if (sessionId !== undefined && ensureWorktree !== undefined) {
@@ -154,6 +154,44 @@ export async function resumeWorktree(deps, options) {
154
154
  await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
155
155
  return record;
156
156
  }
157
+ /**
158
+ * Commits everything in the calling session's own worktree. Git runs from
159
+ * here rather than the shell, where a host cannot prove a command stays inside
160
+ * the worktree; that is also why it refuses anything that is not the caller's.
161
+ *
162
+ * ponytail: stages all changes, so an untracked file Git does not ignore goes
163
+ * in too; narrow it with a path list if that ever commits something unwanted.
164
+ */
165
+ export async function commitWorktree(deps, options) {
166
+ const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
167
+ if (currentRoot === mainRoot) {
168
+ throw manageError("WORKTREE_COMMIT_MAIN", `worktree commit works only inside an agent-ops worktree; this is the main checkout (${mainRoot}).`);
169
+ }
170
+ const record = await readWorktreeRecord(currentRoot);
171
+ if (record === null) {
172
+ throw manageError("WORKTREE_COMMIT_UNRECORDED", `${currentRoot} is not an agent-ops worktree; see agent-ops worktree list.`);
173
+ }
174
+ if (options.sessionId === undefined) {
175
+ throw manageError("WORKTREE_COMMIT_SESSION_REQUIRED", "Cannot tell which session is committing; pass --session <id> or set AGENT_OPS_SESSION_ID.");
176
+ }
177
+ if (record.sessionId !== options.sessionId) {
178
+ throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
179
+ }
180
+ const step = async (args, code, message) => {
181
+ const result = await deps.git(record.path, args);
182
+ if (result.exitCode !== 0) {
183
+ const detail = result.stderr.trim().split("\n")[0];
184
+ throw manageError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
185
+ }
186
+ return result.stdout.trim();
187
+ };
188
+ await step(["add", "-A"], "WORKTREE_COMMIT_FAILED", "Git could not stage the worktree.");
189
+ if ((await deps.git(record.path, ["diff", "--cached", "--quiet"])).exitCode === 0) {
190
+ throw manageError("WORKTREE_COMMIT_NOTHING", `${record.path} has no changes to commit.`);
191
+ }
192
+ await step(["commit", "-m", options.message], "WORKTREE_COMMIT_FAILED", "Git refused the commit.");
193
+ return { record, commit: await step(["rev-parse", "HEAD"], "WORKTREE_COMMIT_FAILED", "Git could not read the new commit.") };
194
+ }
157
195
  /**
158
196
  * Deletes a worktree and its branch. Work that exists nowhere else — an
159
197
  * uncommitted change or a commit the target lacks — needs `force`.
@@ -291,7 +291,9 @@ export async function addWorktree(deps, options) {
291
291
  const mainTrust = await deps.trust.status(mainRoot, mainConfig);
292
292
  const setupSteps = mainConfig.worktree?.setup ?? [];
293
293
  if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
294
- throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands; run agent-ops trust grant in the main checkout first.");
294
+ throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands and the main checkout is not trusted: trust is bound to the config hash, " +
295
+ "so a hand edit of .agent-ops/config.json (adding worktree.setup, say) leaves it untrusted. " +
296
+ "Trust is the user's to grant, once: run agent-ops update in the main checkout, which lists the commands it will trust and asks.");
295
297
  }
296
298
  await ensureExcluded(deps, mainRoot, commonDir);
297
299
  await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
@@ -8,7 +8,7 @@ import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage,
8
8
  import { buildTargetInvocation } from "./invocation.js";
9
9
  import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
10
10
  import { detectHostTarget, planReviewTargets } from "./roles.js";
11
- import { detectHostRestriction } from "./host-sandbox.js";
11
+ import { detectHostRestriction, isLoginFailure, isNetworkFailure } from "./host-sandbox.js";
12
12
  import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
13
13
  import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
14
14
  import { extractUsage, sumUsage } from "./usage.js";
@@ -143,10 +143,14 @@ function preflightUnavailableReason(result) {
143
143
  return "timeout";
144
144
  case "unauthenticated":
145
145
  return "login-required";
146
+ case "network-unreachable":
147
+ return "network-unreachable";
146
148
  case "ineligible":
147
149
  return "capability-unavailable";
148
150
  case "capability-unavailable":
149
151
  return "capability-unavailable";
152
+ case "probe-failed":
153
+ return "probe-failed";
150
154
  }
151
155
  }
152
156
  /**
@@ -155,7 +159,7 @@ function preflightUnavailableReason(result) {
155
159
  * suppressed under `--json`: a machine consumer would otherwise be left with
156
160
  * the bare authentication guess this exists to qualify. Never evidence.
157
161
  */
158
- function firstComplaint(...streams) {
162
+ export function firstComplaint(...streams) {
159
163
  for (const stream of streams) {
160
164
  const line = redactSecrets(stream)
161
165
  .split(/\r?\n/u)
@@ -171,9 +175,10 @@ function rejectedCallReason(output) {
171
175
  if (/\b(?:quota|rate limit|usage limit|too many requests)\b/iu.test(output)) {
172
176
  return "quota-exhausted";
173
177
  }
174
- if (/\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b/iu.test(output) ||
175
- /\b(?:invalid|expired)\s+(?:api key|token|credential)/iu.test(output) ||
176
- /\b401\b/u.test(output)) {
178
+ if (isNetworkFailure(output)) {
179
+ return "network-unreachable";
180
+ }
181
+ if (isLoginFailure(output)) {
177
182
  return "login-required";
178
183
  }
179
184
  return "capability-unavailable";
@@ -735,15 +740,18 @@ export function createReviewExecutor(options) {
735
740
  attempts: []
736
741
  };
737
742
  }
738
- const result = await options.preflightTarget(target, {
743
+ const answer = await options.preflightTarget(target, {
739
744
  timeoutMs: Math.max(1, remainingMs())
740
745
  });
746
+ const result = typeof answer === "string" ? answer : answer.result;
741
747
  if (result === "ok") {
742
748
  preflight.push({ target, status: "PASS" });
743
749
  continue;
744
750
  }
745
751
  const reason = preflightUnavailableReason(result);
746
- const diagnostic = `target preflight returned ${result}`;
752
+ const complaint = typeof answer === "string" ? undefined : answer.diagnostic;
753
+ const diagnostic = `target preflight returned ${result}` +
754
+ (complaint === undefined ? "" : `: ${complaint}`);
747
755
  preflight.push({ target, status: "NOT_RUN", reason, diagnostic });
748
756
  report(`${target}: preflight ${reason} (${diagnostic})`);
749
757
  return {
@@ -1,7 +1,51 @@
1
- import { createServer } from "node:net";
1
+ import { connect, createServer } from "node:net";
2
2
  /** Targets that need a loopback listener of their own to answer at all. */
3
3
  export const BIND_DEPENDENT_TARGETS = ["agy"];
4
4
  const BIND_PROBE_TIMEOUT_MS = 2_000;
5
+ /**
6
+ * A reviewer that cannot reach its API host says so in its own words. Reading
7
+ * that as "not logged in" sent users to re-login an authenticated CLI when the
8
+ * host sandbox was what withheld the network.
9
+ */
10
+ const NETWORK_FAILURE = /\b(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH)\b|getaddrinfo|fetch failed|network is unreachable|could not resolve host|no such host|dial tcp/iu;
11
+ export function isNetworkFailure(output) {
12
+ return NETWORK_FAILURE.test(output);
13
+ }
14
+ /**
15
+ * Only output that says so is a login problem. A CLI that dies for any other
16
+ * reason (a sandbox denying its state directory, a crash) used to be reported
17
+ * as "not logged in" too, which sent users to re-login a working install.
18
+ */
19
+ const LOGIN_FAILURE = /\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b|\b(?:invalid|expired)\s+(?:api key|token|credential)|\b401\b/iu;
20
+ export function isLoginFailure(output) {
21
+ return LOGIN_FAILURE.test(output);
22
+ }
23
+ /** The host each reviewer talks to, so a reachability check tests its real path. */
24
+ export const TARGET_API_HOST = {
25
+ agy: "cloudcode-pa.googleapis.com",
26
+ codex: "api.openai.com",
27
+ claude: "api.anthropic.com"
28
+ };
29
+ export const REACH_TIMEOUT_MS = 2_000;
30
+ /**
31
+ * DNS plus a TCP connect to `host:443`, nothing sent. Diagnostic only: it
32
+ * refines a failed probe's reason and never gates a review, so a proxy-only
33
+ * network that fails it still gets the review attempted.
34
+ */
35
+ export async function probeHostReachable(host, port = 443) {
36
+ return await new Promise((resolve) => {
37
+ const socket = connect({ host, port });
38
+ const finish = (value) => {
39
+ clearTimeout(timer);
40
+ socket.destroy();
41
+ resolve(value);
42
+ };
43
+ const timer = setTimeout(() => finish(false), REACH_TIMEOUT_MS);
44
+ timer.unref();
45
+ socket.once("connect", () => finish(true));
46
+ socket.once("error", () => finish(false));
47
+ });
48
+ }
5
49
  /**
6
50
  * Opens and immediately closes a loopback listener on an ephemeral port.
7
51
  * Cheap enough to run before every review, and it exercises exactly the
@@ -1,10 +1,16 @@
1
1
  import { runVerificationCommand } from "../verify/spawn.js";
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { extractFinalMessage } from "./extract.js";
6
- import { isolatedReviewEnvironment } from "./execute.js";
6
+ import { firstComplaint, isolatedReviewEnvironment } from "./execute.js";
7
+ import { isLoginFailure, isNetworkFailure, probeHostReachable, TARGET_API_HOST } from "./host-sandbox.js";
7
8
  import { buildProbeInvocation } from "./invocation.js";
9
+ /** A log's last non-empty line: where a CLI that logs to a file puts its error. */
10
+ async function lastLogLine(path) {
11
+ const text = await readFile(path, "utf8").catch(() => "");
12
+ return text.split(/\r?\n/u).filter((line) => line.trim().length > 0).at(-1) ?? "";
13
+ }
8
14
  const PROBE_PROMPT = "Reply with the single word OK and nothing else.";
9
15
  /**
10
16
  * Matches the review timeout rather than being "quick": codex at high
@@ -29,6 +35,9 @@ export function probeTimeoutMs(requested) {
29
35
  * is not evidence at all.
30
36
  */
31
37
  export async function probeReviewTarget(target, options) {
38
+ return (await probeReviewTargetDetailed(target, options)).result;
39
+ }
40
+ export async function probeReviewTargetDetailed(target, options) {
32
41
  const deep = options.deep === true;
33
42
  const directory = deep
34
43
  ? await mkdtemp(join(tmpdir(), "agent-ops-review-probe-"))
@@ -66,7 +75,7 @@ export async function probeReviewTarget(target, options) {
66
75
  : {})
67
76
  });
68
77
  if (invocation === undefined) {
69
- return "ineligible";
78
+ return { result: "ineligible" };
70
79
  }
71
80
  const spawned = await runVerificationCommand({
72
81
  id: `review-probe-${target}`,
@@ -88,21 +97,44 @@ export async function probeReviewTarget(target, options) {
88
97
  : {})
89
98
  });
90
99
  if (spawned.failureClass === "missing-executable") {
91
- return "missing-executable";
100
+ return { result: "missing-executable" };
92
101
  }
93
102
  if (spawned.timedOut) {
94
- return "timeout";
103
+ return { result: "timeout" };
95
104
  }
96
- if (/(?:operation not permitted|permission denied|bind(?:ing)?[^\n]*(?:failed|denied))/iu.test(`${spawned.stderr}\n${spawned.stdout}`)) {
97
- return "capability-unavailable";
105
+ const output = `${spawned.stderr}\n${spawned.stdout}`;
106
+ // The target's own words, so a failure is never reduced to a guess. agy
107
+ // writes its errors to the log file the probe hands it, not to stderr.
108
+ const complaint = async () => firstComplaint(spawned.stderr, spawned.stdout) ??
109
+ (deep && target === "agy"
110
+ ? firstComplaint(await lastLogLine(join(directory, "agy.log")))
111
+ : undefined);
112
+ if (/(?:operation not permitted|permission denied|bind(?:ing)?[^\n]*(?:failed|denied))/iu.test(output)) {
113
+ return { result: "capability-unavailable", diagnostic: await complaint() };
98
114
  }
99
115
  if (!deep) {
100
- return spawned.status === "PASS" ? "ok" : "unauthenticated";
116
+ return spawned.status === "PASS"
117
+ ? { result: "ok" }
118
+ : { result: "probe-failed", diagnostic: await complaint() };
119
+ }
120
+ if (spawned.status === "PASS" &&
121
+ extractFinalMessage(target, spawned.stdout) !== undefined) {
122
+ return { result: "ok" };
123
+ }
124
+ // An unreachable API host outranks whatever the CLI printed: a sandbox that
125
+ // withholds the network makes an authenticated CLI say "not logged in".
126
+ if (isNetworkFailure(output)) {
127
+ return { result: "network-unreachable", diagnostic: await complaint() };
128
+ }
129
+ if (!await (options.reachable ?? probeHostReachable)(TARGET_API_HOST[target])) {
130
+ return { result: "network-unreachable", diagnostic: await complaint() };
101
131
  }
102
- return spawned.status === "PASS" &&
103
- extractFinalMessage(target, spawned.stdout) !== undefined
104
- ? "ok"
105
- : "unauthenticated";
132
+ const diagnostic = await complaint();
133
+ // Login is what the output says it is; anything else is a probe that could
134
+ // not run, and the diagnostic is all the evidence there is.
135
+ return isLoginFailure(`${output}\n${diagnostic ?? ""}`)
136
+ ? { result: "unauthenticated", diagnostic }
137
+ : { result: "probe-failed", diagnostic };
106
138
  }
107
139
  finally {
108
140
  if (deep) {
@@ -83,6 +83,15 @@ export function renderReviewResult(result) {
83
83
  result.attempts?.some((attempt) => attempt.reason === "login-required")) {
84
84
  lines.push("Run: agent-ops doctor --check-auth to verify target authentication.");
85
85
  }
86
+ if (result.reason === "network-unreachable" ||
87
+ result.attempts?.some((attempt) => attempt.reason === "network-unreachable")) {
88
+ lines.push("The reviewer could not reach the network. This is not a login " +
89
+ "problem: run agent-ops outside the sandbox, or grant it escalated execution.");
90
+ }
91
+ if (result.reason === "probe-failed") {
92
+ lines.push("The reviewer CLI failed its probe for the reason quoted above. This " +
93
+ "is not a login problem: fix that cause instead of logging in again.");
94
+ }
86
95
  // Evidence is pinned to the source it was produced from, so any edit to a
87
96
  // changed file after the verifier ran — a doc rewritten by a later step
88
97
  // counts — voids it. Without the next command the caller reads
@@ -130,10 +130,21 @@ never rewritten.
130
130
  targets are permitted, `--yes` decides whether to spend money now.
131
131
 
132
132
  Reviewer failures are classified conservatively from their output: recognizable
133
- authentication messages become `login-required`, quota messages become
134
- `quota-exhausted`, and other non-zero exits become `capability-unavailable`.
135
- The review suggests `doctor --check-auth` only when an authentication failure
136
- was detected. Confirm target authentication with:
133
+ network errors (`ENOTFOUND`, `fetch failed`, `getaddrinfo` and similar) become
134
+ `network-unreachable`, authentication messages become `login-required`, quota
135
+ messages become `quota-exhausted`, and other non-zero exits become
136
+ `capability-unavailable`. A sandbox that withholds the network therefore is not
137
+ reported as a logged-out CLI: `network-unreachable` tells you to run outside the
138
+ sandbox instead of running `<target> login`. When a `--check-auth` probe or the
139
+ review preflight finds a target unauthenticated, a 2-second TCP connection to
140
+ that target's API host on port 443 (`cloudcode-pa.googleapis.com` for agy,
141
+ `api.openai.com` for codex, `api.anthropic.com` for claude) decides between the
142
+ two; it only refines the reported reason and never blocks a review. The review suggests
143
+ `doctor --check-auth` only when an authentication failure was detected. A probe
144
+ that fails without network or login wording is `probe-failed`, never
145
+ `login-required`: the preflight record and `doctor` quote the target's own first
146
+ line (for agy, the last line of its log file when stderr is empty) so the real
147
+ cause is visible instead of guessed. Confirm target authentication with:
137
148
 
138
149
  ```bash
139
150
  agent-ops doctor # presence only: no tokens, no network
@@ -196,6 +207,52 @@ evidence. Give each editing conversation its own worktree instead:
196
207
  - The session's completion gate follows it: Claude Code enters the worktree
197
208
  with EnterWorktree, and a host that cannot move a session (agy) is judged by
198
209
  the worktree's gate through a redirect the main checkout records.
210
+ - A command knows its session from `--session`, then `AGENT_OPS_SESSION_ID`,
211
+ then `CODEX_THREAD_ID`, then the id a SessionStart recorded for the
212
+ checkout. Claude Code's SessionStart appends `AGENT_OPS_SESSION_ID` to its
213
+ `CLAUDE_ENV_FILE`, so every later Bash command carries it. When a different
214
+ session started in the same checkout within fifteen minutes, the recorded id
215
+ is contested: `task create`, `attach` and `status` fail with
216
+ `SESSION_ID_AMBIGUOUS` instead of acting as the wrong session.
217
+ - A worktree belongs to the session that created it: a file write into
218
+ another session's worktree is denied (`WORKTREE_OWNED_BY_OTHER_SESSION`),
219
+ naming the session's own worktree. A session with no baseline (its
220
+ SessionStart never arrived) gets one at its first tool call, never at Stop.
221
+ - Inside a worktree the project config is the main checkout's, so a branch
222
+ cannot change the gate, verifiers or trust that judge it before merging; a
223
+ worktree of a repository whose main checkout has no config uses its own.
224
+ - `agent-ops worktree commit --message <text>` stages and commits every change
225
+ in the current worktree. Git runs inside agent-ops, not the shell, so a host
226
+ that cannot prove a shell command stays inside the worktree still lets the
227
+ session commit; it refuses the main checkout, a worktree another session
228
+ owns, a caller it cannot identify and an empty change. It stages everything
229
+ Git does not ignore, so keep stray files out of the worktree.
230
+ - In auto mode the Claude PreToolUse hook also handles `EnterWorktree`: a
231
+ switch into exactly a worktree recorded for the calling session is allowed
232
+ without Claude Code's own confirmation, and any other path is denied
233
+ (`WORKTREE_ENTER_DENIED`, naming the session's worktrees). Run `agent-ops
234
+ update` once so the hook's matcher includes `EnterWorktree`; `doctor`
235
+ reports an older matcher as registration drift.
236
+ - With `worktree.mode` auto, `init` and `update` also pre-authorize what a
237
+ session runs itself, and the plan lists it before you confirm: in
238
+ `.claude/settings.local.json`, `permissions.allow` for `agent-ops task`,
239
+ `verify`, `review`, `worktree` and `doctor` and `sandbox.excludedCommands`
240
+ for `agent-ops review` (your other entries are kept, and the plan describes
241
+ the file instead of printing it); and, when the CLI can see Codex's home
242
+ (`CODEX_HOME`, else `~/.codex`), `rules/agent-ops.rules` with allow rules
243
+ for `agent-ops review` and `doctor --check-auth`, with and without
244
+ `AGENT_OPS_HOST=`. `trust`, `allow-stop`, `init`, `update` and `uninstall`
245
+ are never pre-authorized. Turning auto mode off, or `uninstall`, takes
246
+ exactly those entries back out; a rules file of that name agent-ops did not
247
+ write is left alone.
248
+ - `review` and `doctor` take `--output <file>`: the JSON envelope is written
249
+ there (mode 0600) and one summary line is printed. Use it instead of a shell
250
+ redirect, which Codex wraps in `zsh -lc` and can only allow by matching the
251
+ whole command string.
252
+ - Trust is bound to the config hash, so a hand edit of `.agent-ops/config.json`
253
+ (adding `worktree.setup`, say) leaves the repository untrusted and
254
+ `WORKTREE_SETUP_UNTRUSTED` stops the next worktree. The one grant is the
255
+ user's: `agent-ops update` lists the commands it will trust and asks once.
199
256
  - In a worktree, verify and review each task but do not run `task complete`
200
257
  (Claude Code refuses it inside an isolated worktree session). `agent-ops
201
258
  worktree finish <name>` completes every non-archived task in the worktree,
@@ -111,9 +111,19 @@ task ID 必須明確指定,且永遠使用該 task 的原始 criteria;不能
111
111
  每次執行 review 仍需 `--yes`:init 的勾選決定「允許哪些目標」,
112
112
  `--yes` 決定「現在是否要花錢」。
113
113
 
114
- Reviewer 失敗會依輸出採保守分類:可辨識的認證訊息為 `login-required`,
114
+ Reviewer 失敗會依輸出採保守分類:網路錯誤(`ENOTFOUND`、`fetch failed`、
115
+ `getaddrinfo` 等)為 `network-unreachable`,可辨識的認證訊息為 `login-required`,
115
116
  quota 訊息為 `quota-exhausted`,其他非零退出則為 `capability-unavailable`。
117
+ 因此沙盒擋住網路時不會被誤報成 CLI 未登入:`network-unreachable` 會提示改在
118
+ 沙盒外執行,而不是執行 `<target> login`。當 `--check-auth` 探測或 review
119
+ preflight 判定目標未認證時,會再對該目標的 API 主機(agy 為
120
+ `cloudcode-pa.googleapis.com`、codex 為 `api.openai.com`、claude 為
121
+ `api.anthropic.com`)的 443 埠做 2 秒 TCP 連線測試,據此在兩種原因間擇一;
122
+ 它只修正回報的原因,不會阻擋 review。
116
123
  只有偵測到認證失敗時,review 才會建議執行 `doctor --check-auth`。
124
+ 探測失敗但輸出沒有網路或登入字樣時為 `probe-failed`,不會被當成
125
+ `login-required`:preflight 紀錄與 `doctor` 會引述目標自己的第一行輸出
126
+ (agy 在 stderr 為空時取其 log 檔最後一行),讓真正原因可見而非猜測。
117
127
  可用以下指令確認目標的認證狀態:
118
128
 
119
129
  ```bash
@@ -170,6 +180,44 @@ fingerprint,因此兩個對話修改同一個 checkout 會互相作廢對方
170
180
  - Session 的 completion gate 會跟著它:Claude Code 以 EnterWorktree 進入
171
181
  worktree;無法移動 session 的 host(agy)則透過主 checkout 記錄的 redirect,
172
182
  由 worktree 的 gate 判定。
183
+ - 指令依序以 `--session`、`AGENT_OPS_SESSION_ID`、`CODEX_THREAD_ID`、SessionStart
184
+ 為該 checkout 記錄的 id 得知自己的 session。Claude Code 的 SessionStart 會把
185
+ `AGENT_OPS_SESSION_ID` 寫入 `CLAUDE_ENV_FILE`,之後每個 Bash 指令都帶有它。
186
+ 十五分鐘內若有另一個 session 在同一 checkout 啟動,記錄的 id 視為有爭議:
187
+ `task create`、`attach`、`status` 會以 `SESSION_ID_AMBIGUOUS` 失敗,而不是以
188
+ 錯誤的 session 行動。
189
+ - Worktree 屬於建立它的 session:寫入別的 session 的 worktree 會被拒絕
190
+ (`WORKTREE_OWNED_BY_OTHER_SESSION`),並指出該 session 自己的 worktree。沒有
191
+ baseline 的 session(SessionStart 沒送達)會在第一次呼叫工具時補建,絕不會在
192
+ Stop 時補建。
193
+ - 在 worktree 內,專案 config 一律取自主 checkout,因此分支無法在合併前改變判定
194
+ 它自己的 gate、verifier 或 trust;主 checkout 沒有 config 時才使用 worktree 自己的。
195
+ - `agent-ops worktree commit --message <text>` 會暫存並 commit 目前 worktree 的
196
+ 所有變更。Git 由 agent-ops 自己執行而非經過 shell,因此即使 host 無法證明某條
197
+ shell 指令停留在 worktree 內,session 仍能 commit;它會拒絕主 checkout、別的
198
+ session 擁有的 worktree、無法辨識的呼叫者,以及沒有變更的情況。它會暫存所有
199
+ Git 未忽略的檔案,所以請不要把雜檔留在 worktree 裡。
200
+ - 在 auto 模式下,Claude 的 PreToolUse hook 也處理 `EnterWorktree`:切入恰好是
201
+ 為呼叫 session 記錄的 worktree 時,不必經過 Claude Code 自己的確認即放行;其他
202
+ 任何路徑一律拒絕(`WORKTREE_ENTER_DENIED`,並列出該 session 的 worktree)。請
203
+ 執行一次 `agent-ops update`,讓 hook 的 matcher 包含 `EnterWorktree`;`doctor`
204
+ 會把較舊的 matcher 回報為註冊漂移。
205
+ - 當 `worktree.mode` 為 auto,`init` 與 `update` 也會預先授權 session 自己會執行的
206
+ 指令,並在你確認前先列在計畫裡:在 `.claude/settings.local.json` 寫入
207
+ `agent-ops task`、`verify`、`review`、`worktree`、`doctor` 的 `permissions.allow`,
208
+ 以及 `agent-ops review` 的 `sandbox.excludedCommands`(你原有的項目會保留,計畫只
209
+ 描述該檔案而不印出內容);若 CLI 找得到 Codex 的 home(`CODEX_HOME`,否則
210
+ `~/.codex`),另寫入 `rules/agent-ops.rules`,內含 `agent-ops review` 與
211
+ `doctor --check-auth` 的 allow 規則(含與不含 `AGENT_OPS_HOST=`)。`trust`、
212
+ `allow-stop`、`init`、`update`、`uninstall` 永遠不會被預先授權。關閉 auto 模式或
213
+ `uninstall` 會精確移除這些項目;同名但不是 agent-ops 寫的規則檔不會被動到。
214
+ - `review` 與 `doctor` 支援 `--output <file>`:JSON envelope 寫入該檔(權限 0600),
215
+ 並只印出一行摘要。請用它取代 shell 重導向;Codex 會把重導向包成 `zsh -lc`,只能
216
+ 以整串指令比對來放行。
217
+ - Trust 綁定 config hash,因此手動編輯 `.agent-ops/config.json`(例如加入
218
+ `worktree.setup`)會讓 repository 變成未受信任,`WORKTREE_SETUP_UNTRUSTED` 會擋下
219
+ 下一個 worktree。唯一的授權點屬於使用者:`agent-ops update` 會列出它將信任的指令並
220
+ 詢問一次。
173
221
  - 在 worktree 裡對每個 task 執行 verify 與 review,但不要執行 `task complete`
174
222
  (Claude Code 在隔離的 worktree session 中會拒絕它)。`agent-ops worktree finish
175
223
  <name>` 會在任何 rebase 之前 complete worktree 內每個未封存的 task,先 subtask