@kylecheng3146/agent-ops 0.5.3 → 0.7.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/README.md +103 -1
- package/dist/packages/cli/src/args.js +183 -3
- package/dist/packages/cli/src/bin.js +76 -19
- package/dist/packages/cli/src/cli.js +53 -8
- package/dist/packages/cli/src/codex-loop-process.js +19 -2
- package/dist/packages/cli/src/commands/advance.js +42 -5
- package/dist/packages/cli/src/commands/init.js +2 -0
- package/dist/packages/cli/src/commands/office.js +20 -0
- package/dist/packages/cli/src/commands/review-show.js +1 -1
- package/dist/packages/cli/src/commands/review.js +100 -9
- package/dist/packages/cli/src/commands/run.js +115 -0
- package/dist/packages/cli/src/commands/task.js +61 -17
- package/dist/packages/cli/src/commands/update.js +8 -0
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +64 -1
- package/dist/packages/cli/src/hook-entry.js +2 -1
- package/dist/packages/cli/src/hook-process.js +54 -3
- package/dist/packages/cli/src/loop-entry.js +2 -1
- package/dist/packages/cli/src/office-entry.js +230 -0
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +321 -0
- package/dist/packages/cli/src/run-entry.js +952 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/packages/cli/src/wizard.js +14 -2
- package/dist/runtime/src/adapters/agy/config.js +4 -3
- package/dist/runtime/src/adapters/agy/events.js +9 -0
- package/dist/runtime/src/adapters/claude/config.js +3 -3
- package/dist/runtime/src/adapters/claude/events.js +8 -0
- package/dist/runtime/src/adapters/codex/config.js +3 -3
- package/dist/runtime/src/adapters/codex/events.js +8 -0
- package/dist/runtime/src/adapters/opencode/config.js +5 -4
- package/dist/runtime/src/adapters/opencode/events.js +8 -0
- package/dist/runtime/src/config/explain.js +11 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/codex-loop.js +4 -0
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +44 -1
- package/dist/runtime/src/install/plan.js +16 -11
- package/dist/runtime/src/install/preauth.js +44 -18
- package/dist/runtime/src/install/profiles.js +8 -2
- package/dist/runtime/src/install/update.js +1 -0
- package/dist/runtime/src/office/collect.js +233 -0
- package/dist/runtime/src/office/page.js +873 -0
- package/dist/runtime/src/office/scene.js +168 -0
- package/dist/runtime/src/office/server.js +139 -0
- package/dist/runtime/src/office/sessions.js +115 -0
- package/dist/runtime/src/office/snapshot.js +107 -0
- package/dist/runtime/src/parallel/finish.js +85 -22
- package/dist/runtime/src/parallel/guard.js +12 -2
- package/dist/runtime/src/parallel/integrate.js +288 -10
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +71 -5
- package/dist/runtime/src/parallel/service.js +41 -1
- package/dist/runtime/src/review/attestation.js +36 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/phase.js +37 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +433 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +80 -9
- package/dist/runtime/src/task/completion.js +14 -2
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/service.js +316 -13
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/spec/acceptance-and-evidence.md +48 -0
- package/docs/en/spec/harness-adapters.md +113 -1
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +21 -1
- package/docs/zh-TW/spec/harness-adapters.md +93 -1
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +230 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +265 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { canonicalJson } from "../config/hash.js";
|
|
2
|
+
import { sha256 } from "../fs/hash.js";
|
|
3
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
+
import { RunControlService } from "./controls.js";
|
|
5
|
+
const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
/** Provider decisions are observations, never an alternate permission executor. */
|
|
7
|
+
export async function recordNativeRunAuthorization(repository, event) {
|
|
8
|
+
if (!plain(event.payload))
|
|
9
|
+
return;
|
|
10
|
+
const payload = event.payload;
|
|
11
|
+
const decisions = [];
|
|
12
|
+
if (event.host === "codex" && event.transportMethod === "item/autoApprovalReview/completed") {
|
|
13
|
+
if (!plain(payload.action) || !plain(payload.review) || typeof payload.reviewId !== "string")
|
|
14
|
+
throw new AgentOpsError("RUN_AUTHORIZATION_PROTOCOL_INVALID", "Native approval notification has an invalid scope or identity.");
|
|
15
|
+
const action = payload.action, review = payload.review;
|
|
16
|
+
if (payload.decisionSource !== "agent" || !["approved", "denied", "timedOut", "aborted"].includes(String(review.status)) ||
|
|
17
|
+
!["command", "execve", "writeStdin", "applyPatch", "networkAccess", "mcpToolCall", "requestPermissions"].includes(String(action.type)))
|
|
18
|
+
throw new AgentOpsError("RUN_AUTHORIZATION_PROTOCOL_INVALID", "Native approval must expose a supported completed agent decision.");
|
|
19
|
+
const status = review.status;
|
|
20
|
+
decisions.push({ id: payload.reviewId, operation: String(action.type), command: canonicalJson(action),
|
|
21
|
+
resource: canonicalJson({ cwd: action.cwd ?? null, host: action.host ?? null, target: action.target ?? null,
|
|
22
|
+
files: action.files ?? null, server: action.server ?? null, permissions: action.permissions ?? null }),
|
|
23
|
+
result: status === "approved" ? "auto-approved" : status === "denied" ? "denied" : "requires-host-approval",
|
|
24
|
+
reason: typeof review.rationale === "string" && review.rationale.trim().length > 0 ? review.rationale : "Native review did not expose a rationale." });
|
|
25
|
+
}
|
|
26
|
+
else if (event.host === "claude") {
|
|
27
|
+
const message = plain(payload.message) ? payload.message : null;
|
|
28
|
+
if (message !== null && Array.isArray(message.content))
|
|
29
|
+
for (const item of message.content) {
|
|
30
|
+
if (!plain(item) || item.type !== "tool_use" || typeof item.id !== "string" || typeof item.name !== "string" || !plain(item.input))
|
|
31
|
+
continue;
|
|
32
|
+
decisions.push({ id: "tool:" + item.id, operation: item.name, command: canonicalJson(item.input), resource: canonicalJson({ tool: item.name }),
|
|
33
|
+
result: "unknown", reason: "Claude exposed the requested tool scope but no per-command auto-mode decision or rationale." });
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(payload.permission_denials))
|
|
36
|
+
for (const item of payload.permission_denials) {
|
|
37
|
+
if (!plain(item))
|
|
38
|
+
continue;
|
|
39
|
+
decisions.push({ id: "denied:" + (typeof item.tool_use_id === "string" ? item.tool_use_id : sha256(canonicalJson(item))),
|
|
40
|
+
operation: typeof item.tool_name === "string" ? item.tool_name : "native-permission",
|
|
41
|
+
command: canonicalJson(item.tool_input ?? item), resource: canonicalJson({ tool: item.tool_name ?? null }),
|
|
42
|
+
result: "denied", reason: "Claude reported an explicit permission denial; no alternate execution is authorized." });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (decisions.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
const state = await repository.read(event.runId);
|
|
48
|
+
const worker = state?.workers.find(w => w.workerId === event.workerId);
|
|
49
|
+
if (state?.policyBinding === undefined || worker?.generation !== event.generation || state.status !== "active" || state.disableRestart)
|
|
50
|
+
return;
|
|
51
|
+
if (event.host === "codex" && payload.threadId !== worker.nativeJobId)
|
|
52
|
+
throw new AgentOpsError("RUN_AUTHORIZATION_STALE", "Native approval belongs to another thread.");
|
|
53
|
+
const service = new RunControlService(repository);
|
|
54
|
+
for (const decision of decisions)
|
|
55
|
+
await service.recordAuthorization(event.runId, { ...decision,
|
|
56
|
+
authorizationId: decision.id, workerId: worker.workerId, generation: worker.generation, nativeSessionId: worker.nativeSessionId,
|
|
57
|
+
source: event.host === "codex" ? "codex-auto-review" : "claude-auto-mode", commandDigest: sha256(decision.command),
|
|
58
|
+
resourceDigest: sha256(decision.resource), policyConfigHash: state.policyBinding.configHash,
|
|
59
|
+
runtimeHash: state.policyBinding.runtimeHash, policyArtifactDigest: state.policyBinding.artifactDigest, expiresAt: state.policyBinding.expiresAt });
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function highWater(value, incoming) {
|
|
2
|
+
if (incoming === null) {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
return value === null ? incoming : Math.max(value, incoming);
|
|
6
|
+
}
|
|
7
|
+
/** Merge telemetry without allowing a resumed process to lower a prior high-water mark. */
|
|
8
|
+
export function recordUsage(usage, incoming) {
|
|
9
|
+
const index = usage.findIndex((value) => value.source === incoming.source && value.epoch === incoming.epoch);
|
|
10
|
+
if (index < 0) {
|
|
11
|
+
return [...usage, incoming];
|
|
12
|
+
}
|
|
13
|
+
const previous = usage[index];
|
|
14
|
+
const sameEpoch = previous.epoch === incoming.epoch;
|
|
15
|
+
const merged = sameEpoch
|
|
16
|
+
? {
|
|
17
|
+
...incoming,
|
|
18
|
+
inputTokens: highWater(previous.inputTokens, incoming.inputTokens),
|
|
19
|
+
outputTokens: highWater(previous.outputTokens, incoming.outputTokens),
|
|
20
|
+
totalTokens: highWater(previous.totalTokens, incoming.totalTokens),
|
|
21
|
+
costUsd: highWater(previous.costUsd, incoming.costUsd),
|
|
22
|
+
completeness: previous.completeness === "complete" || incoming.completeness === "complete"
|
|
23
|
+
? "complete"
|
|
24
|
+
: previous.completeness === "partial" || incoming.completeness === "partial"
|
|
25
|
+
? "partial"
|
|
26
|
+
: "unknown"
|
|
27
|
+
}
|
|
28
|
+
: incoming;
|
|
29
|
+
return usage.map((value, currentIndex) => currentIndex === index ? merged : value);
|
|
30
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { sha256 } from "../fs/hash.js";
|
|
2
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
3
|
+
import { canonicalJson } from "../config/hash.js";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
6
|
+
import { redactSecrets } from "../security/redact.js";
|
|
7
|
+
const MAX_REASON_LENGTH = 4_096;
|
|
8
|
+
function error(code, message) {
|
|
9
|
+
return new AgentOpsError(code, message);
|
|
10
|
+
}
|
|
11
|
+
function replace(state, controls) {
|
|
12
|
+
return { ...state, controls };
|
|
13
|
+
}
|
|
14
|
+
function nowIso(now) {
|
|
15
|
+
const value = now();
|
|
16
|
+
if (!Number.isFinite(Date.parse(value)))
|
|
17
|
+
throw error("RUN_CLOCK_INVALID", "Run clock returned an invalid timestamp.");
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run-scoped controls are an audit record and a coordination boundary. They
|
|
22
|
+
* never turn a repo trust grant into a global permission or bypass a native
|
|
23
|
+
* host's explicit denial.
|
|
24
|
+
*/
|
|
25
|
+
export class RunControlService {
|
|
26
|
+
#repository;
|
|
27
|
+
#now;
|
|
28
|
+
constructor(repository, now = () => new Date().toISOString()) {
|
|
29
|
+
this.#repository = repository;
|
|
30
|
+
this.#now = now;
|
|
31
|
+
}
|
|
32
|
+
async request(runId, request) {
|
|
33
|
+
const now = nowIso(this.#now);
|
|
34
|
+
const reason = request.reason?.trim() ?? null;
|
|
35
|
+
if (reason !== null && (reason.length === 0 || reason.length > MAX_REASON_LENGTH || reason.includes("\0"))) {
|
|
36
|
+
throw error("RUN_CONTROL_REASON_INVALID", "Control reason is empty or too large.");
|
|
37
|
+
}
|
|
38
|
+
return await this.#repository.mutate(runId, (current) => {
|
|
39
|
+
if (request.action === "resume" && ["complete", "blocked"].includes(current.status))
|
|
40
|
+
throw error("RUN_NOT_RESUMABLE", `Run ${runId} is ${current.status}.`);
|
|
41
|
+
const control = {
|
|
42
|
+
action: request.action,
|
|
43
|
+
actor: request.actor,
|
|
44
|
+
reason,
|
|
45
|
+
requestedAt: now,
|
|
46
|
+
expiresAt: request.expiresAt ?? null,
|
|
47
|
+
appliedAt: null
|
|
48
|
+
};
|
|
49
|
+
const status = request.action === "stop"
|
|
50
|
+
? "stopping"
|
|
51
|
+
: request.action === "pause"
|
|
52
|
+
? "paused"
|
|
53
|
+
: request.action === "resume" || request.action === "respond"
|
|
54
|
+
? "active"
|
|
55
|
+
: current.status;
|
|
56
|
+
return {
|
|
57
|
+
...replace(current, [...current.controls, control]),
|
|
58
|
+
status,
|
|
59
|
+
// A user-authorized resume re-enables reconciliation. The native
|
|
60
|
+
// process is still restarted only after the supervisor revalidates
|
|
61
|
+
// its generation and worktree lease.
|
|
62
|
+
disableRestart: request.action === "stop" ? true : request.action === "resume" ? false : current.disableRestart,
|
|
63
|
+
awaitingResume: request.action === "resume" ? false : current.awaitingResume
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async markApplied(runId, action, requestedAt) {
|
|
68
|
+
return await this.#repository.mutate(runId, (current) => {
|
|
69
|
+
const found = current.controls.findIndex((control) => control.action === action && control.requestedAt === requestedAt && control.appliedAt === null);
|
|
70
|
+
if (found < 0)
|
|
71
|
+
throw error("RUN_CONTROL_NOT_FOUND", `No pending ${action} control was found.`);
|
|
72
|
+
const controls = [...current.controls];
|
|
73
|
+
controls[found] = { ...controls[found], appliedAt: nowIso(this.#now) };
|
|
74
|
+
return replace(current, controls);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async pending(runId) {
|
|
78
|
+
const state = await this.#repository.read(runId);
|
|
79
|
+
if (state === null)
|
|
80
|
+
throw error("RUN_NOT_FOUND", `Run not found: ${runId}`);
|
|
81
|
+
return state.controls.filter((control) => control.appliedAt === null);
|
|
82
|
+
}
|
|
83
|
+
/** Keep the complete redacted scope; the digests identify the original input. */
|
|
84
|
+
async recordAuthorization(runId, authorization) {
|
|
85
|
+
if (sha256(authorization.command) !== authorization.commandDigest || sha256(authorization.resource) !== authorization.resourceDigest ||
|
|
86
|
+
!["auto-approved", "requires-host-approval", "denied", "unknown"].includes(authorization.result) ||
|
|
87
|
+
!["claude-auto-mode", "codex-auto-review", "coordinator-policy-review", "user", "repo-trust"].includes(authorization.source) ||
|
|
88
|
+
!Number.isSafeInteger(authorization.generation) || authorization.generation < 1 ||
|
|
89
|
+
authorization.workerId.length === 0 || authorization.workerId.length > 256 ||
|
|
90
|
+
authorization.operation.length === 0 || authorization.operation.length > 256) {
|
|
91
|
+
throw error("RUN_AUTHORIZATION_INVALID", "Run authorization must use command and resource digests.");
|
|
92
|
+
}
|
|
93
|
+
if (authorization.reason.length === 0 || authorization.reason.length > MAX_REASON_LENGTH)
|
|
94
|
+
throw error("RUN_AUTHORIZATION_INVALID", "Run authorization reason is invalid.");
|
|
95
|
+
const at = nowIso(this.#now);
|
|
96
|
+
if (!Number.isFinite(Date.parse(authorization.expiresAt)) ||
|
|
97
|
+
(authorization.result === "auto-approved" && Date.parse(authorization.expiresAt) <= Date.parse(at)) ||
|
|
98
|
+
!/^[a-f0-9]{64}$/u.test(authorization.policyConfigHash) || !/^[a-f0-9]{64}$/u.test(authorization.runtimeHash) ||
|
|
99
|
+
!/^[a-f0-9]{64}$/u.test(authorization.policyArtifactDigest) ||
|
|
100
|
+
authorization.authorizationId.length === 0 || authorization.authorizationId.length > 256 ||
|
|
101
|
+
authorization.command.length > 32 * 1024 || authorization.resource.length > 32 * 1024)
|
|
102
|
+
throw error("RUN_AUTHORIZATION_INVALID", "Authorization identity, expiry or scope is invalid.");
|
|
103
|
+
const id = "auth-" + sha256(canonicalJson({ runId, workerId: authorization.workerId, generation: authorization.generation,
|
|
104
|
+
nativeSessionId: authorization.nativeSessionId, source: authorization.source, id: authorization.authorizationId }));
|
|
105
|
+
const state = await this.#repository.read(runId);
|
|
106
|
+
if (state === null)
|
|
107
|
+
throw error("RUN_NOT_FOUND", "Run does not exist.");
|
|
108
|
+
const path = join(state.commonDir, "agent-ops/runs", runId, "authorizations", id + ".json");
|
|
109
|
+
const content = canonicalJson({ runId, ...authorization, command: redactSecrets(authorization.command),
|
|
110
|
+
resource: redactSecrets(authorization.resource), reason: redactSecrets(authorization.reason) });
|
|
111
|
+
const previous = await readPrivateFile(path, state.commonDir);
|
|
112
|
+
if (previous !== null && previous !== content)
|
|
113
|
+
throw error("RUN_AUTHORIZATION_CONFLICT", "An authorization identity cannot be reused for a different decision.");
|
|
114
|
+
const saved = await this.#repository.mutate(runId, async (current) => {
|
|
115
|
+
const worker = current.workers.find(w => w.workerId === authorization.workerId);
|
|
116
|
+
if (worker?.generation !== authorization.generation || worker.nativeSessionId !== authorization.nativeSessionId ||
|
|
117
|
+
current.status !== "active" || current.disableRestart || current.policyBinding?.configHash !== authorization.policyConfigHash ||
|
|
118
|
+
current.policyBinding.runtimeHash !== authorization.runtimeHash || current.policyBinding.expiresAt !== authorization.expiresAt ||
|
|
119
|
+
current.policyBinding.artifactDigest !== authorization.policyArtifactDigest)
|
|
120
|
+
throw error("RUN_AUTHORIZATION_STALE", "Authorization must bind the current worker, run policy and runtime.");
|
|
121
|
+
await writePrivateFile(path, content, current.commonDir);
|
|
122
|
+
if (current.events.some(event => event.id === id))
|
|
123
|
+
return current;
|
|
124
|
+
return { ...current, events: [...current.events, {
|
|
125
|
+
id,
|
|
126
|
+
at,
|
|
127
|
+
type: "control",
|
|
128
|
+
code: authorization.result === "auto-approved" ? "RUN_COMMAND_AUTO_APPROVED" : authorization.result === "denied" ? "RUN_COMMAND_DENIED" : "RUN_COMMAND_AUTHORIZATION_OBSERVED",
|
|
129
|
+
workerId: authorization.workerId,
|
|
130
|
+
taskId: worker.taskId,
|
|
131
|
+
detail: canonicalJson({ artifact: path, digest: sha256(content), result: authorization.result, source: authorization.source })
|
|
132
|
+
}].slice(-2_000)
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
if (authorization.result === "denied")
|
|
136
|
+
throw error("RUN_COMMAND_DENIED", "The native host or organization denied this command; its audit is saved and the denial cannot be bypassed.");
|
|
137
|
+
return saved;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { canonicalJson } from "../config/hash.js";
|
|
2
|
+
import { sha256 } from "../fs/hash.js";
|
|
3
|
+
const MAX_ID = 4_096;
|
|
4
|
+
function bounded(value, label) {
|
|
5
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID || value.includes("\0") || /[\r\n]/u.test(value)) {
|
|
6
|
+
throw new TypeError(`${label} must be a bounded single-line string.`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
function status(value) {
|
|
11
|
+
return value === "PASS" || value === "FAIL" ? value : "UNKNOWN";
|
|
12
|
+
}
|
|
13
|
+
function failureClass(value, current) {
|
|
14
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_ID || value.includes("\0") || /[\r\n]/u.test(value)) {
|
|
15
|
+
return current === "PASS" ? "none" : "unknown";
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function stablePins(values) {
|
|
20
|
+
return [...new Set((values ?? []).map((value) => bounded(value, "pinId")))].sort();
|
|
21
|
+
}
|
|
22
|
+
function stableIds(values, label) {
|
|
23
|
+
return [...new Set((values ?? []).map((value) => bounded(value, label)))].sort();
|
|
24
|
+
}
|
|
25
|
+
function itemKey(check) {
|
|
26
|
+
return {
|
|
27
|
+
criterionId: check.criterionId,
|
|
28
|
+
runnerId: check.runnerId,
|
|
29
|
+
checkId: check.checkId,
|
|
30
|
+
phase: check.phase,
|
|
31
|
+
pinId: check.pinId
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function identity(check) {
|
|
35
|
+
return {
|
|
36
|
+
...itemKey(check),
|
|
37
|
+
status: check.status,
|
|
38
|
+
failureClass: check.failureClass
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function sortedChecks(checks) {
|
|
42
|
+
return [...checks].sort((left, right) => canonicalJson(identity(left)).localeCompare(canonicalJson(identity(right))));
|
|
43
|
+
}
|
|
44
|
+
function commandObservation(result) {
|
|
45
|
+
const current = status(result.status);
|
|
46
|
+
return {
|
|
47
|
+
criterionId: null,
|
|
48
|
+
runnerId: null,
|
|
49
|
+
checkId: bounded(result.commandId, "commandId"),
|
|
50
|
+
phase: "verification",
|
|
51
|
+
status: current,
|
|
52
|
+
failureClass: failureClass(result.failureClass, current),
|
|
53
|
+
pinId: null
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function acceptanceObservations(result) {
|
|
57
|
+
const current = status(result.status);
|
|
58
|
+
const criterionId = bounded(result.criterionId, "criterionId");
|
|
59
|
+
const runnerId = bounded(result.runnerId, "runnerId");
|
|
60
|
+
const phase = result.phase === "baseline" || result.phase === "candidate" ? result.phase : "candidate";
|
|
61
|
+
const actual = new Map((result.checks ?? []).map((check) => [bounded(check.checkId, "checkId"), {
|
|
62
|
+
status: status(check.status),
|
|
63
|
+
failureClass: failureClass(check.failureClass, status(check.status))
|
|
64
|
+
}]));
|
|
65
|
+
const required = stableIds(result.requiredCheckIds, "checkId");
|
|
66
|
+
const red = stableIds(result.redCheckIds, "redCheckId");
|
|
67
|
+
const checkIds = [...new Set([...required, ...red, ...actual.keys()])].sort();
|
|
68
|
+
if (checkIds.length === 0) {
|
|
69
|
+
return [{
|
|
70
|
+
criterionId,
|
|
71
|
+
runnerId,
|
|
72
|
+
checkId: criterionId,
|
|
73
|
+
phase,
|
|
74
|
+
status: current,
|
|
75
|
+
failureClass: failureClass(result.failureClass, current),
|
|
76
|
+
pinId: null
|
|
77
|
+
}];
|
|
78
|
+
}
|
|
79
|
+
return checkIds.map((checkId) => {
|
|
80
|
+
const observed = actual.get(checkId);
|
|
81
|
+
// A PASS criterion is a successful contract result. Baseline assertion
|
|
82
|
+
// failures are the intended red proof and must not become repair work.
|
|
83
|
+
if (current === "PASS") {
|
|
84
|
+
return { criterionId, runnerId, checkId, phase, status: "PASS", failureClass: "none", pinId: null };
|
|
85
|
+
}
|
|
86
|
+
if (phase === "baseline" && red.length > 0) {
|
|
87
|
+
if (red.includes(checkId) && observed?.status === "FAIL" && observed.failureClass === "assertion") {
|
|
88
|
+
return { criterionId, runnerId, checkId, phase, status: "PASS", failureClass: "none", pinId: null };
|
|
89
|
+
}
|
|
90
|
+
if (red.includes(checkId)) {
|
|
91
|
+
return {
|
|
92
|
+
criterionId,
|
|
93
|
+
runnerId,
|
|
94
|
+
checkId,
|
|
95
|
+
phase,
|
|
96
|
+
status: "FAIL",
|
|
97
|
+
failureClass: observed === undefined ? "missing-red-check" : observed.status === "PASS" ? "non-discriminating" : observed.failureClass,
|
|
98
|
+
pinId: null
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
criterionId,
|
|
104
|
+
runnerId,
|
|
105
|
+
checkId,
|
|
106
|
+
phase,
|
|
107
|
+
status: observed?.status ?? current,
|
|
108
|
+
failureClass: observed?.failureClass ?? (observed === undefined ? "missing-check" : failureClass(result.failureClass, current)),
|
|
109
|
+
pinId: null
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function pinObservation(pinId) {
|
|
114
|
+
return {
|
|
115
|
+
criterionId: null,
|
|
116
|
+
runnerId: null,
|
|
117
|
+
checkId: pinId,
|
|
118
|
+
phase: "review",
|
|
119
|
+
status: "FAIL",
|
|
120
|
+
failureClass: "pinned-finding",
|
|
121
|
+
pinId
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Turn a verifier report and the saved finding ratchet into a stable semantic
|
|
126
|
+
* observation. Raw diagnostics, source commits, timestamps, and task failure
|
|
127
|
+
* fingerprints are deliberately not read by this function.
|
|
128
|
+
*/
|
|
129
|
+
export function deriveFailureObservation(report, options = {}) {
|
|
130
|
+
const checks = [
|
|
131
|
+
...report.results.filter((result) => result.required).map(commandObservation),
|
|
132
|
+
...(report.acceptance ?? []).flatMap(acceptanceObservations),
|
|
133
|
+
...stablePins(options.pendingPinIds).map(pinObservation)
|
|
134
|
+
];
|
|
135
|
+
if (checks.length === 0 && report.status !== undefined && report.status !== "PASS") {
|
|
136
|
+
checks.push({
|
|
137
|
+
criterionId: null,
|
|
138
|
+
runnerId: null,
|
|
139
|
+
checkId: "verification",
|
|
140
|
+
phase: "verification",
|
|
141
|
+
status: status(report.status),
|
|
142
|
+
failureClass: "verification-failed",
|
|
143
|
+
pinId: null
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const ordered = sortedChecks(checks);
|
|
147
|
+
const pendingPinIds = stablePins(options.pendingPinIds);
|
|
148
|
+
const failing = ordered.filter((check) => check.status !== "PASS").map(identity);
|
|
149
|
+
return {
|
|
150
|
+
taskId: bounded(report.taskId, "taskId"),
|
|
151
|
+
checks: ordered,
|
|
152
|
+
pendingPinIds,
|
|
153
|
+
failureKey: failing.length === 0 ? null : sha256(canonicalJson({ taskId: report.taskId, failing })),
|
|
154
|
+
progressDigest: sha256(canonicalJson({
|
|
155
|
+
taskId: report.taskId,
|
|
156
|
+
checks: ordered.map((check) => identity(check)),
|
|
157
|
+
pendingPinIds
|
|
158
|
+
}))
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Compare only stable check identities; diagnostic text never participates. */
|
|
162
|
+
export function compareFailureObservations(previous, current) {
|
|
163
|
+
const currentByItem = new Map(current.checks.map((check) => [canonicalJson(itemKey(check)), check]));
|
|
164
|
+
const passed = previous.checks.some((check) => {
|
|
165
|
+
if (check.status === "PASS")
|
|
166
|
+
return false;
|
|
167
|
+
return currentByItem.get(canonicalJson(itemKey(check)))?.status === "PASS";
|
|
168
|
+
});
|
|
169
|
+
const currentPins = new Set(current.pendingPinIds);
|
|
170
|
+
const dischargedPin = previous.pendingPinIds.some((pinId) => !currentPins.has(pinId));
|
|
171
|
+
return {
|
|
172
|
+
sameFailure: previous.failureKey !== null && previous.failureKey === current.failureKey,
|
|
173
|
+
usefulProgress: passed || dischargedPin,
|
|
174
|
+
failureKey: current.failureKey,
|
|
175
|
+
progressDigest: current.progressDigest
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { AgentOpsError } from "../../fs/paths.js";
|
|
3
|
+
import { JsonLineStream } from "./protocol.js";
|
|
4
|
+
import { actionResult, assertContext, assertProcessRunning, event, spawnNative, stopNativeProcess } from "./util.js";
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function stringField(value, key) {
|
|
9
|
+
return isRecord(value) && typeof value[key] === "string" ? value[key] : null;
|
|
10
|
+
}
|
|
11
|
+
function goalStatus(value) {
|
|
12
|
+
if (isRecord(value)) {
|
|
13
|
+
const subtype = stringField(value, "subtype");
|
|
14
|
+
if (subtype === "success")
|
|
15
|
+
return "complete";
|
|
16
|
+
if (subtype === "error_max_budget_usd" || subtype === "error_max_turns")
|
|
17
|
+
return "budgetLimited";
|
|
18
|
+
const status = stringField(value, "status");
|
|
19
|
+
if (status === "active" || status === "paused" || status === "blocked" ||
|
|
20
|
+
status === "usageLimited" || status === "budgetLimited" || status === "complete") {
|
|
21
|
+
return status;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
function writeMessage(handle, text) {
|
|
27
|
+
handle.process.stdin.write(`${JSON.stringify({
|
|
28
|
+
type: "user",
|
|
29
|
+
message: {
|
|
30
|
+
role: "user",
|
|
31
|
+
content: [{ type: "text", text }]
|
|
32
|
+
}
|
|
33
|
+
})}\n`);
|
|
34
|
+
}
|
|
35
|
+
export class ClaudeGoalHost {
|
|
36
|
+
options;
|
|
37
|
+
kind = "claude";
|
|
38
|
+
streams = new WeakMap();
|
|
39
|
+
now;
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.now = options.now ?? (() => new Date());
|
|
43
|
+
}
|
|
44
|
+
async start(input) {
|
|
45
|
+
return this.open(input, false, null);
|
|
46
|
+
}
|
|
47
|
+
async resume(input, sessionId, objective) {
|
|
48
|
+
if (sessionId.length === 0) {
|
|
49
|
+
throw new AgentOpsError("NATIVE_SESSION_INVALID", "Claude session id is required to resume.");
|
|
50
|
+
}
|
|
51
|
+
const handle = await this.open(input, true, sessionId);
|
|
52
|
+
if (objective !== undefined) {
|
|
53
|
+
await this.activate(handle, objective, input, input.tokenBudget);
|
|
54
|
+
}
|
|
55
|
+
return handle;
|
|
56
|
+
}
|
|
57
|
+
async activate(handle, objective, context, tokenBudget = null) {
|
|
58
|
+
assertContext(handle, context);
|
|
59
|
+
assertProcessRunning(handle.process);
|
|
60
|
+
if (objective.length === 0 || objective.length > 4000) {
|
|
61
|
+
throw new AgentOpsError("NATIVE_GOAL_INVALID", "Claude goal objective must be 1–4000 characters.");
|
|
62
|
+
}
|
|
63
|
+
const condition = tokenBudget === null
|
|
64
|
+
? `/goal ${objective}`
|
|
65
|
+
: `/goal ${objective} or stop after ${tokenBudget} tokens`;
|
|
66
|
+
writeMessage(handle, condition);
|
|
67
|
+
return actionResult(true, "active", { objective, tokenBudget });
|
|
68
|
+
}
|
|
69
|
+
async update(handle, update) {
|
|
70
|
+
assertContext(handle, update);
|
|
71
|
+
assertProcessRunning(handle.process);
|
|
72
|
+
return this.activate(handle, update.objective, update, update.tokenBudget ?? null);
|
|
73
|
+
}
|
|
74
|
+
async *observe(handle, options = {}) {
|
|
75
|
+
const stream = this.stream(handle);
|
|
76
|
+
while (true) {
|
|
77
|
+
const message = await stream.next(options.signal);
|
|
78
|
+
if (message === null) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const mapped = this.mapEvent(handle, message);
|
|
82
|
+
yield mapped;
|
|
83
|
+
if (mapped.type === "completed" || mapped.type === "error" || mapped.type === "closed") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async interrupt(handle, context) {
|
|
89
|
+
assertContext(handle, context);
|
|
90
|
+
if (handle.process.exitCode === null && handle.process.signalCode === null) {
|
|
91
|
+
handle.process.kill("SIGINT");
|
|
92
|
+
}
|
|
93
|
+
return actionResult(true, "paused", { signal: "SIGINT" });
|
|
94
|
+
}
|
|
95
|
+
async stop(handle, context, signal = "SIGTERM") {
|
|
96
|
+
assertContext(handle, context);
|
|
97
|
+
await stopNativeProcess(handle.process, signal);
|
|
98
|
+
return actionResult(true, "paused", { signal });
|
|
99
|
+
}
|
|
100
|
+
async open(input, resume, sessionId) {
|
|
101
|
+
const requestedSessionId = sessionId ?? input.sessionId ?? randomUUID();
|
|
102
|
+
const defaultArgs = resume
|
|
103
|
+
? [
|
|
104
|
+
"-p",
|
|
105
|
+
"--resume", requestedSessionId,
|
|
106
|
+
"--input-format", "stream-json",
|
|
107
|
+
"--output-format", "stream-json",
|
|
108
|
+
"--verbose", "--include-partial-messages",
|
|
109
|
+
"--permission-mode", "auto",
|
|
110
|
+
"--permission-prompts", "none"
|
|
111
|
+
]
|
|
112
|
+
: [
|
|
113
|
+
"-p",
|
|
114
|
+
"--session-id", requestedSessionId,
|
|
115
|
+
"--input-format", "stream-json",
|
|
116
|
+
"--output-format", "stream-json",
|
|
117
|
+
"--verbose", "--include-partial-messages",
|
|
118
|
+
"--permission-mode", "auto",
|
|
119
|
+
"--permission-prompts", "none"
|
|
120
|
+
];
|
|
121
|
+
const command = this.options.command ?? "claude";
|
|
122
|
+
const args = this.options.args ?? defaultArgs;
|
|
123
|
+
const process = spawnNative(command, args, input.cwd, input.env, this.options);
|
|
124
|
+
const stream = new JsonLineStream(process);
|
|
125
|
+
try {
|
|
126
|
+
if (process.pid !== undefined)
|
|
127
|
+
await input.registerProcess?.(process.pid);
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
stream.close(cause);
|
|
131
|
+
if (process.pid !== undefined)
|
|
132
|
+
await stopNativeProcess(process, "SIGKILL");
|
|
133
|
+
throw cause;
|
|
134
|
+
}
|
|
135
|
+
const handle = {
|
|
136
|
+
runId: input.runId,
|
|
137
|
+
workerId: input.workerId,
|
|
138
|
+
generation: input.generation,
|
|
139
|
+
contractHash: input.contractHash,
|
|
140
|
+
kind: this.kind,
|
|
141
|
+
// --session-id and --resume make this identity durable before the first
|
|
142
|
+
// user message. Claude can emit startup hook events before system/init,
|
|
143
|
+
// so start intentionally does not wait for that event.
|
|
144
|
+
sessionId: requestedSessionId,
|
|
145
|
+
threadId: null,
|
|
146
|
+
nativeVersion: input.nativeVersion ?? this.options.nativeVersion ?? null,
|
|
147
|
+
cwd: input.cwd,
|
|
148
|
+
processId: process.pid ?? null,
|
|
149
|
+
startedAt: this.now().toISOString(),
|
|
150
|
+
process,
|
|
151
|
+
goalStatus: "unknown"
|
|
152
|
+
};
|
|
153
|
+
this.streams.set(handle, stream);
|
|
154
|
+
return handle;
|
|
155
|
+
}
|
|
156
|
+
stream(handle) {
|
|
157
|
+
const stream = this.streams.get(handle);
|
|
158
|
+
if (stream === undefined) {
|
|
159
|
+
throw new AgentOpsError("NATIVE_SESSION_INVALID", "Unknown Claude native handle.");
|
|
160
|
+
}
|
|
161
|
+
return stream;
|
|
162
|
+
}
|
|
163
|
+
mapEvent(handle, payload) {
|
|
164
|
+
const type = stringField(payload, "type");
|
|
165
|
+
if (type === "result") {
|
|
166
|
+
const subtype = stringField(payload, "subtype");
|
|
167
|
+
return event(handle, subtype === "success" ? "completed" : "error", goalStatus(payload), payload, this.now);
|
|
168
|
+
}
|
|
169
|
+
if (type === "error" || (type === "system" && stringField(payload, "subtype") === "error")) {
|
|
170
|
+
return event(handle, "error", goalStatus(payload), payload, this.now);
|
|
171
|
+
}
|
|
172
|
+
if (isRecord(payload.usage) || typeof payload.total_cost_usd === "number") {
|
|
173
|
+
return event(handle, "usage", goalStatus(payload), payload, this.now);
|
|
174
|
+
}
|
|
175
|
+
if (type === "system" && stringField(payload, "subtype") === "goal") {
|
|
176
|
+
return event(handle, "goal-updated", goalStatus(payload), payload, this.now);
|
|
177
|
+
}
|
|
178
|
+
return event(handle, "message", goalStatus(payload), payload, this.now);
|
|
179
|
+
}
|
|
180
|
+
}
|