@kylecheng3146/agent-ops 0.1.4 → 0.1.5
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 +88 -13
- package/dist/packages/cli/src/args.js +39 -6
- package/dist/packages/cli/src/bin.js +33 -11
- package/dist/packages/cli/src/cli.js +6 -1
- package/dist/packages/cli/src/commands/doctor.js +31 -9
- package/dist/packages/cli/src/commands/hook.js +18 -16
- package/dist/packages/cli/src/commands/init.js +9 -5
- package/dist/packages/cli/src/commands/review.js +5 -1
- package/dist/packages/cli/src/commands/uninstall.js +6 -5
- package/dist/packages/cli/src/commands/update.js +13 -5
- package/dist/packages/cli/src/context.js +9 -3
- package/dist/packages/cli/src/hook-process.js +109 -7
- package/dist/packages/cli/src/plan-output.js +9 -4
- package/dist/packages/cli/src/public-plan.js +62 -0
- package/dist/packages/cli/src/version.js +1 -1
- package/dist/packages/cli/src/wizard.js +27 -11
- package/dist/runtime/src/adapters/claude/config.js +0 -8
- package/dist/runtime/src/adapters/claude/events.js +26 -0
- package/dist/runtime/src/adapters/claude/output.js +6 -6
- package/dist/runtime/src/adapters/claude/surfaces.js +70 -0
- package/dist/runtime/src/adapters/codex/config.js +29 -11
- package/dist/runtime/src/adapters/codex/events.js +26 -0
- package/dist/runtime/src/adapters/codex/output.js +10 -0
- package/dist/runtime/src/adapters/codex/surfaces.js +12 -0
- package/dist/runtime/src/adapters/opencode/config.js +170 -0
- package/dist/runtime/src/adapters/opencode/events.js +49 -0
- package/dist/runtime/src/adapters/opencode/input.js +32 -0
- package/dist/runtime/src/adapters/opencode/output.js +23 -0
- package/dist/runtime/src/adapters/opencode/surfaces.js +23 -0
- package/dist/runtime/src/config/explain.js +7 -0
- package/dist/runtime/src/config/hash.js +24 -0
- package/dist/runtime/src/config/merge.js +6 -2
- package/dist/runtime/src/config/migrate.js +19 -4
- package/dist/runtime/src/contracts.js +10 -1
- package/dist/runtime/src/fs/manifest.js +32 -1
- package/dist/runtime/src/fs/transaction.js +14 -2
- package/dist/runtime/src/hooks/advisory.js +16 -0
- package/dist/runtime/src/hooks/stop-service.js +70 -0
- package/dist/runtime/src/hooks/stop-verify.js +4 -1
- package/dist/runtime/src/install/doctor.js +119 -9
- package/dist/runtime/src/install/harness.js +296 -35
- package/dist/runtime/src/install/hooks.js +22 -17
- package/dist/runtime/src/install/ownership.js +110 -34
- package/dist/runtime/src/install/plan.js +207 -28
- package/dist/runtime/src/install/probes.js +9 -43
- package/dist/runtime/src/install/profiles.js +8 -1
- package/dist/runtime/src/install/surface-inspection.js +296 -0
- package/dist/runtime/src/install/surfaces.js +11 -0
- package/dist/runtime/src/install/uninstall.js +10 -3
- package/dist/runtime/src/install/update.js +8 -2
- package/dist/runtime/src/schema/validate.js +37 -18
- package/dist/runtime/src/task/service.js +3 -3
- package/dist/runtime/src/task/store.js +12 -3
- package/dist/runtime/src/verify/command-executor.js +113 -0
- package/dist/runtime/src/verify/evidence.js +4 -24
- package/dist/runtime/src/verify/service.js +20 -92
- package/dist/runtime/src/verify/spawn.js +6 -1
- package/docs/en/guides/configuration.md +83 -0
- package/docs/en/guides/quickstart.md +9 -0
- package/docs/en/guides/security.md +5 -0
- package/docs/en/spec/README.md +9 -0
- package/docs/en/spec/harness-adapters.md +66 -2
- package/docs/en/spec/maintenance.md +11 -0
- package/docs/en/spec/review.md +11 -0
- package/docs/zh-TW/guides/configuration.md +77 -0
- package/docs/zh-TW/guides/quickstart.md +9 -0
- package/docs/zh-TW/guides/security.md +5 -0
- package/docs/zh-TW/spec/README.md +9 -0
- package/docs/zh-TW/spec/harness-adapters.md +57 -3
- package/docs/zh-TW/spec/maintenance.md +11 -1
- package/docs/zh-TW/spec/review.md +10 -0
- package/package.json +4 -2
- package/schemas/config.schema.json +55 -1
- package/schemas/manifest.schema.json +7 -2
- package/templates/common/AGENTS.block.md +2 -1
- package/templates/common/CLAUDE.block.md +2 -1
- package/dist/runtime/src/review/claude-runner.js +0 -4
- package/dist/runtime/src/review/codex-runner.js +0 -4
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { resolveContainedPath } from "../fs/paths.js";
|
|
4
|
+
import { harnessDescriptor, harnessSurfaceByPath, harnessSurfaces } from "./harness.js";
|
|
5
|
+
import { resolveCapabilities, resolveProfiles } from "./profiles.js";
|
|
6
|
+
import { isWritableSurface } from "./surfaces.js";
|
|
7
|
+
const MAX_SURFACE_FILE_BYTES = 1024 * 1024;
|
|
8
|
+
function isMissing(error) {
|
|
9
|
+
return (typeof error === "object" &&
|
|
10
|
+
error !== null &&
|
|
11
|
+
"code" in error &&
|
|
12
|
+
error.code === "ENOENT");
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
async function readSurface(root, surface) {
|
|
18
|
+
if (surface.scope === "external") {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
let resolvedPath;
|
|
22
|
+
try {
|
|
23
|
+
resolvedPath = await resolveContainedPath(root, surface.path);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isMissing(error)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
let before;
|
|
32
|
+
try {
|
|
33
|
+
before = await lstat(resolvedPath, { bigint: true });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (isMissing(error)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
if (!before.isFile() ||
|
|
42
|
+
before.size > BigInt(MAX_SURFACE_FILE_BYTES)) {
|
|
43
|
+
throw new Error("Surface is not a bounded regular file.");
|
|
44
|
+
}
|
|
45
|
+
const handle = await open(resolvedPath, constants.O_RDONLY |
|
|
46
|
+
constants.O_NOFOLLOW |
|
|
47
|
+
constants.O_NONBLOCK);
|
|
48
|
+
try {
|
|
49
|
+
const opened = await handle.stat({ bigint: true });
|
|
50
|
+
const resolvedAgain = await resolveContainedPath(root, surface.path);
|
|
51
|
+
const after = await lstat(resolvedAgain, { bigint: true });
|
|
52
|
+
if (!opened.isFile() ||
|
|
53
|
+
opened.size > BigInt(MAX_SURFACE_FILE_BYTES) ||
|
|
54
|
+
before.dev !== after.dev ||
|
|
55
|
+
before.ino !== after.ino) {
|
|
56
|
+
throw new Error("Surface identity changed during inspection.");
|
|
57
|
+
}
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let totalBytes = 0;
|
|
60
|
+
while (totalBytes <= MAX_SURFACE_FILE_BYTES) {
|
|
61
|
+
const remaining = MAX_SURFACE_FILE_BYTES + 1 - totalBytes;
|
|
62
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, remaining));
|
|
63
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
64
|
+
if (bytesRead === 0) {
|
|
65
|
+
return {
|
|
66
|
+
source: Buffer.concat(chunks, totalBytes).toString("utf8")
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
70
|
+
totalBytes += bytesRead;
|
|
71
|
+
}
|
|
72
|
+
throw new Error("Surface exceeded its inspection limit.");
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function sameEvents(left, right) {
|
|
79
|
+
return (left.length === right.length &&
|
|
80
|
+
left.every((event) => right.includes(event)));
|
|
81
|
+
}
|
|
82
|
+
function desiredCapabilities(config) {
|
|
83
|
+
return config.profiles.length === 0
|
|
84
|
+
? []
|
|
85
|
+
: resolveCapabilities(config).capabilities;
|
|
86
|
+
}
|
|
87
|
+
function managedJsonCount(source, isManagedHandler) {
|
|
88
|
+
if (source === null) {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
return jsonHandlerCounts(source, isManagedHandler)?.managed ?? 0;
|
|
92
|
+
}
|
|
93
|
+
export async function inspectHarnessRegistrations(options) {
|
|
94
|
+
const capabilities = desiredCapabilities(options.config);
|
|
95
|
+
const statuses = [];
|
|
96
|
+
for (const harness of options.manifest.harness) {
|
|
97
|
+
const descriptor = harnessDescriptor(harness);
|
|
98
|
+
const control = descriptor.control;
|
|
99
|
+
const hookRecord = options.manifest.hooks?.find(({ harness: recordHarness }) => recordHarness === harness);
|
|
100
|
+
const recordedEvents = hookRecord?.events ?? [];
|
|
101
|
+
const desiredEvents = control.buildHooks === undefined
|
|
102
|
+
? []
|
|
103
|
+
: Object.keys(control.buildHooks(capabilities, "probe").hooks);
|
|
104
|
+
if (control.buildHooks !== undefined) {
|
|
105
|
+
const surfaces = harnessSurfaces(harness, options.manifest.scope, options.root);
|
|
106
|
+
const writableJsonSurfaces = surfaces.filter((surface) => isWritableSurface(surface) && surface.representation === "json");
|
|
107
|
+
let managedCount = 0;
|
|
108
|
+
for (const surface of writableJsonSurfaces) {
|
|
109
|
+
try {
|
|
110
|
+
const read = await readSurface(options.root, surface);
|
|
111
|
+
managedCount += managedJsonCount(read?.source ?? null, control.isManagedHandler);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// An unsafe surface is treated as drift below when it is selected.
|
|
115
|
+
managedCount += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const selectedSurface = hookRecord === undefined
|
|
119
|
+
? writableJsonSurfaces.find((surface) => surface.access === "managed-default")
|
|
120
|
+
: harnessSurfaceByPath(harness, options.manifest.scope, hookRecord.path, options.root);
|
|
121
|
+
let source = null;
|
|
122
|
+
if (selectedSurface !== undefined) {
|
|
123
|
+
try {
|
|
124
|
+
source = (await readSurface(options.root, selectedSurface))?.source ??
|
|
125
|
+
null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
source = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const registered = hookRecord === undefined
|
|
132
|
+
? desiredEvents.length === 0 && managedCount === 0
|
|
133
|
+
: selectedSurface !== undefined &&
|
|
134
|
+
sameEvents(desiredEvents, recordedEvents) &&
|
|
135
|
+
(desiredEvents.length === 0
|
|
136
|
+
? managedCount === 0
|
|
137
|
+
: source !== null &&
|
|
138
|
+
control.hookRegistered(source, capabilities));
|
|
139
|
+
statuses.push({
|
|
140
|
+
harness,
|
|
141
|
+
registered,
|
|
142
|
+
desiredEvents,
|
|
143
|
+
recordedEvents
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const pluginArtifact = options.manifest.artifacts.find(({ id }) => id === "opencode-plugin");
|
|
148
|
+
const hasDesiredPlugin = control.registrations.some((registration) => capabilities.includes(registration.capability));
|
|
149
|
+
let registered = !hasDesiredPlugin && pluginArtifact === undefined;
|
|
150
|
+
if (hasDesiredPlugin && pluginArtifact !== undefined) {
|
|
151
|
+
const discovered = harnessSurfaces(harness, options.manifest.scope, options.root).find((surface) => surface.id === "opencode-plugin");
|
|
152
|
+
const selectedSurface = discovered === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: { ...discovered, path: pluginArtifact.path };
|
|
155
|
+
try {
|
|
156
|
+
const source = selectedSurface === undefined
|
|
157
|
+
? null
|
|
158
|
+
: (await readSurface(options.root, selectedSurface))?.source ?? null;
|
|
159
|
+
registered =
|
|
160
|
+
source !== null && control.hookRegistered(source, capabilities);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
registered = false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!hasDesiredPlugin && pluginArtifact !== undefined) {
|
|
167
|
+
registered = false;
|
|
168
|
+
}
|
|
169
|
+
statuses.push({
|
|
170
|
+
harness,
|
|
171
|
+
registered,
|
|
172
|
+
desiredEvents,
|
|
173
|
+
recordedEvents
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return statuses;
|
|
177
|
+
}
|
|
178
|
+
function jsonHandlerCounts(source, isManagedHandler) {
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(source);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (!isRecord(parsed)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const hooks = parsed.hooks;
|
|
190
|
+
if (hooks === undefined) {
|
|
191
|
+
return { managed: 0, foreign: 0 };
|
|
192
|
+
}
|
|
193
|
+
if (!isRecord(hooks)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
let managed = 0;
|
|
197
|
+
let foreign = 0;
|
|
198
|
+
for (const groups of Object.values(hooks)) {
|
|
199
|
+
if (!Array.isArray(groups)) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
for (const group of groups) {
|
|
203
|
+
if (!isRecord(group) || !Array.isArray(group.hooks)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
for (const handler of group.hooks) {
|
|
207
|
+
if (isManagedHandler?.(handler) === true) {
|
|
208
|
+
managed += 1;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
foreign += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { managed, foreign };
|
|
217
|
+
}
|
|
218
|
+
function handlerCounts(surface, source, capabilities, isManagedHandler, hookRegistered) {
|
|
219
|
+
if (surface.representation === "json") {
|
|
220
|
+
return jsonHandlerCounts(source, isManagedHandler);
|
|
221
|
+
}
|
|
222
|
+
if (surface.representation === "javascript") {
|
|
223
|
+
return {
|
|
224
|
+
managed: hookRegistered(source, capabilities) ? 1 : 0,
|
|
225
|
+
foreign: 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { managed: 0, foreign: 0 };
|
|
229
|
+
}
|
|
230
|
+
export async function inspectHarnessSurfaces(options) {
|
|
231
|
+
const capabilities = options.profiles.length === 0
|
|
232
|
+
? []
|
|
233
|
+
: resolveProfiles(options.profiles).capabilities;
|
|
234
|
+
const statuses = [];
|
|
235
|
+
for (const harness of options.harness) {
|
|
236
|
+
const descriptor = harnessDescriptor(harness);
|
|
237
|
+
for (const surface of harnessSurfaces(harness, options.scope, options.root)) {
|
|
238
|
+
if (surface.scope === "external") {
|
|
239
|
+
statuses.push({
|
|
240
|
+
harness,
|
|
241
|
+
surfaceId: surface.id,
|
|
242
|
+
path: surface.path,
|
|
243
|
+
status: "unknown",
|
|
244
|
+
managedHandlerCount: 0,
|
|
245
|
+
foreignHandlerCount: 0
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const read = await readSurface(options.root, surface);
|
|
251
|
+
if (read === null) {
|
|
252
|
+
statuses.push({
|
|
253
|
+
harness,
|
|
254
|
+
surfaceId: surface.id,
|
|
255
|
+
path: surface.path,
|
|
256
|
+
status: "missing",
|
|
257
|
+
managedHandlerCount: 0,
|
|
258
|
+
foreignHandlerCount: 0
|
|
259
|
+
});
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const counts = handlerCounts(surface, read.source, capabilities, descriptor.control.isManagedHandler, descriptor.control.hookRegistered);
|
|
263
|
+
if (counts === null) {
|
|
264
|
+
statuses.push({
|
|
265
|
+
harness,
|
|
266
|
+
surfaceId: surface.id,
|
|
267
|
+
path: surface.path,
|
|
268
|
+
status: "unknown",
|
|
269
|
+
managedHandlerCount: 0,
|
|
270
|
+
foreignHandlerCount: 0
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
statuses.push({
|
|
275
|
+
harness,
|
|
276
|
+
surfaceId: surface.id,
|
|
277
|
+
path: surface.path,
|
|
278
|
+
status: counts.managed > 0 ? "managed" : "foreign",
|
|
279
|
+
managedHandlerCount: counts.managed,
|
|
280
|
+
foreignHandlerCount: counts.foreign
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
statuses.push({
|
|
285
|
+
harness,
|
|
286
|
+
surfaceId: surface.id,
|
|
287
|
+
path: surface.path,
|
|
288
|
+
status: "unknown",
|
|
289
|
+
managedHandlerCount: 0,
|
|
290
|
+
foreignHandlerCount: 0
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return statuses;
|
|
296
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function findSurfaceById(surfaces, id) {
|
|
2
|
+
return surfaces.find((surface) => surface.id === id);
|
|
3
|
+
}
|
|
4
|
+
export function findSurfaceByPath(surfaces, path) {
|
|
5
|
+
return surfaces.find((surface) => surface.path === path);
|
|
6
|
+
}
|
|
7
|
+
export function isWritableSurface(surface) {
|
|
8
|
+
return (surface !== undefined &&
|
|
9
|
+
(surface.access === "managed-default" ||
|
|
10
|
+
surface.access === "managed-opt-in"));
|
|
11
|
+
}
|
|
@@ -7,6 +7,7 @@ import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
|
|
|
7
7
|
import { FileTransaction } from "../fs/transaction.js";
|
|
8
8
|
import { planHookRemoval } from "./hooks.js";
|
|
9
9
|
import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
|
|
10
|
+
import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
|
|
10
11
|
const MANIFEST_PATH = ".agent-ops/manifest.json";
|
|
11
12
|
const MAX_UNINSTALL_FILE_BYTES = 1024 * 1024;
|
|
12
13
|
function isMissing(error) {
|
|
@@ -117,13 +118,17 @@ export async function createUninstallPlan(root) {
|
|
|
117
118
|
};
|
|
118
119
|
}
|
|
119
120
|
const manifest = parseInstallManifest(currentManifest.content);
|
|
120
|
-
const expectedMarkers = assertSupportedManifestOwnership(manifest);
|
|
121
|
+
const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
|
|
121
122
|
const operations = [];
|
|
122
123
|
for (const artifact of manifest.artifacts) {
|
|
123
124
|
const current = await readCurrentFile(root, artifact.path);
|
|
124
125
|
if (current === null || current.hash !== artifact.hash) {
|
|
125
126
|
throw new AgentOpsError("MANAGED_ARTIFACT_CHANGED", `Managed artifact changed after installation: ${artifact.path}`);
|
|
126
127
|
}
|
|
128
|
+
if (artifact.id === "opencode-plugin" &&
|
|
129
|
+
!isOpencodeManagedPlugin(current.content)) {
|
|
130
|
+
throw new AgentOpsError("MANIFEST_OWNERSHIP_INVALID", `The recorded opencode plugin is not an agent-ops managed plugin: ${artifact.path}`);
|
|
131
|
+
}
|
|
127
132
|
operations.push({
|
|
128
133
|
kind: "remove",
|
|
129
134
|
path: artifact.path,
|
|
@@ -141,13 +146,15 @@ export async function createUninstallPlan(root) {
|
|
|
141
146
|
? {
|
|
142
147
|
kind: "remove",
|
|
143
148
|
path: hook.path,
|
|
144
|
-
expectedHash: current.hash
|
|
149
|
+
expectedHash: current.hash,
|
|
150
|
+
disclosure: removal.disclosure
|
|
145
151
|
}
|
|
146
152
|
: {
|
|
147
153
|
kind: "write",
|
|
148
154
|
path: hook.path,
|
|
149
155
|
content: removal.content,
|
|
150
|
-
expectedHash: current.hash
|
|
156
|
+
expectedHash: current.hash,
|
|
157
|
+
disclosure: removal.disclosure
|
|
151
158
|
});
|
|
152
159
|
}
|
|
153
160
|
operations.push({
|
|
@@ -88,7 +88,9 @@ export async function createUpdatePlan(options) {
|
|
|
88
88
|
"manifest",
|
|
89
89
|
"markers"
|
|
90
90
|
]) {
|
|
91
|
-
|
|
91
|
+
const status = statusOf(report, id);
|
|
92
|
+
if (status !== "PASS" &&
|
|
93
|
+
!(id === "markers" && status === "DEGRADED")) {
|
|
92
94
|
throw new AgentOpsError("UPDATE_INSTALLATION_INVALID", `Update requires a passing ${id} doctor check.`);
|
|
93
95
|
}
|
|
94
96
|
}
|
|
@@ -103,13 +105,17 @@ export async function createUpdatePlan(options) {
|
|
|
103
105
|
const installation = await createInstallPlan({
|
|
104
106
|
root: options.root,
|
|
105
107
|
scope: report.manifest.scope,
|
|
106
|
-
harness: report.manifest.harness,
|
|
108
|
+
harness: options.harness ?? report.manifest.harness,
|
|
107
109
|
profiles: configPreview.migrated.profiles,
|
|
108
110
|
adapters: options.adapters,
|
|
109
111
|
toolkitVersion: targetVersion,
|
|
112
|
+
allowHarnessChange: true,
|
|
110
113
|
...(options.hookRuntimePath === undefined
|
|
111
114
|
? {}
|
|
112
115
|
: { hookRuntimePath: options.hookRuntimePath }),
|
|
116
|
+
...(options.hookTargets === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { hookTargets: options.hookTargets }),
|
|
113
119
|
existingConfig: {
|
|
114
120
|
value: configPreview.migrated,
|
|
115
121
|
sourceHash: configPreview.sourceHash
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CONFIG_SCHEMA_VERSION, EVIDENCE_SCHEMA_VERSION, MANIFEST_SCHEMA_VERSION, TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
2
2
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,127}$/;
|
|
3
3
|
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
4
4
|
const WINDOWS_RESERVED_SEGMENT = /^(?:aux|com[1-9]|con|lpt[1-9]|nul|prn)(?:\..*)?$/i;
|
|
5
5
|
const PROFILE_VALUES = new Set(["advisory", "core", "guardrails"]);
|
|
6
6
|
const EVIDENCE_KINDS = new Set(["exit-code", "file", "test-count"]);
|
|
7
7
|
const SCOPE_VALUES = new Set(["project", "user"]);
|
|
8
|
-
const HARNESS_VALUES = new Set(["
|
|
8
|
+
const HARNESS_VALUES = new Set(["claude", "codex", "opencode"]);
|
|
9
|
+
// opencode's plugin is a managed artifact, not a ManagedHookRecord entry.
|
|
9
10
|
const HOOK_HARNESS_VALUES = new Set(["claude", "codex"]);
|
|
10
11
|
const HOOK_EVENT_VALUES = new Set([
|
|
11
12
|
"SessionStart",
|
|
@@ -38,12 +39,12 @@ function unknownFieldFailure(value, allowed, path) {
|
|
|
38
39
|
? undefined
|
|
39
40
|
: failure("UNKNOWN_FIELD", `${path}.${unknown}`, `Unknown field: ${unknown}`);
|
|
40
41
|
}
|
|
41
|
-
function validateRoot(value, allowed) {
|
|
42
|
+
function validateRoot(value, allowed, expectedVersion) {
|
|
42
43
|
if (!isRecord(value)) {
|
|
43
44
|
return failure("INVALID_TYPE", "$", "Expected an object.");
|
|
44
45
|
}
|
|
45
|
-
if (value.schemaVersion !==
|
|
46
|
-
return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${
|
|
46
|
+
if (value.schemaVersion !== expectedVersion) {
|
|
47
|
+
return failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion", `Expected schemaVersion ${expectedVersion}.`);
|
|
47
48
|
}
|
|
48
49
|
return unknownFieldFailure(value, allowed, "$") ?? value;
|
|
49
50
|
}
|
|
@@ -277,12 +278,13 @@ function validateSecurityException(value, path) {
|
|
|
277
278
|
}
|
|
278
279
|
export function validateConfig(value) {
|
|
279
280
|
const root = validateRoot(value, [
|
|
281
|
+
"features",
|
|
280
282
|
"pathMappings",
|
|
281
283
|
"profiles",
|
|
282
284
|
"schemaVersion",
|
|
283
285
|
"securityExceptions",
|
|
284
286
|
"verification"
|
|
285
|
-
]);
|
|
287
|
+
], CONFIG_SCHEMA_VERSION);
|
|
286
288
|
if (isFailure(root)) {
|
|
287
289
|
return root;
|
|
288
290
|
}
|
|
@@ -306,6 +308,23 @@ export function validateConfig(value) {
|
|
|
306
308
|
if (!hasUniqueStrings(root.profiles)) {
|
|
307
309
|
return failure("DUPLICATE_ID", "$.profiles", "Profiles must be unique.");
|
|
308
310
|
}
|
|
311
|
+
if (!isRecord(root.features)) {
|
|
312
|
+
return failure("INVALID_TYPE", "$.features", "features must be an object.");
|
|
313
|
+
}
|
|
314
|
+
const featuresUnknown = unknownFieldFailure(root.features, ["stopVerification"], "$.features");
|
|
315
|
+
if (featuresUnknown !== undefined) {
|
|
316
|
+
return featuresUnknown;
|
|
317
|
+
}
|
|
318
|
+
if (!isRecord(root.features.stopVerification)) {
|
|
319
|
+
return failure("INVALID_TYPE", "$.features.stopVerification", "stopVerification must be an object.");
|
|
320
|
+
}
|
|
321
|
+
const stopVerificationUnknown = unknownFieldFailure(root.features.stopVerification, ["enabled"], "$.features.stopVerification");
|
|
322
|
+
if (stopVerificationUnknown !== undefined) {
|
|
323
|
+
return stopVerificationUnknown;
|
|
324
|
+
}
|
|
325
|
+
if (typeof root.features.stopVerification.enabled !== "boolean") {
|
|
326
|
+
return failure("INVALID_FEATURE", "$.features.stopVerification.enabled", "stopVerification.enabled must be a boolean.");
|
|
327
|
+
}
|
|
309
328
|
if (!isRecord(root.verification)) {
|
|
310
329
|
return failure("INVALID_TYPE", "$.verification", "verification must be an object.");
|
|
311
330
|
}
|
|
@@ -327,6 +346,10 @@ export function validateConfig(value) {
|
|
|
327
346
|
}
|
|
328
347
|
commandIds.add(command.value.id);
|
|
329
348
|
}
|
|
349
|
+
if (root.features.stopVerification.enabled === true &&
|
|
350
|
+
root.verification.commands.length === 0) {
|
|
351
|
+
return failure("STOP_VERIFICATION_COMMANDS_REQUIRED", "$.features.stopVerification.enabled", "Stop verification requires at least one verification command.");
|
|
352
|
+
}
|
|
330
353
|
if (!Array.isArray(root.pathMappings)) {
|
|
331
354
|
return failure("INVALID_TYPE", "$.pathMappings", "pathMappings must be an array.");
|
|
332
355
|
}
|
|
@@ -372,7 +395,7 @@ function validateCriterion(value, path) {
|
|
|
372
395
|
return success(value);
|
|
373
396
|
}
|
|
374
397
|
export function validateTask(value) {
|
|
375
|
-
const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"]);
|
|
398
|
+
const root = validateRoot(value, ["criteria", "id", "schemaVersion", "title"], TASK_SCHEMA_VERSION);
|
|
376
399
|
if (isFailure(root)) {
|
|
377
400
|
return root;
|
|
378
401
|
}
|
|
@@ -433,7 +456,7 @@ export function validateEvidence(value) {
|
|
|
433
456
|
"taskId",
|
|
434
457
|
"testCount",
|
|
435
458
|
"toolVersions"
|
|
436
|
-
]);
|
|
459
|
+
], EVIDENCE_SCHEMA_VERSION);
|
|
437
460
|
if (isFailure(root)) {
|
|
438
461
|
return root;
|
|
439
462
|
}
|
|
@@ -556,22 +579,18 @@ function validateManagedHook(value, path) {
|
|
|
556
579
|
return success(value);
|
|
557
580
|
}
|
|
558
581
|
export function validateManifest(value) {
|
|
559
|
-
const root = validateRoot(value, [
|
|
560
|
-
"artifacts",
|
|
561
|
-
"harness",
|
|
562
|
-
"hooks",
|
|
563
|
-
"markers",
|
|
564
|
-
"schemaVersion",
|
|
565
|
-
"scope"
|
|
566
|
-
]);
|
|
582
|
+
const root = validateRoot(value, ["artifacts", "harness", "hooks", "markers", "schemaVersion", "scope"], MANIFEST_SCHEMA_VERSION);
|
|
567
583
|
if (isFailure(root)) {
|
|
568
584
|
return root;
|
|
569
585
|
}
|
|
570
586
|
if (typeof root.scope !== "string" || !SCOPE_VALUES.has(root.scope)) {
|
|
571
587
|
return failure("INVALID_SCOPE", "$.scope", "Unsupported install scope.");
|
|
572
588
|
}
|
|
573
|
-
if (
|
|
574
|
-
|
|
589
|
+
if (!Array.isArray(root.harness) ||
|
|
590
|
+
root.harness.length === 0 ||
|
|
591
|
+
!root.harness.every((id) => typeof id === "string" && HARNESS_VALUES.has(id)) ||
|
|
592
|
+
new Set(root.harness).size !== root.harness.length) {
|
|
593
|
+
return failure("INVALID_HARNESS", "$.harness", "Harness must be a non-empty list of unique supported harnesses.");
|
|
575
594
|
}
|
|
576
595
|
if (!Array.isArray(root.artifacts) || !Array.isArray(root.markers)) {
|
|
577
596
|
return failure("INVALID_TYPE", "$.artifacts", "artifacts and markers must be arrays.");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
3
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
4
|
import { validateTask } from "../schema/validate.js";
|
|
5
5
|
import { renderTaskMarkdown } from "./render.js";
|
|
@@ -74,7 +74,7 @@ export class TaskService {
|
|
|
74
74
|
}
|
|
75
75
|
async create(input) {
|
|
76
76
|
const task = {
|
|
77
|
-
schemaVersion:
|
|
77
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
78
78
|
id: this.#generateId(),
|
|
79
79
|
title: input.title,
|
|
80
80
|
criteria: [...input.criteria]
|
|
@@ -117,7 +117,7 @@ export class TaskService {
|
|
|
117
117
|
const state = await this.#store.read();
|
|
118
118
|
if (query.taskId !== undefined) {
|
|
119
119
|
return cloneRecord(findTask({
|
|
120
|
-
schemaVersion:
|
|
120
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
121
121
|
tasks: [...state.tasks],
|
|
122
122
|
sessions: [...state.sessions]
|
|
123
123
|
}, query.taskId));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
2
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
3
4
|
import { validateTask } from "../schema/validate.js";
|
|
4
5
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
@@ -154,7 +155,11 @@ function parseSession(value) {
|
|
|
154
155
|
}
|
|
155
156
|
function parseState(source) {
|
|
156
157
|
if (source === null) {
|
|
157
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
160
|
+
tasks: [],
|
|
161
|
+
sessions: []
|
|
162
|
+
};
|
|
158
163
|
}
|
|
159
164
|
let value;
|
|
160
165
|
try {
|
|
@@ -165,7 +170,7 @@ function parseState(source) {
|
|
|
165
170
|
}
|
|
166
171
|
if (!isRecord(value) ||
|
|
167
172
|
!hasExactKeys(value, ["schemaVersion", "sessions", "tasks"]) ||
|
|
168
|
-
value.schemaVersion !==
|
|
173
|
+
value.schemaVersion !== TASK_SCHEMA_VERSION ||
|
|
169
174
|
!Array.isArray(value.tasks) ||
|
|
170
175
|
!Array.isArray(value.sessions)) {
|
|
171
176
|
return invalidState("Task state has an unsupported structure.");
|
|
@@ -184,7 +189,11 @@ function parseState(source) {
|
|
|
184
189
|
})) {
|
|
185
190
|
return invalidState("Session attachments must reference known tasks.");
|
|
186
191
|
}
|
|
187
|
-
return {
|
|
192
|
+
return {
|
|
193
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
194
|
+
tasks,
|
|
195
|
+
sessions
|
|
196
|
+
};
|
|
188
197
|
}
|
|
189
198
|
function positiveMaxBytes(value) {
|
|
190
199
|
if (value === undefined) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { runVerificationCommand } from "./spawn.js";
|
|
2
|
+
import { evaluateTestCount, parseTestCount } from "./test-count.js";
|
|
3
|
+
function classifyTestCountCode(code) {
|
|
4
|
+
const classes = {
|
|
5
|
+
TEST_COUNT_BELOW_MINIMUM: "test-count-below-minimum",
|
|
6
|
+
TEST_COUNT_INVALID: "test-count-invalid",
|
|
7
|
+
TEST_COUNT_OK: "none",
|
|
8
|
+
TEST_COUNT_REQUIREMENT_INVALID: "test-count-requirement-invalid",
|
|
9
|
+
TEST_COUNT_UNPARSEABLE: "test-count-unparseable",
|
|
10
|
+
ZERO_TESTS: "zero-tests"
|
|
11
|
+
};
|
|
12
|
+
return classes[code];
|
|
13
|
+
}
|
|
14
|
+
function untrustedResult(commandId) {
|
|
15
|
+
return {
|
|
16
|
+
commandId,
|
|
17
|
+
status: "UNKNOWN",
|
|
18
|
+
failureClass: "repository-untrusted",
|
|
19
|
+
exitCode: null,
|
|
20
|
+
signal: null,
|
|
21
|
+
timedOut: false,
|
|
22
|
+
durationMs: 0,
|
|
23
|
+
stdout: "",
|
|
24
|
+
stderr: "",
|
|
25
|
+
stdoutTruncated: false,
|
|
26
|
+
stderrTruncated: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function classifyCommand(command, spawned) {
|
|
30
|
+
if (command.evidence.kind === "file") {
|
|
31
|
+
return {
|
|
32
|
+
status: spawned.status === "PASS" ? "UNKNOWN" : spawned.status,
|
|
33
|
+
failureClass: spawned.status === "PASS"
|
|
34
|
+
? "file-evidence-unsupported"
|
|
35
|
+
: spawned.failureClass,
|
|
36
|
+
testCount: null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (command.evidence.kind !== "test-count") {
|
|
40
|
+
return {
|
|
41
|
+
status: spawned.status,
|
|
42
|
+
failureClass: spawned.failureClass,
|
|
43
|
+
testCount: null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const testCount = parseTestCount(`${spawned.stdout}\n${spawned.stderr}`);
|
|
47
|
+
if (spawned.status !== "PASS") {
|
|
48
|
+
return {
|
|
49
|
+
status: spawned.status,
|
|
50
|
+
failureClass: spawned.failureClass,
|
|
51
|
+
testCount
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const evaluation = evaluateTestCount(testCount, command.evidence.minimum);
|
|
55
|
+
return {
|
|
56
|
+
status: evaluation.status,
|
|
57
|
+
failureClass: classifyTestCountCode(evaluation.code),
|
|
58
|
+
testCount: evaluation.testCount
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function diagnostic(spawned) {
|
|
62
|
+
return spawned.stderr || spawned.stdout || spawned.failureClass;
|
|
63
|
+
}
|
|
64
|
+
export async function executeConfiguredCommand(command, options) {
|
|
65
|
+
let spawned;
|
|
66
|
+
if (!options.trusted) {
|
|
67
|
+
spawned = untrustedResult(command.id);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const runOptions = {
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
...(options.runner === undefined
|
|
73
|
+
? {}
|
|
74
|
+
: { runner: options.runner }),
|
|
75
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
76
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
77
|
+
...(options.outputLimitBytes === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { outputLimitBytes: options.outputLimitBytes }),
|
|
80
|
+
...(options.terminationGraceMs === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { terminationGraceMs: options.terminationGraceMs })
|
|
83
|
+
};
|
|
84
|
+
spawned = await runVerificationCommand(command, runOptions);
|
|
85
|
+
}
|
|
86
|
+
const classified = classifyCommand(command, spawned);
|
|
87
|
+
return {
|
|
88
|
+
commandId: command.id,
|
|
89
|
+
required: command.required,
|
|
90
|
+
status: classified.status,
|
|
91
|
+
failureClass: classified.failureClass,
|
|
92
|
+
exitCode: spawned.exitCode,
|
|
93
|
+
signal: spawned.signal,
|
|
94
|
+
timedOut: spawned.timedOut,
|
|
95
|
+
testCount: classified.testCount,
|
|
96
|
+
diagnostic: diagnostic(spawned),
|
|
97
|
+
stdout: spawned.stdout,
|
|
98
|
+
stderr: spawned.stderr,
|
|
99
|
+
stdoutTruncated: spawned.stdoutTruncated,
|
|
100
|
+
stderrTruncated: spawned.stderrTruncated
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function aggregateVerificationStatus(results) {
|
|
104
|
+
const required = results.filter((result) => result.required);
|
|
105
|
+
const gating = required.length > 0 ? required : results;
|
|
106
|
+
if (gating.some((result) => result.status === "FAIL")) {
|
|
107
|
+
return "FAIL";
|
|
108
|
+
}
|
|
109
|
+
if (gating.some((result) => result.status === "UNKNOWN")) {
|
|
110
|
+
return "UNKNOWN";
|
|
111
|
+
}
|
|
112
|
+
return "PASS";
|
|
113
|
+
}
|