@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.
- package/dist/packages/cli/src/args.js +49 -5
- package/dist/packages/cli/src/bin.js +17 -35
- package/dist/packages/cli/src/cli.js +27 -2
- package/dist/packages/cli/src/commands/hook.js +4 -1
- package/dist/packages/cli/src/commands/init.js +1 -0
- package/dist/packages/cli/src/commands/parallel.js +16 -3
- package/dist/packages/cli/src/commands/uninstall.js +3 -2
- package/dist/packages/cli/src/commands/update.js +1 -0
- package/dist/packages/cli/src/context.js +28 -3
- package/dist/packages/cli/src/hook-process.js +14 -3
- package/dist/packages/cli/src/public-plan.js +32 -2
- package/dist/runtime/src/adapters/claude/config.js +1 -1
- package/dist/runtime/src/adapters/claude/input.js +17 -0
- package/dist/runtime/src/adapters/claude/output.js +9 -0
- package/dist/runtime/src/hooks/codex-loop.js +67 -7
- package/dist/runtime/src/hooks/completion-gate.js +27 -2
- package/dist/runtime/src/hooks/dispatch.js +5 -0
- package/dist/runtime/src/install/apply.js +2 -0
- package/dist/runtime/src/install/doctor.js +12 -1
- package/dist/runtime/src/install/harness.js +16 -5
- package/dist/runtime/src/install/plan.js +9 -0
- package/dist/runtime/src/install/preauth.js +265 -0
- package/dist/runtime/src/install/probes.js +32 -0
- package/dist/runtime/src/install/uninstall.js +29 -11
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/parallel/enter.js +43 -0
- package/dist/runtime/src/parallel/finish.js +2 -1
- package/dist/runtime/src/parallel/guard.js +20 -1
- package/dist/runtime/src/parallel/manage.js +38 -0
- package/dist/runtime/src/parallel/service.js +3 -1
- package/dist/runtime/src/review/execute.js +15 -7
- package/dist/runtime/src/review/host-sandbox.js +45 -1
- package/dist/runtime/src/review/probe.js +44 -12
- package/dist/runtime/src/review/render.js +9 -0
- package/docs/en/guides/configuration.md +61 -4
- package/docs/zh-TW/guides/configuration.md +49 -1
- package/package.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile as execFileCallback } from "node:child_process";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
+
import { appendFile, lstat, realpath } from "node:fs/promises";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { applyManagedBlock } from "../fs/managed-block.js";
|
|
6
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
7
|
import { evaluateGuardrail } from "../guardrails/evaluate.js";
|
|
7
8
|
import { appendLocalLog } from "../logging/local-log.js";
|
|
8
9
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
@@ -394,18 +395,29 @@ function parseSessionMarker(source) {
|
|
|
394
395
|
}
|
|
395
396
|
const session = source.match(/^Session: (\S+)$/mu)?.[1];
|
|
396
397
|
const task = source.match(/^Task: (\S+)$/mu)?.[1];
|
|
398
|
+
const contested = /^Contested: yes$/mu.test(source);
|
|
397
399
|
return {
|
|
398
400
|
...(session === undefined ? {} : { sessionId: session }),
|
|
399
|
-
...(task === undefined ? {} : { taskId: task })
|
|
401
|
+
...(task === undefined ? {} : { taskId: task }),
|
|
402
|
+
...(contested ? { contested } : {})
|
|
400
403
|
};
|
|
401
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* A different session starting inside this window means two are open in one
|
|
407
|
+
* checkout.
|
|
408
|
+
*
|
|
409
|
+
* ponytail: a session idle for longer than this is not noticed; hosts that
|
|
410
|
+
* inject their own id (Claude Code, Codex) never depend on the marker at all.
|
|
411
|
+
*/
|
|
412
|
+
const CONTEST_WINDOW_MS = 15 * 60 * 1000;
|
|
402
413
|
/**
|
|
403
414
|
* The bridge the CLI cannot build for itself: a hook is told the session id,
|
|
404
415
|
* a command run inside that session is not. Recording it here lets
|
|
405
416
|
* `agent-ops task attach` default to the session actually running.
|
|
406
417
|
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
418
|
+
* The last writer wins, but a different session replacing a recent one marks
|
|
419
|
+
* the checkout contested, so a command that has to guess its session refuses
|
|
420
|
+
* instead of acting as the wrong one (`resolveCommandSessionId`).
|
|
409
421
|
*/
|
|
410
422
|
export async function writeSessionMarker(options) {
|
|
411
423
|
const path = loopPath(options.root, options.harness, "loop-state.md");
|
|
@@ -414,12 +426,22 @@ export async function writeSessionMarker(options) {
|
|
|
414
426
|
const baseline = source ?? "# Loop state\n";
|
|
415
427
|
const previous = parseSessionMarker(source);
|
|
416
428
|
const taskId = options.taskId ?? previous.taskId;
|
|
429
|
+
const replaced = previous.sessionId !== undefined &&
|
|
430
|
+
previous.sessionId !== options.sessionId;
|
|
431
|
+
const age = replaced
|
|
432
|
+
? (options.now?.() ?? Date.now()) -
|
|
433
|
+
await lstat(path).then((status) => status.mtimeMs, () => 0)
|
|
434
|
+
: 0;
|
|
435
|
+
const contested = replaced
|
|
436
|
+
? age < CONTEST_WINDOW_MS
|
|
437
|
+
: previous.contested === true;
|
|
417
438
|
await writePrivateFile(path, applyManagedBlock(baseline, {
|
|
418
439
|
id: LOOP_SESSION_ID,
|
|
419
440
|
version: 1,
|
|
420
441
|
content: [
|
|
421
442
|
`Session: ${options.sessionId}`,
|
|
422
|
-
...(taskId === undefined ? [] : [`Task: ${taskId}`])
|
|
443
|
+
...(taskId === undefined ? [] : [`Task: ${taskId}`]),
|
|
444
|
+
...(contested ? ["Contested: yes"] : [])
|
|
423
445
|
].join("\n")
|
|
424
446
|
}), options.root);
|
|
425
447
|
});
|
|
@@ -430,6 +452,9 @@ export async function writeSessionMarker(options) {
|
|
|
430
452
|
* written marker wins, because a checkout may carry both.
|
|
431
453
|
*/
|
|
432
454
|
export async function readRecordedSessionId(root) {
|
|
455
|
+
return (await readRecordedSession(root))?.sessionId;
|
|
456
|
+
}
|
|
457
|
+
async function readRecordedSession(root) {
|
|
433
458
|
const candidates = [];
|
|
434
459
|
for (const harness of ["claude", "codex"]) {
|
|
435
460
|
const path = loopPath(root, harness, "loop-state.md");
|
|
@@ -439,13 +464,40 @@ export async function readRecordedSessionId(root) {
|
|
|
439
464
|
continue;
|
|
440
465
|
}
|
|
441
466
|
const status = await lstat(path);
|
|
442
|
-
candidates.push({
|
|
467
|
+
candidates.push({
|
|
468
|
+
sessionId: marker.sessionId,
|
|
469
|
+
contested: marker.contested === true,
|
|
470
|
+
at: status.mtimeMs
|
|
471
|
+
});
|
|
443
472
|
}
|
|
444
473
|
catch {
|
|
445
474
|
continue;
|
|
446
475
|
}
|
|
447
476
|
}
|
|
448
|
-
return candidates.sort((left, right) => right.at - left.at)[0]
|
|
477
|
+
return candidates.sort((left, right) => right.at - left.at)[0];
|
|
478
|
+
}
|
|
479
|
+
/** The id the host injected into this command's environment, if any. */
|
|
480
|
+
export function sessionIdFromEnvironment(env = process.env) {
|
|
481
|
+
return [env.AGENT_OPS_SESSION_ID, env.CODEX_THREAD_ID]
|
|
482
|
+
.find((value) => value !== undefined && value !== "");
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Who is running this command: the injected id, else the one a SessionStart
|
|
486
|
+
* recorded for this checkout. A recorded id another session has since
|
|
487
|
+
* contested is a guess, and acting as the wrong session is worse than
|
|
488
|
+
* stopping.
|
|
489
|
+
*/
|
|
490
|
+
export async function resolveCommandSessionId(root, env = process.env) {
|
|
491
|
+
const injected = sessionIdFromEnvironment(env);
|
|
492
|
+
if (injected !== undefined) {
|
|
493
|
+
return injected;
|
|
494
|
+
}
|
|
495
|
+
const recorded = await readRecordedSession(root);
|
|
496
|
+
if (recorded?.contested === true) {
|
|
497
|
+
throw new AgentOpsError("SESSION_ID_AMBIGUOUS", "Another session started in this checkout recently, so its recorded session id may not be yours. " +
|
|
498
|
+
"Pass --session <id>, or set AGENT_OPS_SESSION_ID.");
|
|
499
|
+
}
|
|
500
|
+
return recorded?.sessionId;
|
|
449
501
|
}
|
|
450
502
|
/**
|
|
451
503
|
* The task attached to this session, never merely the most recent one: a
|
|
@@ -650,6 +702,14 @@ export async function runProjectLoop(options) {
|
|
|
650
702
|
sessionId,
|
|
651
703
|
...(restore.taskId === undefined ? {} : { taskId: restore.taskId })
|
|
652
704
|
}).catch(() => undefined);
|
|
705
|
+
// Claude Code sources this file before every Bash command, so each of
|
|
706
|
+
// this session's commands knows who it is. `sessionId` matched
|
|
707
|
+
// SESSION_ID_PATTERN, which is why it is safe to write unquoted.
|
|
708
|
+
const envFile = (options.env ?? process.env).CLAUDE_ENV_FILE;
|
|
709
|
+
if (options.harness === "claude" && envFile !== undefined && envFile !== "") {
|
|
710
|
+
await appendFile(envFile, `export AGENT_OPS_SESSION_ID=${sessionId}\n`)
|
|
711
|
+
.catch(() => undefined);
|
|
712
|
+
}
|
|
653
713
|
}
|
|
654
714
|
try {
|
|
655
715
|
const [goal, telemetryEntries] = await Promise.all([
|
|
@@ -234,6 +234,23 @@ export class CompletionGateService {
|
|
|
234
234
|
? `Git-visible changes require an attached completed task, current PASS evidence, and a PASS review. Session: ${sessionId}. Create with --session ${sessionId}; one-time permit: agent-ops allow-stop --session ${sessionId}.`
|
|
235
235
|
: undefined);
|
|
236
236
|
}
|
|
237
|
+
/** Best effort: no baseline only means Stop keeps blocking, as before. */
|
|
238
|
+
async #backfill(sessionId) {
|
|
239
|
+
try {
|
|
240
|
+
if (await this.#store.read(sessionId) !== null)
|
|
241
|
+
return;
|
|
242
|
+
const baselineFingerprint = await this.#fingerprint();
|
|
243
|
+
await this.#store.mutate(sessionId, (current) => current ?? {
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
sessionId,
|
|
246
|
+
baselineFingerprint,
|
|
247
|
+
permitFingerprint: null
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
237
254
|
async grantPermit(sessionId) {
|
|
238
255
|
const fingerprint = await this.#fingerprint();
|
|
239
256
|
await this.#store.mutate(sessionId, (state) => {
|
|
@@ -320,8 +337,14 @@ export class CompletionGateService {
|
|
|
320
337
|
if (event.event === "session-start") {
|
|
321
338
|
return await this.initialize(sessionId);
|
|
322
339
|
}
|
|
323
|
-
if (event.event !== "stop")
|
|
340
|
+
if (event.event !== "stop") {
|
|
341
|
+
// A session whose SessionStart never reached this checkout has no
|
|
342
|
+
// baseline, and the first tool call is the earliest safe moment to take
|
|
343
|
+
// one. Stop never is: a baseline taken there would swallow the change.
|
|
344
|
+
if (event.event !== "unsupported")
|
|
345
|
+
await this.#backfill(sessionId);
|
|
324
346
|
return null;
|
|
347
|
+
}
|
|
325
348
|
if (event.terminationReason === undefined ||
|
|
326
349
|
(event.terminationReason === "model_stop" && event.fullyIdle === undefined)) {
|
|
327
350
|
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_STOP_INPUT_INVALID", "Restore the Stop termination reason and fullyIdle metadata before stopping.");
|
|
@@ -332,7 +355,9 @@ export class CompletionGateService {
|
|
|
332
355
|
const fingerprint = await this.#fingerprint();
|
|
333
356
|
const state = await this.#store.read(sessionId);
|
|
334
357
|
if (state === null) {
|
|
335
|
-
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED",
|
|
358
|
+
return gateResult("block", "UNKNOWN", "COMPLETION_GATE_NOT_INITIALIZED", this.#options.harness === "agy"
|
|
359
|
+
? "The session baseline is unavailable; continue once so PreInvocation can initialize it."
|
|
360
|
+
: "The session baseline is unavailable: its SessionStart never reached this checkout. The next tool call records one; if this repeats, run agent-ops doctor.");
|
|
336
361
|
}
|
|
337
362
|
const changed = state.baselineFingerprint !== fingerprint && state.permitFingerprint !== fingerprint;
|
|
338
363
|
if (state.root !== undefined && state.root !== this.#options.root) {
|
|
@@ -57,6 +57,11 @@ export async function dispatchHookEvent(event, options) {
|
|
|
57
57
|
? continueWith("PASS", "HOOK_NOOP")
|
|
58
58
|
: await options.worktreeGuard(event);
|
|
59
59
|
}
|
|
60
|
+
if (event.event === "enter-worktree") {
|
|
61
|
+
return options.worktreeEnter === undefined
|
|
62
|
+
? continueWith("PASS", "HOOK_NOOP")
|
|
63
|
+
: await options.worktreeEnter(event);
|
|
64
|
+
}
|
|
60
65
|
if (event.event === "session-start" &&
|
|
61
66
|
options.capabilities.includes("lifecycle-summary") &&
|
|
62
67
|
options.advisory !== undefined) {
|
|
@@ -4,6 +4,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
|
|
|
4
4
|
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
5
5
|
import { FileTransaction } from "../fs/transaction.js";
|
|
6
6
|
import { validateConfig } from "../schema/validate.js";
|
|
7
|
+
import { applyPreauth } from "./preauth.js";
|
|
7
8
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
8
9
|
const MANIFEST_PATH = ".agent-ops/manifest.json";
|
|
9
10
|
async function readInstalledFile(root, path) {
|
|
@@ -67,4 +68,5 @@ export async function applyInstallPlan(root, plan) {
|
|
|
67
68
|
assertManifestOperation(plan);
|
|
68
69
|
const transaction = new FileTransaction(root);
|
|
69
70
|
await transaction.apply({ operations: plan.operations }, async () => await validateAppliedPlan(root, plan));
|
|
71
|
+
await applyPreauth(plan.preauthorization ?? []);
|
|
70
72
|
}
|
|
@@ -460,7 +460,9 @@ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []
|
|
|
460
460
|
const failures = [];
|
|
461
461
|
const degraded = [];
|
|
462
462
|
for (const target of targets) {
|
|
463
|
-
const
|
|
463
|
+
const answer = await probe(target, checkAuth);
|
|
464
|
+
const result = typeof answer === "string" ? answer : answer.result;
|
|
465
|
+
const diagnostic = typeof answer === "string" ? undefined : answer.diagnostic;
|
|
464
466
|
if (result === "missing-executable") {
|
|
465
467
|
failures.push(check("review-targets", "FAIL", `${target} not found.`, undefined, `Install ${target}, or remove "${target}" from reviewRoles[].targets.`));
|
|
466
468
|
continue;
|
|
@@ -475,10 +477,19 @@ async function checkReviewTargets(config, probe, checkAuth, selectedTargets = []
|
|
|
475
477
|
failures.push(check("review-targets", "FAIL", `${target} did not answer in time.`, undefined, "Re-run: agent-ops doctor --check-auth"));
|
|
476
478
|
continue;
|
|
477
479
|
}
|
|
480
|
+
if (result === "network-unreachable") {
|
|
481
|
+
failures.push(check("review-targets", "FAIL", `${target} could not reach the network, so its login state is unknown.`, undefined, "Run agent-ops outside the sandbox, or grant it escalated execution, then re-run: agent-ops doctor --check-auth"));
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
478
484
|
if (result === "capability-unavailable") {
|
|
479
485
|
failures.push(check("review-targets", "FAIL", `${target} could not complete its read-only capability probe.`, undefined, "Run agent-ops review on a host with network and loopback permission, then retry."));
|
|
480
486
|
continue;
|
|
481
487
|
}
|
|
488
|
+
if (result === "probe-failed") {
|
|
489
|
+
failures.push(check("review-targets", "FAIL", `${target} failed its probe${diagnostic === undefined ? "" : `: ${diagnostic}`}. ` +
|
|
490
|
+
"This is not evidence of a missing login.", undefined, "Fix what the message reports, or run agent-ops review --json for the full preflight record, then re-run: agent-ops doctor --check-auth"));
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
482
493
|
if (checkAuth && result !== "ok") {
|
|
483
494
|
failures.push(check("review-targets", "FAIL", `${target} is installed but not authenticated, or it rejected the call.`, undefined, `Run: ${target} login`));
|
|
484
495
|
}
|
|
@@ -48,7 +48,8 @@ function jsonHookRegistered(control, source, capabilities) {
|
|
|
48
48
|
control.isManagedHandler === undefined) {
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
|
-
const
|
|
51
|
+
const expected = control.buildHooks(capabilities, "probe").hooks;
|
|
52
|
+
const events = Object.keys(expected);
|
|
52
53
|
if (events.length === 0) {
|
|
53
54
|
return true;
|
|
54
55
|
}
|
|
@@ -57,12 +58,22 @@ function jsonHookRegistered(control, source, capabilities) {
|
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
59
60
|
const hooks = parsed.hooks;
|
|
61
|
+
const isManaged = control.isManagedHandler;
|
|
62
|
+
const ownsHandler = (group, matcher) => isRecord(group) &&
|
|
63
|
+
(matcher === undefined || group.matcher === matcher) &&
|
|
64
|
+
Array.isArray(group.hooks) &&
|
|
65
|
+
group.hooks.some(isManaged);
|
|
60
66
|
return events.every((event) => {
|
|
61
67
|
const groups = hooks[event];
|
|
68
|
+
// A matcher is part of the registration: one written before a tool was
|
|
69
|
+
// added to it still holds a managed handler, which never fires for that tool.
|
|
70
|
+
const matchers = [expected[event]]
|
|
71
|
+
.flat()
|
|
72
|
+
.filter(isRecord)
|
|
73
|
+
.flatMap((group) => typeof group.matcher === "string" ? [group.matcher] : []);
|
|
62
74
|
return (Array.isArray(groups) &&
|
|
63
|
-
groups.some((group) =>
|
|
64
|
-
|
|
65
|
-
group.hooks.some(control.isManagedHandler)));
|
|
75
|
+
groups.some((group) => ownsHandler(group)) &&
|
|
76
|
+
matchers.every((matcher) => groups.some((group) => ownsHandler(group, matcher))));
|
|
66
77
|
});
|
|
67
78
|
}
|
|
68
79
|
function runtimeFailureResult(capability, registrations, remedy) {
|
|
@@ -308,7 +319,7 @@ export function managedRules(descriptor, context) {
|
|
|
308
319
|
""
|
|
309
320
|
];
|
|
310
321
|
if (context.capabilities.includes("rules")) {
|
|
311
|
-
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
322
|
+
lines.push("For every change:", "", "1. Define two to five mechanically verifiable acceptance criteria.", "2. Inspect the smallest relevant scope and preserve unrelated changes.", "3. Apply the smallest safe change.", "4. Run evidence-producing verification for every criterion.", "5. Obtain complete independent review before claiming completion, via", " `agent-ops review --task <task-id> --yes` (or the CLI's equivalent invocation).", " Never call a", " review-target CLI (agy, codex, claude) directly — direct calls skip", " the enforced read-only sandbox flags and can hang or fail on command", " permission prompts.", " Start this command through the trusted outer host runner as its first", " and only invocation. On Codex, the outer tool request must use", " `sandbox_permissions: \"require_escalated\"` so network and loopback", " are available; in that same request, unset", " `CODEX_SANDBOX_NETWORK_DISABLED` and set `AGENT_OPS_HOST` when three", " targets are configured. The Codex shell form is", " `env -u CODEX_SANDBOX_NETWORK_DISABLED AGENT_OPS_HOST=<current-host> agent-ops review --task <task-id> --yes`.", " Keep it a plain command line: to keep the output add `--output <file>`", " (the JSON envelope is written there and one line is printed) instead of", " a shell redirect, which the host can only allow by matching the whole", " command string, task id included.", " Do not run it once in the restricted sandbox and retry; changing the", " variable inside that sandbox is not elevation.", " Review runs exactly two fresh sessions: with three configured targets,", " set `AGENT_OPS_HOST` to the current CLI id so it is excluded; with two", " targets both run in configured order; with one target it runs twice.", " The first session is necessary; a PASS then requires an adversarial", " re-check carrying the first report. A first FAIL or NOT_RUN stops.", "", "Treat `.agent-ops/config.json` as verifier authority. Discovery output is", "only a proposal until a user confirms it. Repository commands require an", "exact matching trust record. Confirmed project init/update grants it", "automatically when verification commands are configured.", "");
|
|
312
323
|
}
|
|
313
324
|
if (context.capabilities.includes("task")) {
|
|
314
325
|
lines.push("Split a change that exceeds five acceptance criteria into subtasks:", "`agent-ops task create --parent <task-id>` records one, and", "`agent-ops task status --parent <task-id>` lists them. Each subtask", "carries its own criteria, verification, and independent review;", "completing one never completes its parent.", "", "Delegate a subtask whose scope spans unknown files, broad scans, or an", "independent workstream instead of doing it inline. Name the bounded", "scope, the artifact to return, and the acceptance criteria it serves.", subagentInstruction(descriptor), "Read every returned artifact back yourself: integration, verification,", "and review stay with the coordinator.", "", "One writer at a time. `agent-ops verify` and `agent-ops review`", "fingerprint the working tree and report UNKNOWN when anything else", "writes it during the run, so delegate edits to one subagent at a time,", "or give each writer its own Git worktree and merge before verifying.", "Read-only delegation has no such limit and may run in parallel.", "", "Parallel conversations in one repository each need their own worktree.", "When `.agent-ops/config.json` sets `worktree.mode` to `auto`, run", "`agent-ops task create` from the main checkout before your first edit:", "it creates this session's worktree and puts the task there (`agent-ops", "worktree add <name> --session <session-id>` does the same without a", "task). Work only inside the printed path", "(Claude Code: EnterWorktree with that path; otherwise use it as every", "command's working directory). Commit, then run verify and review for", "each task with the printed `--base`; do not run `task complete` in the", "worktree. Leave it (Claude Code: ExitWorktree with action keep) and run", "`agent-ops worktree finish <name>` from the main checkout: it completes", "every task in the worktree, subtasks first, then merges. A finish that", "reports an incomplete task names it; verify or review that task again", "in the worktree and finish again. A finish that reports a conflict is", "resolved once, as a new task whose criteria cover both intents; if its", "verification or review fails, reset as instructed and report.", "");
|
|
@@ -11,6 +11,7 @@ import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./
|
|
|
11
11
|
import { codexHooksExplicitlyDisabled, loopLauncherArtifactId, loopSeeds, planLoopContribution, selectedLoopHarnesses } from "./codex-loop.js";
|
|
12
12
|
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
13
13
|
import { planHookRegistration, planHookRemoval } from "./hooks.js";
|
|
14
|
+
import { planPreauth } from "./preauth.js";
|
|
14
15
|
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
15
16
|
const CONFIG_PATH = ".agent-ops/config.json";
|
|
16
17
|
const MANIFEST_PATH = ".agent-ops/manifest.json";
|
|
@@ -598,6 +599,13 @@ export async function createInstallPlan(options) {
|
|
|
598
599
|
content: formatInstallManifest(manifest),
|
|
599
600
|
expectedHash: existing?.hash ?? null
|
|
600
601
|
});
|
|
602
|
+
const preauthorization = await planPreauth({
|
|
603
|
+
root: options.root,
|
|
604
|
+
scope: options.scope,
|
|
605
|
+
harness: options.harness,
|
|
606
|
+
desired: config.config.worktree?.mode === "auto",
|
|
607
|
+
...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
|
|
608
|
+
});
|
|
601
609
|
return {
|
|
602
610
|
scope: options.scope,
|
|
603
611
|
harness: options.harness,
|
|
@@ -606,6 +614,7 @@ export async function createInstallPlan(options) {
|
|
|
606
614
|
config: config.config,
|
|
607
615
|
manifest,
|
|
608
616
|
operations,
|
|
617
|
+
...(preauthorization.length === 0 ? {} : { preauthorization }),
|
|
609
618
|
detectedVerification: config.detectedVerification,
|
|
610
619
|
verificationBlockers: config.verificationBlockers
|
|
611
620
|
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
6
|
+
import { FileTransaction } from "../fs/transaction.js";
|
|
7
|
+
/**
|
|
8
|
+
* What a session may do without asking, decided once, when the user confirms
|
|
9
|
+
* init or update: the commands a worktree session runs itself. `trust`,
|
|
10
|
+
* `allow-stop`, `init`, `update` and `uninstall` stay out, so the approvals
|
|
11
|
+
* that are the user's alone remain the user's.
|
|
12
|
+
*/
|
|
13
|
+
export const CLAUDE_LOCAL_SETTINGS_PATH = ".claude/settings.local.json";
|
|
14
|
+
export const CLAUDE_PREAUTH_ALLOW = [
|
|
15
|
+
"Bash(agent-ops task *)",
|
|
16
|
+
"Bash(agent-ops verify *)",
|
|
17
|
+
"Bash(agent-ops review *)",
|
|
18
|
+
"Bash(agent-ops worktree *)",
|
|
19
|
+
"Bash(agent-ops doctor *)"
|
|
20
|
+
];
|
|
21
|
+
/** review talks to the reviewer's API, which the sandbox withholds. */
|
|
22
|
+
export const CLAUDE_PREAUTH_UNSANDBOXED = [
|
|
23
|
+
"agent-ops review",
|
|
24
|
+
"agent-ops review *"
|
|
25
|
+
];
|
|
26
|
+
export const CODEX_RULES_PATH = "rules/agent-ops.rules";
|
|
27
|
+
export const CODEX_RULES_MARKER = "# agent-ops managed: regenerated by agent-ops init and update; do not edit.";
|
|
28
|
+
const HOSTS = ["AGENT_OPS_HOST=codex", "AGENT_OPS_HOST=claude", "AGENT_OPS_HOST=agy"];
|
|
29
|
+
const ENV_PREFIX = ["env", "-u", "CODEX_SANDBOX_NETWORK_DISABLED"];
|
|
30
|
+
function starlarkList(values) {
|
|
31
|
+
return `[${values.map((value) => typeof value === "string"
|
|
32
|
+
? JSON.stringify(value)
|
|
33
|
+
: `[${value.map((item) => JSON.stringify(item)).join(", ")}]`).join(", ")}]`;
|
|
34
|
+
}
|
|
35
|
+
function rule(options) {
|
|
36
|
+
const pattern = [
|
|
37
|
+
...ENV_PREFIX,
|
|
38
|
+
...(options.withHost ? [HOSTS] : []),
|
|
39
|
+
"agent-ops",
|
|
40
|
+
...options.command
|
|
41
|
+
];
|
|
42
|
+
return [
|
|
43
|
+
"prefix_rule(",
|
|
44
|
+
` pattern = ${starlarkList(pattern)},`,
|
|
45
|
+
' decision = "allow",',
|
|
46
|
+
` justification = ${JSON.stringify(options.justification)},`,
|
|
47
|
+
` match = [${JSON.stringify(options.match)}],`,
|
|
48
|
+
` not_match = [${JSON.stringify(options.notMatch)}]`,
|
|
49
|
+
")"
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Prefix rules for the exact shapes `.agent-ops/AGENTS.md` tells Codex to run,
|
|
54
|
+
* with and without AGENT_OPS_HOST, for review and the auth probe. Matching is
|
|
55
|
+
* by argv prefix, so a shell redirect would defeat it: use `--output`.
|
|
56
|
+
*/
|
|
57
|
+
export const CODEX_RULES_CONTENT = `${[
|
|
58
|
+
CODEX_RULES_MARKER,
|
|
59
|
+
...[true, false].flatMap((withHost) => {
|
|
60
|
+
const host = withHost ? "AGENT_OPS_HOST=codex " : "";
|
|
61
|
+
const env = `env -u CODEX_SANDBOX_NETWORK_DISABLED ${host}agent-ops`;
|
|
62
|
+
return [
|
|
63
|
+
rule({
|
|
64
|
+
withHost,
|
|
65
|
+
command: ["review"],
|
|
66
|
+
justification: "agent-ops review needs network and loopback outside the sandbox.",
|
|
67
|
+
match: `${env} review --task task-x --yes --output review.json`,
|
|
68
|
+
notMatch: `${env} trust grant --scope project --yes`
|
|
69
|
+
}),
|
|
70
|
+
rule({
|
|
71
|
+
withHost,
|
|
72
|
+
command: ["doctor", "--check-auth"],
|
|
73
|
+
justification: "agent-ops doctor --check-auth probes review targets, which needs the network outside the sandbox.",
|
|
74
|
+
match: `${env} doctor --check-auth --json --output doctor.json`,
|
|
75
|
+
notMatch: `${env} doctor --scope user`
|
|
76
|
+
})
|
|
77
|
+
];
|
|
78
|
+
})
|
|
79
|
+
].join("\n")}\n`;
|
|
80
|
+
export function codexHomeDirectory(env = process.env) {
|
|
81
|
+
return env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
|
|
82
|
+
}
|
|
83
|
+
async function readCurrent(root, path) {
|
|
84
|
+
const resolved = await resolveContainedPath(root, path);
|
|
85
|
+
try {
|
|
86
|
+
if (!(await lstat(resolved)).isFile()) {
|
|
87
|
+
throw new AgentOpsError("PREAUTH_TARGET_INVALID", `Pre-authorization target must be a regular file: ${path}`);
|
|
88
|
+
}
|
|
89
|
+
const content = await readFile(resolved, "utf8");
|
|
90
|
+
return { content, hash: sha256(content) };
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function invalidSettings(reason, cause) {
|
|
100
|
+
return new AgentOpsError("PREAUTH_SETTINGS_INVALID", `${CLAUDE_LOCAL_SETTINGS_PATH} ${reason}; fix it or remove it, then run agent-ops update.`, cause === undefined ? undefined : { cause });
|
|
101
|
+
}
|
|
102
|
+
function stringList(value, where) {
|
|
103
|
+
if (value === undefined) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
107
|
+
throw invalidSettings(`has a ${where} that is not a list of strings`);
|
|
108
|
+
}
|
|
109
|
+
return [...value];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The managed entries merged into, or stripped from, the user's own local
|
|
113
|
+
* settings. Nothing else in the file is touched. `null` content means the file
|
|
114
|
+
* held nothing but these entries and should go; `undefined` means no change.
|
|
115
|
+
*/
|
|
116
|
+
export function planClaudeLocalSettings(source, desired) {
|
|
117
|
+
let settings = {};
|
|
118
|
+
if (source !== null && source.trim() !== "") {
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(source);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw invalidSettings("is not valid JSON", error);
|
|
125
|
+
}
|
|
126
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
127
|
+
throw invalidSettings("is not a JSON object");
|
|
128
|
+
}
|
|
129
|
+
settings = parsed;
|
|
130
|
+
}
|
|
131
|
+
const permissions = (settings.permissions ?? {});
|
|
132
|
+
const sandbox = (settings.sandbox ?? {});
|
|
133
|
+
if (typeof permissions !== "object" || Array.isArray(permissions) ||
|
|
134
|
+
typeof sandbox !== "object" || Array.isArray(sandbox)) {
|
|
135
|
+
throw invalidSettings("has a permissions or sandbox entry that is not an object");
|
|
136
|
+
}
|
|
137
|
+
const allow = stringList(permissions.allow, "permissions.allow");
|
|
138
|
+
const excluded = stringList(sandbox.excludedCommands, "sandbox.excludedCommands");
|
|
139
|
+
const merge = (current, managed) => desired
|
|
140
|
+
? [...current, ...managed.filter((entry) => !current.includes(entry))]
|
|
141
|
+
: current.filter((entry) => !managed.includes(entry));
|
|
142
|
+
const nextAllow = merge(allow, CLAUDE_PREAUTH_ALLOW);
|
|
143
|
+
const nextExcluded = merge(excluded, CLAUDE_PREAUTH_UNSANDBOXED);
|
|
144
|
+
if (nextAllow.length === allow.length && nextExcluded.length === excluded.length &&
|
|
145
|
+
nextAllow.every((entry, index) => entry === allow[index]) &&
|
|
146
|
+
nextExcluded.every((entry, index) => entry === excluded[index])) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const { allow: _allow, ...otherPermissions } = permissions;
|
|
150
|
+
const { excludedCommands: _excluded, ...otherSandbox } = sandbox;
|
|
151
|
+
const nextPermissions = {
|
|
152
|
+
...otherPermissions,
|
|
153
|
+
...(nextAllow.length === 0 ? {} : { allow: nextAllow })
|
|
154
|
+
};
|
|
155
|
+
const nextSandbox = {
|
|
156
|
+
...otherSandbox,
|
|
157
|
+
...(nextExcluded.length === 0 ? {} : { excludedCommands: nextExcluded })
|
|
158
|
+
};
|
|
159
|
+
const { permissions: _p, sandbox: _s, ...rest } = settings;
|
|
160
|
+
const next = {
|
|
161
|
+
...rest,
|
|
162
|
+
...(Object.keys(nextPermissions).length === 0 ? {} : { permissions: nextPermissions }),
|
|
163
|
+
...(Object.keys(nextSandbox).length === 0 ? {} : { sandbox: nextSandbox })
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
content: Object.keys(next).length === 0 ? null : `${JSON.stringify(next, null, 2)}\n`
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function claudeChange(root, desired) {
|
|
170
|
+
const current = await readCurrent(root, CLAUDE_LOCAL_SETTINGS_PATH);
|
|
171
|
+
const planned = planClaudeLocalSettings(current?.content ?? null, desired);
|
|
172
|
+
if (planned === undefined) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
const summary = desired
|
|
176
|
+
? `Adds to ${CLAUDE_LOCAL_SETTINGS_PATH} (existing entries kept): permissions.allow ` +
|
|
177
|
+
`${JSON.stringify(CLAUDE_PREAUTH_ALLOW)}; sandbox.excludedCommands ${JSON.stringify(CLAUDE_PREAUTH_UNSANDBOXED)}.`
|
|
178
|
+
: `Removes the agent-ops entries from ${CLAUDE_LOCAL_SETTINGS_PATH}, keeping everything else.`;
|
|
179
|
+
if (planned.content === null) {
|
|
180
|
+
return current === null
|
|
181
|
+
? undefined
|
|
182
|
+
: {
|
|
183
|
+
root,
|
|
184
|
+
operation: { kind: "remove", path: CLAUDE_LOCAL_SETTINGS_PATH, expectedHash: current.hash },
|
|
185
|
+
summary,
|
|
186
|
+
ownedContent: false
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
root,
|
|
191
|
+
operation: {
|
|
192
|
+
kind: "write",
|
|
193
|
+
path: CLAUDE_LOCAL_SETTINGS_PATH,
|
|
194
|
+
content: planned.content,
|
|
195
|
+
expectedHash: current?.hash ?? null
|
|
196
|
+
},
|
|
197
|
+
summary,
|
|
198
|
+
ownedContent: false
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async function codexChange(codexHome, desired) {
|
|
202
|
+
const isDirectory = await lstat(codexHome).then((status) => status.isDirectory(), () => false);
|
|
203
|
+
if (!isDirectory) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const current = await readCurrent(codexHome, CODEX_RULES_PATH);
|
|
207
|
+
// A rules file of the same name that agent-ops did not write is not ours to
|
|
208
|
+
// overwrite or delete.
|
|
209
|
+
if (current !== null && !current.content.startsWith(CODEX_RULES_MARKER)) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
if (!desired) {
|
|
213
|
+
return current === null
|
|
214
|
+
? undefined
|
|
215
|
+
: {
|
|
216
|
+
root: codexHome,
|
|
217
|
+
operation: { kind: "remove", path: CODEX_RULES_PATH, expectedHash: current.hash },
|
|
218
|
+
summary: `Removes ${join(codexHome, CODEX_RULES_PATH)}, the rules agent-ops wrote.`,
|
|
219
|
+
ownedContent: true
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return current?.content === CODEX_RULES_CONTENT
|
|
223
|
+
? undefined
|
|
224
|
+
: {
|
|
225
|
+
root: codexHome,
|
|
226
|
+
operation: {
|
|
227
|
+
kind: "write",
|
|
228
|
+
path: CODEX_RULES_PATH,
|
|
229
|
+
content: CODEX_RULES_CONTENT,
|
|
230
|
+
expectedHash: current?.hash ?? null
|
|
231
|
+
},
|
|
232
|
+
summary: `Writes ${join(codexHome, CODEX_RULES_PATH)}: allow rules for agent-ops review and doctor --check-auth.`,
|
|
233
|
+
ownedContent: true
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* What init, update and uninstall change beyond the manifest: the local Claude
|
|
238
|
+
* settings entries and the Codex rules file. Project scope only; `desired`
|
|
239
|
+
* false plans the removal of what agent-ops wrote.
|
|
240
|
+
*/
|
|
241
|
+
export async function planPreauth(options) {
|
|
242
|
+
if (options.scope !== "project") {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const changes = [];
|
|
246
|
+
if (options.harness.includes("claude")) {
|
|
247
|
+
changes.push(await claudeChange(options.root, options.desired));
|
|
248
|
+
}
|
|
249
|
+
// Codex's home is outside the project, so it is only touched when the caller
|
|
250
|
+
// names it (the CLI does): planning alone must never reach into a real home.
|
|
251
|
+
if (options.harness.includes("codex") && options.codexHome !== undefined) {
|
|
252
|
+
changes.push(await codexChange(options.codexHome, options.desired));
|
|
253
|
+
}
|
|
254
|
+
return changes.filter((change) => change !== undefined);
|
|
255
|
+
}
|
|
256
|
+
export async function applyPreauth(changes) {
|
|
257
|
+
for (const change of changes) {
|
|
258
|
+
try {
|
|
259
|
+
await new FileTransaction(change.root).apply({ operations: [change.operation] });
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw new AgentOpsError("PREAUTH_APPLY_FAILED", `The rest was applied, but ${join(change.root, change.operation.path)} was not written; fix that file and run agent-ops update.`, { cause: error });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -118,3 +118,35 @@ export function repositoryTrustStatus(trust) {
|
|
|
118
118
|
? "FAIL"
|
|
119
119
|
: "UNKNOWN";
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Trust is bound to the config hash, so a hand edit of the config is what
|
|
123
|
+
* usually costs it. The fix is always the user's: `update` lists the commands
|
|
124
|
+
* it will trust and asks once, which is why nothing here tells an agent to
|
|
125
|
+
* grant it.
|
|
126
|
+
*/
|
|
127
|
+
const TRUST_REMEDY = "The user grants it, once: run `agent-ops update`, which lists the commands it will trust and asks (or `agent-ops trust grant`).";
|
|
128
|
+
export function repositoryTrustProbe(trust, verificationConfigured) {
|
|
129
|
+
const status = repositoryTrustStatus(trust);
|
|
130
|
+
if (trust === "STALE") {
|
|
131
|
+
return {
|
|
132
|
+
status,
|
|
133
|
+
message: "Repository trust binding is stale: the config hash changed since trust was granted, as a hand edit of .agent-ops/config.json does.",
|
|
134
|
+
code: "TRUST_REQUIRED",
|
|
135
|
+
remediation: TRUST_REMEDY
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (trust === "UNTRUSTED") {
|
|
139
|
+
return {
|
|
140
|
+
status,
|
|
141
|
+
message: verificationConfigured
|
|
142
|
+
? "Repository is not trusted; Stop verification will not run."
|
|
143
|
+
: "Repository is not trusted.",
|
|
144
|
+
...(verificationConfigured
|
|
145
|
+
? { code: "TRUST_REQUIRED", remediation: TRUST_REMEDY }
|
|
146
|
+
: {
|
|
147
|
+
remediation: "No action needed; trust is only required once verification.commands is set."
|
|
148
|
+
})
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { status };
|
|
152
|
+
}
|