@zeroroot-ai/zerocool 0.6.0 → 0.8.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/dispatch.d.ts +63 -5
- package/dist/dispatch.js +145 -10
- package/dist/fix.d.ts +73 -12
- package/dist/fix.js +106 -9
- package/dist/watch.d.ts +47 -33
- package/dist/watch.js +59 -69
- package/package.json +2 -2
package/dist/dispatch.d.ts
CHANGED
|
@@ -46,8 +46,9 @@
|
|
|
46
46
|
* GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
|
|
47
47
|
* GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
|
|
48
48
|
* ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
|
|
49
|
-
* ZEROCOOL_TASK force a task kind ("source-analysis"
|
|
50
|
-
* own context `zerocool.task`
|
|
49
|
+
* ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
|
|
50
|
+
* "fix"); the Task's own context `zerocool.task`
|
|
51
|
+
* is the normal way
|
|
51
52
|
* GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
|
|
52
53
|
* GIBSON_TIMEOUT_MS hard deadline for the run, in ms
|
|
53
54
|
*
|
|
@@ -57,7 +58,8 @@
|
|
|
57
58
|
*/
|
|
58
59
|
import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
59
60
|
import { type FindingsBackend } from "./findings.js";
|
|
60
|
-
import { type
|
|
61
|
+
import { type FindingSource, type FindingStatusWriter, type FixPlanner, type Workspace } from "./fix.js";
|
|
62
|
+
import { type GitLabClient, type GitLabWriter } from "./gitlab.js";
|
|
61
63
|
import { runOpencode } from "./opencode-run.js";
|
|
62
64
|
import type { SemgrepRunner } from "./semgrep.js";
|
|
63
65
|
import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
|
|
@@ -151,7 +153,7 @@ export declare function decodeTask(taskB64: string | undefined): DecodedTask;
|
|
|
151
153
|
*/
|
|
152
154
|
export declare function typedValueString(val: unknown): string | undefined;
|
|
153
155
|
/** The task kinds a sandboxed dispatch can serve. */
|
|
154
|
-
export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
|
|
156
|
+
export type DispatchTaskKind = "opencode" | "source-analysis" | "watch" | "fix";
|
|
155
157
|
/**
|
|
156
158
|
* Which task this dispatch runs. The mission node says so in the Task's
|
|
157
159
|
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
@@ -190,6 +192,29 @@ export interface DispatchDeps {
|
|
|
190
192
|
taskKind?: DispatchTaskKind;
|
|
191
193
|
/** Watch seams: no GitLab, no World and no child missions in a test. */
|
|
192
194
|
watch?: WatchDeps;
|
|
195
|
+
/** Fix seams: no GitLab, no graph and no checkout in a test. */
|
|
196
|
+
fix?: FixDeps;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The fix task's injectable seams.
|
|
200
|
+
*
|
|
201
|
+
* `planner` and `workspace` have no default. The Fix decides what to change
|
|
202
|
+
* and runs the repository's own tests before anything is pushed, and neither
|
|
203
|
+
* is something this process can synthesise: a default planner would be one
|
|
204
|
+
* that changes nothing, and a default workspace would be one whose tests
|
|
205
|
+
* always pass. Both would turn a missing dependency into a Fix that reports
|
|
206
|
+
* success having done nothing, so a dispatch without them fails instead.
|
|
207
|
+
*/
|
|
208
|
+
export interface FixDeps {
|
|
209
|
+
gitlab?: GitLabWriter;
|
|
210
|
+
findings?: FindingSource;
|
|
211
|
+
status?: FindingStatusWriter;
|
|
212
|
+
planner?: FixPlanner;
|
|
213
|
+
workspace?: Workspace;
|
|
214
|
+
/** Resolve the GitLab token. Defaults to the harness `GetCredential`. */
|
|
215
|
+
credential?: (name: string) => Promise<string>;
|
|
216
|
+
/** Cap on merge requests opened by one pass. */
|
|
217
|
+
maxMergeRequests?: number;
|
|
193
218
|
}
|
|
194
219
|
/** The watch task's injectable seams. Everything the loop reaches out through. */
|
|
195
220
|
export interface WatchDeps {
|
|
@@ -237,11 +262,44 @@ export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: D
|
|
|
237
262
|
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
238
263
|
* `gitlab.ref` branch to watch; defaults to `main`
|
|
239
264
|
* `gitlab.credential` tenant secret holding the project access token
|
|
240
|
-
* `repository.url` clone url
|
|
265
|
+
* `repository.url` clone url the Scan mission checks out (required)
|
|
266
|
+
* `image.ref` the image the pipeline published, by digest (required)
|
|
241
267
|
* `target.id` the registered target each Scan mission binds to
|
|
242
268
|
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
243
269
|
*/
|
|
244
270
|
export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
271
|
+
/**
|
|
272
|
+
* The fix task, sandboxed (zerocool-plugins#89, finished in #96).
|
|
273
|
+
*
|
|
274
|
+
* What the always-on agent does after a Scan mission lands: read the
|
|
275
|
+
* Application's Findings over the task grant, work them in priority order,
|
|
276
|
+
* and for each one it can act on rewrite the repository, run the repository's
|
|
277
|
+
* own tests, and only then open a merge request that merges itself on a green
|
|
278
|
+
* pipeline. See `fix.ts` for why the order is tests-then-push and why this
|
|
279
|
+
* never marks a Finding `verified`.
|
|
280
|
+
*
|
|
281
|
+
* THE READ AND THE STATUS WRITE TRAVEL ON THE DISPATCH GRANT, not on a
|
|
282
|
+
* component identity — this process has none. `applicationFindings` and
|
|
283
|
+
* `observe` both reach the daemon as the task, so the run holds exactly the
|
|
284
|
+
* authority its dispatch granted.
|
|
285
|
+
*
|
|
286
|
+
* A REJECTED READ FAILS THE RUN. `harnessFindingSource` does not catch it, and
|
|
287
|
+
* neither does this: an unreachable graph reported as an empty backlog is a
|
|
288
|
+
* Fix that says the Application is clean while the backlog is live, and it
|
|
289
|
+
* reads identically to health.
|
|
290
|
+
*
|
|
291
|
+
* Task context keys it reads:
|
|
292
|
+
* `zerocool.task` selector, "fix"
|
|
293
|
+
* `application` the Application whose Findings are worked (required)
|
|
294
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
295
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
296
|
+
* `gitlab.ref` branch merge requests target; defaults to `main`
|
|
297
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
298
|
+
* `repository.commit` the commit the Scan ran against — what the status lands on
|
|
299
|
+
* `pipeline.url` the pipeline page, linked from the commit status
|
|
300
|
+
* `fix.max_merge_requests` cap on merge requests opened in one pass
|
|
301
|
+
*/
|
|
302
|
+
export declare function runFixDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
245
303
|
/**
|
|
246
304
|
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
247
305
|
* is returned to the caller and never logged: it is the GitLab token, and the
|
package/dist/dispatch.js
CHANGED
|
@@ -46,8 +46,9 @@
|
|
|
46
46
|
* GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
|
|
47
47
|
* GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
|
|
48
48
|
* ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
|
|
49
|
-
* ZEROCOOL_TASK force a task kind ("source-analysis"
|
|
50
|
-
* own context `zerocool.task`
|
|
49
|
+
* ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
|
|
50
|
+
* "fix"); the Task's own context `zerocool.task`
|
|
51
|
+
* is the normal way
|
|
51
52
|
* GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
|
|
52
53
|
* GIBSON_TIMEOUT_MS hard deadline for the run, in ms
|
|
53
54
|
*
|
|
@@ -55,10 +56,11 @@
|
|
|
55
56
|
* {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
|
|
56
57
|
* the GIBSON_CG_JWT this process reads.
|
|
57
58
|
*/
|
|
58
|
-
import { openTaskHarness } from "@zeroroot-ai/sdk";
|
|
59
|
+
import { observe, openTaskHarness, taskKnowledge } from "@zeroroot-ai/sdk";
|
|
59
60
|
import { join } from "node:path";
|
|
60
61
|
import { taskFindingsBackend } from "./findings.js";
|
|
61
|
-
import {
|
|
62
|
+
import { harnessFindingSource, harnessFindingStatus, runFix, } from "./fix.js";
|
|
63
|
+
import { gitlabRest, gitlabRestWriter } from "./gitlab.js";
|
|
62
64
|
import { runOpencode } from "./opencode-run.js";
|
|
63
65
|
import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
|
|
64
66
|
import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
|
|
@@ -183,6 +185,8 @@ export function dispatchTaskKind(ctx, env = {}) {
|
|
|
183
185
|
return "source-analysis";
|
|
184
186
|
if (kind === "watch")
|
|
185
187
|
return "watch";
|
|
188
|
+
if (kind === "fix")
|
|
189
|
+
return "fix";
|
|
186
190
|
return "opencode";
|
|
187
191
|
}
|
|
188
192
|
/**
|
|
@@ -245,6 +249,9 @@ export async function runDispatch(ctx, deps = {}) {
|
|
|
245
249
|
if (kind === "watch") {
|
|
246
250
|
return runWatchDispatch(ctx, deps);
|
|
247
251
|
}
|
|
252
|
+
if (kind === "fix") {
|
|
253
|
+
return runFixDispatch(ctx, deps);
|
|
254
|
+
}
|
|
248
255
|
const run = deps.run ?? runOpencode;
|
|
249
256
|
const result = await run({
|
|
250
257
|
goal: ctx.goal,
|
|
@@ -335,7 +342,8 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
|
|
|
335
342
|
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
336
343
|
* `gitlab.ref` branch to watch; defaults to `main`
|
|
337
344
|
* `gitlab.credential` tenant secret holding the project access token
|
|
338
|
-
* `repository.url` clone url
|
|
345
|
+
* `repository.url` clone url the Scan mission checks out (required)
|
|
346
|
+
* `image.ref` the image the pipeline published, by digest (required)
|
|
339
347
|
* `target.id` the registered target each Scan mission binds to
|
|
340
348
|
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
341
349
|
*/
|
|
@@ -346,6 +354,8 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
346
354
|
const ref = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
|
|
347
355
|
const targetId = ctx.taskContext["target.id"] || "";
|
|
348
356
|
const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
|
|
357
|
+
const repositoryUrl = ctx.taskContext["repository.url"] || "";
|
|
358
|
+
const imageRef = ctx.taskContext["image.ref"] || "";
|
|
349
359
|
if (!application) {
|
|
350
360
|
throw new Error("watch: the mission node set no `application` in the task context. The watch keys its " +
|
|
351
361
|
"checkpoints and its Scan missions by Application; without one it cannot tell its own " +
|
|
@@ -355,6 +365,23 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
355
365
|
throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
356
366
|
"project to poll.");
|
|
357
367
|
}
|
|
368
|
+
// Checked here, not at the first trigger. The checked-in Scan mission requires
|
|
369
|
+
// all seven of its parameters — CUE refuses an incomplete render rather than
|
|
370
|
+
// substituting an empty string — so a watch missing one is already broken. It
|
|
371
|
+
// would otherwise poll cleanly for hours and fail on the first pipeline, at
|
|
372
|
+
// which point the failure looks like the pipeline's fault rather than the
|
|
373
|
+
// watch's configuration.
|
|
374
|
+
if (!w.scans) {
|
|
375
|
+
const missing = [
|
|
376
|
+
...(repositoryUrl ? [] : ["repository.url"]),
|
|
377
|
+
...(imageRef ? [] : ["image.ref"]),
|
|
378
|
+
];
|
|
379
|
+
if (missing.length > 0) {
|
|
380
|
+
throw new Error(`watch: the mission node set no ${missing.join(" and no ")} in the task context. The ` +
|
|
381
|
+
"checked-in Scan mission requires every one of its seven parameters, so a Scan " +
|
|
382
|
+
"originated without these would be refused rather than run.");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
358
385
|
// A harness is opened unless every seam that would use one was injected.
|
|
359
386
|
const needsHarness = !w.gitlab || !w.checkpoints || !w.scans || !w.credential;
|
|
360
387
|
const opened = needsHarness && !deps.harness;
|
|
@@ -373,11 +400,7 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
373
400
|
const scans = w.scans ??
|
|
374
401
|
harnessScanLauncher(harness, {
|
|
375
402
|
targetId,
|
|
376
|
-
inputs: {
|
|
377
|
-
application,
|
|
378
|
-
repositoryUrl: ctx.taskContext["repository.url"] || "",
|
|
379
|
-
...(ctx.taskContext["image.ref"] ? { imageRef: ctx.taskContext["image.ref"] } : {}),
|
|
380
|
-
},
|
|
403
|
+
inputs: { application, repositoryUrl, imageRef },
|
|
381
404
|
});
|
|
382
405
|
const intervalMs = Number(ctx.taskContext["watch.interval_ms"] || Number.NaN);
|
|
383
406
|
const summary = await runWatchLoop({
|
|
@@ -412,6 +435,118 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
412
435
|
harness?.stop();
|
|
413
436
|
}
|
|
414
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* The fix task, sandboxed (zerocool-plugins#89, finished in #96).
|
|
440
|
+
*
|
|
441
|
+
* What the always-on agent does after a Scan mission lands: read the
|
|
442
|
+
* Application's Findings over the task grant, work them in priority order,
|
|
443
|
+
* and for each one it can act on rewrite the repository, run the repository's
|
|
444
|
+
* own tests, and only then open a merge request that merges itself on a green
|
|
445
|
+
* pipeline. See `fix.ts` for why the order is tests-then-push and why this
|
|
446
|
+
* never marks a Finding `verified`.
|
|
447
|
+
*
|
|
448
|
+
* THE READ AND THE STATUS WRITE TRAVEL ON THE DISPATCH GRANT, not on a
|
|
449
|
+
* component identity — this process has none. `applicationFindings` and
|
|
450
|
+
* `observe` both reach the daemon as the task, so the run holds exactly the
|
|
451
|
+
* authority its dispatch granted.
|
|
452
|
+
*
|
|
453
|
+
* A REJECTED READ FAILS THE RUN. `harnessFindingSource` does not catch it, and
|
|
454
|
+
* neither does this: an unreachable graph reported as an empty backlog is a
|
|
455
|
+
* Fix that says the Application is clean while the backlog is live, and it
|
|
456
|
+
* reads identically to health.
|
|
457
|
+
*
|
|
458
|
+
* Task context keys it reads:
|
|
459
|
+
* `zerocool.task` selector, "fix"
|
|
460
|
+
* `application` the Application whose Findings are worked (required)
|
|
461
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
462
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
463
|
+
* `gitlab.ref` branch merge requests target; defaults to `main`
|
|
464
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
465
|
+
* `repository.commit` the commit the Scan ran against — what the status lands on
|
|
466
|
+
* `pipeline.url` the pipeline page, linked from the commit status
|
|
467
|
+
* `fix.max_merge_requests` cap on merge requests opened in one pass
|
|
468
|
+
*/
|
|
469
|
+
export async function runFixDispatch(ctx, deps = {}) {
|
|
470
|
+
const f = deps.fix ?? {};
|
|
471
|
+
const application = ctx.taskContext.application || "";
|
|
472
|
+
const projectPath = ctx.taskContext["gitlab.project"] || "";
|
|
473
|
+
const targetRef = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
|
|
474
|
+
const commit = ctx.taskContext["repository.commit"] || "";
|
|
475
|
+
const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
|
|
476
|
+
if (!application) {
|
|
477
|
+
throw new Error("fix: the mission node set no `application` in the task context. The Fix reads one " +
|
|
478
|
+
"Application's Findings and writes back to them; without one it cannot tell which " +
|
|
479
|
+
"backlog it is working.");
|
|
480
|
+
}
|
|
481
|
+
if (!f.gitlab && !projectPath) {
|
|
482
|
+
throw new Error("fix: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
483
|
+
"project to open a merge request against.");
|
|
484
|
+
}
|
|
485
|
+
// No default planner and no default workspace: see FixDeps. A Fix that
|
|
486
|
+
// cannot change anything or cannot test what it changed must not report a
|
|
487
|
+
// clean pass over a backlog it never touched.
|
|
488
|
+
if (!f.planner) {
|
|
489
|
+
throw new Error("fix: no planner was supplied, so this dispatch could only report every finding unfixed");
|
|
490
|
+
}
|
|
491
|
+
if (!f.workspace) {
|
|
492
|
+
throw new Error("fix: no workspace was supplied, so no change could be made or tested");
|
|
493
|
+
}
|
|
494
|
+
const needsHarness = !f.gitlab || !f.findings || !f.status || !f.credential;
|
|
495
|
+
const opened = needsHarness && !deps.harness;
|
|
496
|
+
const harness = needsHarness
|
|
497
|
+
? (deps.harness ??
|
|
498
|
+
((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
|
|
499
|
+
: undefined;
|
|
500
|
+
try {
|
|
501
|
+
const gitlab = f.gitlab ??
|
|
502
|
+
gitlabRestWriter({
|
|
503
|
+
projectPath,
|
|
504
|
+
token: await (f.credential ?? harnessCredential(harness))(credentialName),
|
|
505
|
+
...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
|
|
506
|
+
});
|
|
507
|
+
const findings = f.findings ?? harnessFindingSource(taskKnowledge(harness));
|
|
508
|
+
const status = f.status ??
|
|
509
|
+
harnessFindingStatus((entity) => observe(harness, { lifecycleEntity: entity }));
|
|
510
|
+
const cap = Number(ctx.taskContext["fix.max_merge_requests"] || Number.NaN);
|
|
511
|
+
const summary = await runFix({
|
|
512
|
+
application,
|
|
513
|
+
commit,
|
|
514
|
+
targetRef,
|
|
515
|
+
findings,
|
|
516
|
+
status,
|
|
517
|
+
planner: f.planner,
|
|
518
|
+
workspace: f.workspace,
|
|
519
|
+
gitlab,
|
|
520
|
+
...(ctx.taskContext["pipeline.url"] ? { pipelineUrl: ctx.taskContext["pipeline.url"] } : {}),
|
|
521
|
+
...(f.maxMergeRequests !== undefined
|
|
522
|
+
? { maxMergeRequests: f.maxMergeRequests }
|
|
523
|
+
: Number.isFinite(cap) && cap > 0
|
|
524
|
+
? { maxMergeRequests: cap }
|
|
525
|
+
: {}),
|
|
526
|
+
...(deps.onEvent ? { onEvent: (e) => deps.onEvent?.(JSON.stringify(e)) } : {}),
|
|
527
|
+
});
|
|
528
|
+
const fixing = summary.outcomes.filter((o) => o.result === "fixing").length;
|
|
529
|
+
const merged = summary.outcomes.filter((o) => o.result === "merged").length;
|
|
530
|
+
const unfixed = summary.outcomes.filter((o) => o.result === "unfixed").length;
|
|
531
|
+
return {
|
|
532
|
+
output: `fix ${application}@${commit.slice(0, 12) || "unknown"}: ${fixing} merge request(s) opened, ` +
|
|
533
|
+
`${merged} merged, ${unfixed} left for a human`,
|
|
534
|
+
success: true,
|
|
535
|
+
metadata: {
|
|
536
|
+
task: "fix",
|
|
537
|
+
application,
|
|
538
|
+
commit,
|
|
539
|
+
fixing: String(fixing),
|
|
540
|
+
merged: String(merged),
|
|
541
|
+
unfixed: String(unfixed),
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
if (opened)
|
|
547
|
+
harness?.stop();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
415
550
|
/**
|
|
416
551
|
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
417
552
|
* is returned to the caller and never logged: it is the GitLab token, and the
|
package/dist/fix.d.ts
CHANGED
|
@@ -26,15 +26,14 @@ import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
|
|
|
26
26
|
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
27
27
|
* recorded output if it ever did.
|
|
28
28
|
*
|
|
29
|
-
* TWO SEAMS ARE
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* implementation to add and nothing here changes.
|
|
29
|
+
* THE TWO PLATFORM SEAMS ARE LIVE. Reading an Application's Findings and
|
|
30
|
+
* writing a Finding's status arrived on the TypeScript wire in
|
|
31
|
+
* `@zeroroot-ai/sdk@0.10.0` (sdk-ts#51), so {@link harnessFindingSource} and
|
|
32
|
+
* {@link harnessFindingStatus} below back {@link FindingSource} and
|
|
33
|
+
* {@link FindingStatusWriter} on the per-dispatch task grant. They stayed
|
|
34
|
+
* seams rather than becoming direct calls, which is what let this file be
|
|
35
|
+
* driven by stubs before the wire existed and still be driven by them in
|
|
36
|
+
* tests now that it does.
|
|
38
37
|
*/
|
|
39
38
|
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
40
39
|
export declare const SEVERITIES: readonly ["critical", "high", "medium", "low", "info"];
|
|
@@ -50,12 +49,15 @@ export interface FixableFinding {
|
|
|
50
49
|
/** Where it is: a `Package`, a `Repository` file, or a `Service`. */
|
|
51
50
|
placeLabel: string;
|
|
52
51
|
placeKey: string;
|
|
53
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* `P1`..`P4`, what a triage pass decided (gibson#1684). **Absent means no
|
|
54
|
+
* pass has decided yet, never "unimportant"** — see {@link byWorkOrder}.
|
|
55
|
+
*/
|
|
54
56
|
priority?: string;
|
|
55
57
|
/** The merge request already opened for this Finding, when it is `fixing`. */
|
|
56
58
|
mergeRequestIid?: number;
|
|
57
59
|
}
|
|
58
|
-
/** Reads an Application's Findings.
|
|
60
|
+
/** Reads an Application's Findings. Backed by {@link harnessFindingSource}. */
|
|
59
61
|
export interface FindingSource {
|
|
60
62
|
/**
|
|
61
63
|
* The Application's Findings in the given statuses, **in the order they
|
|
@@ -65,7 +67,7 @@ export interface FindingSource {
|
|
|
65
67
|
*/
|
|
66
68
|
findings(application: string, statuses: string[]): Promise<FixableFinding[]>;
|
|
67
69
|
}
|
|
68
|
-
/** Moves a Finding through its statuses.
|
|
70
|
+
/** Moves a Finding through its statuses. Backed by {@link harnessFindingStatus}. */
|
|
69
71
|
export interface FindingStatusWriter {
|
|
70
72
|
/** Record that a merge request now proposes this Finding's fix. */
|
|
71
73
|
markFixing(findingId: string, mr: MergeRequest): Promise<void>;
|
|
@@ -205,3 +207,62 @@ export declare function statusDescription(summary: FixSummary): string;
|
|
|
205
207
|
export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
|
|
206
208
|
/** The note posted on each merge request: what this pass did, in full. */
|
|
207
209
|
export declare function noteBody(summary: FixSummary): string;
|
|
210
|
+
/**
|
|
211
|
+
* The order the Fix works Findings in: priority first, severity beneath it,
|
|
212
|
+
* then the Finding id so a pass over unchanged input is deterministic.
|
|
213
|
+
*
|
|
214
|
+
* Deterministic order matters beyond tidiness: the merge-request cap means the
|
|
215
|
+
* tail of this list is what gets deferred to the next pass, so an unstable
|
|
216
|
+
* sort would defer a different Finding each run and none would ever be worked.
|
|
217
|
+
*/
|
|
218
|
+
export declare function byWorkOrder(a: FixableFinding, b: FixableFinding): number;
|
|
219
|
+
/**
|
|
220
|
+
* Read an Application's Findings over the task grant (sdk-ts#51).
|
|
221
|
+
*
|
|
222
|
+
* The rejection is deliberately NOT caught. `applicationFindings` throws when
|
|
223
|
+
* the graph is unreachable, and swallowing that into an empty list is the one
|
|
224
|
+
* failure this whole seam exists to prevent: the Fix would report a clean
|
|
225
|
+
* Application over a live backlog, and it would look exactly like health.
|
|
226
|
+
*/
|
|
227
|
+
export declare function harnessFindingSource(knowledge: KnowledgeReadsFindings): FindingSource;
|
|
228
|
+
/** The single read the Fix needs from a knowledge source. */
|
|
229
|
+
export interface KnowledgeReadsFindings {
|
|
230
|
+
applicationFindings(opts: {
|
|
231
|
+
application: string;
|
|
232
|
+
statuses?: string[];
|
|
233
|
+
limit?: number;
|
|
234
|
+
}): Promise<ReadFinding[]>;
|
|
235
|
+
}
|
|
236
|
+
/** The fields of an `ApplicationFinding` the Fix reads. */
|
|
237
|
+
export interface ReadFinding {
|
|
238
|
+
findingId: string;
|
|
239
|
+
status: string;
|
|
240
|
+
severity: string;
|
|
241
|
+
vulnerabilityId: string;
|
|
242
|
+
placeLabel: string;
|
|
243
|
+
placeKey: string;
|
|
244
|
+
priority: string;
|
|
245
|
+
}
|
|
246
|
+
/** Writes one lifecycle sighting. Satisfied by the SDK's `observe`. */
|
|
247
|
+
export type ObserveLifecycle = (entity: {
|
|
248
|
+
label: string;
|
|
249
|
+
idProperties: Record<string, string>;
|
|
250
|
+
properties?: Record<string, string>;
|
|
251
|
+
}) => Promise<void>;
|
|
252
|
+
/**
|
|
253
|
+
* Move a Finding through its statuses over the task grant (sdk-ts#51).
|
|
254
|
+
*
|
|
255
|
+
* The Finding is named by its `brain_id` in `idProperties`, so the write lands
|
|
256
|
+
* on the node the scan raised rather than creating a second one beside it.
|
|
257
|
+
*
|
|
258
|
+
* Only the properties this transition decides are sent. A sighting that omits
|
|
259
|
+
* a property leaves whatever an earlier one established; one that sets it to
|
|
260
|
+
* `""` erases it. The Fix decides a status and, when it has one, the merge
|
|
261
|
+
* request — it decides nothing about severity, priority or reason, so it sends
|
|
262
|
+
* none of them and cannot blank what the scan or a triage pass recorded.
|
|
263
|
+
*
|
|
264
|
+
* There is no `markVerified`, and there is no branch here that could reach it.
|
|
265
|
+
* `verified` follows a rescan not seeing the weakness again, and absence is not
|
|
266
|
+
* an observation this process can make (gibson#1686).
|
|
267
|
+
*/
|
|
268
|
+
export declare function harnessFindingStatus(observe: ObserveLifecycle): FindingStatusWriter;
|
package/dist/fix.js
CHANGED
|
@@ -25,15 +25,14 @@
|
|
|
25
25
|
* `fix.test.ts` proves that with a sentinel that would show up in every
|
|
26
26
|
* recorded output if it ever did.
|
|
27
27
|
*
|
|
28
|
-
* TWO SEAMS ARE
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* implementation to add and nothing here changes.
|
|
28
|
+
* THE TWO PLATFORM SEAMS ARE LIVE. Reading an Application's Findings and
|
|
29
|
+
* writing a Finding's status arrived on the TypeScript wire in
|
|
30
|
+
* `@zeroroot-ai/sdk@0.10.0` (sdk-ts#51), so {@link harnessFindingSource} and
|
|
31
|
+
* {@link harnessFindingStatus} below back {@link FindingSource} and
|
|
32
|
+
* {@link FindingStatusWriter} on the per-dispatch task grant. They stayed
|
|
33
|
+
* seams rather than becoming direct calls, which is what let this file be
|
|
34
|
+
* driven by stubs before the wire existed and still be driven by them in
|
|
35
|
+
* tests now that it does.
|
|
37
36
|
*/
|
|
38
37
|
/** Severities, worst first. Used for the note's counts and for a stable order. */
|
|
39
38
|
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
@@ -253,3 +252,101 @@ function firstLines(output, n = 3) {
|
|
|
253
252
|
function message(e) {
|
|
254
253
|
return e instanceof Error ? e.message : String(e);
|
|
255
254
|
}
|
|
255
|
+
// --------------------------------------------------------------------------
|
|
256
|
+
// the platform seams, backed by the task grant (zerocool-plugins#96)
|
|
257
|
+
// --------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Priority order, worst first. `P1` is the most urgent thing a triage pass
|
|
260
|
+
* found; anything it has not ranked sorts AFTER everything it has.
|
|
261
|
+
*
|
|
262
|
+
* An unranked Finding is not a low-priority one — it is one no pass has
|
|
263
|
+
* decided about yet (gibson#1684). Ranking it as `P4` would be inventing a
|
|
264
|
+
* decision nobody took, and the triage agent's keep-previous rule would then
|
|
265
|
+
* preserve that invention on the next pass. So unranked sorts last among
|
|
266
|
+
* peers rather than being scored, and severity breaks the tie beneath it, so
|
|
267
|
+
* a critical unranked Finding still outranks a low unranked one.
|
|
268
|
+
*/
|
|
269
|
+
const PRIORITY_ORDER = ["p1", "p2", "p3", "p4"];
|
|
270
|
+
function priorityRank(priority) {
|
|
271
|
+
const i = PRIORITY_ORDER.indexOf((priority ?? "").toLowerCase());
|
|
272
|
+
return i === -1 ? PRIORITY_ORDER.length : i;
|
|
273
|
+
}
|
|
274
|
+
function severityRank(severity) {
|
|
275
|
+
const i = SEVERITIES.indexOf(severity.toLowerCase());
|
|
276
|
+
return i === -1 ? SEVERITIES.length : i;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The order the Fix works Findings in: priority first, severity beneath it,
|
|
280
|
+
* then the Finding id so a pass over unchanged input is deterministic.
|
|
281
|
+
*
|
|
282
|
+
* Deterministic order matters beyond tidiness: the merge-request cap means the
|
|
283
|
+
* tail of this list is what gets deferred to the next pass, so an unstable
|
|
284
|
+
* sort would defer a different Finding each run and none would ever be worked.
|
|
285
|
+
*/
|
|
286
|
+
export function byWorkOrder(a, b) {
|
|
287
|
+
const p = priorityRank(a.priority) - priorityRank(b.priority);
|
|
288
|
+
if (p !== 0)
|
|
289
|
+
return p;
|
|
290
|
+
const s = severityRank(a.severity) - severityRank(b.severity);
|
|
291
|
+
if (s !== 0)
|
|
292
|
+
return s;
|
|
293
|
+
return a.id.localeCompare(b.id);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Read an Application's Findings over the task grant (sdk-ts#51).
|
|
297
|
+
*
|
|
298
|
+
* The rejection is deliberately NOT caught. `applicationFindings` throws when
|
|
299
|
+
* the graph is unreachable, and swallowing that into an empty list is the one
|
|
300
|
+
* failure this whole seam exists to prevent: the Fix would report a clean
|
|
301
|
+
* Application over a live backlog, and it would look exactly like health.
|
|
302
|
+
*/
|
|
303
|
+
export function harnessFindingSource(knowledge) {
|
|
304
|
+
return {
|
|
305
|
+
async findings(application, statuses) {
|
|
306
|
+
const found = await knowledge.applicationFindings({ application, statuses });
|
|
307
|
+
return found
|
|
308
|
+
.map((f) => ({
|
|
309
|
+
id: f.findingId,
|
|
310
|
+
status: f.status,
|
|
311
|
+
severity: f.severity,
|
|
312
|
+
vulnerabilityId: f.vulnerabilityId,
|
|
313
|
+
placeLabel: f.placeLabel,
|
|
314
|
+
placeKey: f.placeKey,
|
|
315
|
+
// Omitted rather than defaulted: empty means no pass has decided.
|
|
316
|
+
...(f.priority ? { priority: f.priority } : {}),
|
|
317
|
+
}))
|
|
318
|
+
.sort(byWorkOrder);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Move a Finding through its statuses over the task grant (sdk-ts#51).
|
|
324
|
+
*
|
|
325
|
+
* The Finding is named by its `brain_id` in `idProperties`, so the write lands
|
|
326
|
+
* on the node the scan raised rather than creating a second one beside it.
|
|
327
|
+
*
|
|
328
|
+
* Only the properties this transition decides are sent. A sighting that omits
|
|
329
|
+
* a property leaves whatever an earlier one established; one that sets it to
|
|
330
|
+
* `""` erases it. The Fix decides a status and, when it has one, the merge
|
|
331
|
+
* request — it decides nothing about severity, priority or reason, so it sends
|
|
332
|
+
* none of them and cannot blank what the scan or a triage pass recorded.
|
|
333
|
+
*
|
|
334
|
+
* There is no `markVerified`, and there is no branch here that could reach it.
|
|
335
|
+
* `verified` follows a rescan not seeing the weakness again, and absence is not
|
|
336
|
+
* an observation this process can make (gibson#1686).
|
|
337
|
+
*/
|
|
338
|
+
export function harnessFindingStatus(observe) {
|
|
339
|
+
const write = (findingId, properties) => observe({ label: "Finding", idProperties: { brain_id: findingId }, properties });
|
|
340
|
+
return {
|
|
341
|
+
async markFixing(findingId, mr) {
|
|
342
|
+
await write(findingId, {
|
|
343
|
+
status: "fixing",
|
|
344
|
+
merge_request_iid: String(mr.iid),
|
|
345
|
+
merge_request_url: mr.webUrl,
|
|
346
|
+
});
|
|
347
|
+
},
|
|
348
|
+
async markFixed(findingId) {
|
|
349
|
+
await write(findingId, { status: "fixed" });
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
package/dist/watch.d.ts
CHANGED
|
@@ -156,48 +156,40 @@ export declare function parseCheckpoint(text: string): {
|
|
|
156
156
|
export declare function worldCheckpoints(harness: TaskHarness, application: string): WatchCheckpoints;
|
|
157
157
|
/** The recall text for an application's checkpoints. Exported so a test names it once. */
|
|
158
158
|
export declare function checkpointQueryText(application: string): string;
|
|
159
|
+
/**
|
|
160
|
+
* The name of the checked-in Scan mission in gibson's mission catalog.
|
|
161
|
+
*
|
|
162
|
+
* One file in `internal/platform/missioncatalog/missions/` is one mission,
|
|
163
|
+
* named for its file: `scan.cue` is `"scan"`.
|
|
164
|
+
*/
|
|
165
|
+
export declare const SCAN_CATALOG_MISSION = "scan";
|
|
159
166
|
/** What a Scan mission needs to know about the pipeline that triggered it. */
|
|
160
167
|
export interface ScanMissionInputs {
|
|
161
168
|
application: string;
|
|
162
169
|
pipeline: Pipeline;
|
|
163
170
|
/** The project's clone url, so the Scan mission knows what to check out. */
|
|
164
171
|
repositoryUrl: string;
|
|
165
|
-
/** The image the pipeline
|
|
166
|
-
imageRef
|
|
167
|
-
/** The registered target the runtime scans are allowed to reach. */
|
|
168
|
-
targetId?: string;
|
|
172
|
+
/** The image the pipeline published, by digest. */
|
|
173
|
+
imageRef: string;
|
|
169
174
|
}
|
|
170
175
|
/**
|
|
171
|
-
* The Scan mission
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* in the node's task context, and the swap is this function's body alone.
|
|
185
|
-
*
|
|
186
|
-
* The definition is built as protojson by hand rather than through the
|
|
187
|
-
* generated schema because the SDK's package exports only its index, and the
|
|
188
|
-
* mission schema is not re-exported from it.
|
|
176
|
+
* The seven parameters the checked-in Scan mission is rendered with.
|
|
177
|
+
*
|
|
178
|
+
* These names are the catalog's, not ours: `missioncatalog.Params.fields()` is
|
|
179
|
+
* the one declaration of them, and the daemon REFUSES an unrecognised key
|
|
180
|
+
* rather than ignoring it. That refusal is the smuggling defence — `Params` has
|
|
181
|
+
* no target or host field, so the runtime target binds from the mission's
|
|
182
|
+
* `target_id` alone and a caller cannot point a scan at a host the tenant never
|
|
183
|
+
* registered. A key renamed here without renaming it there fails loudly at the
|
|
184
|
+
* next launch, which is the intended outcome.
|
|
185
|
+
*
|
|
186
|
+
* Every one is required. CUE refuses an incomplete render rather than
|
|
187
|
+
* substituting an empty string, so a missing commit fails instead of scanning
|
|
188
|
+
* HEAD and a missing image fails instead of scanning nothing.
|
|
189
189
|
*/
|
|
190
|
-
export declare function
|
|
190
|
+
export declare function scanCatalogParams(inputs: ScanMissionInputs): Record<string, string>;
|
|
191
191
|
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
192
192
|
export declare function scanMissionName(inputs: ScanMissionInputs): string;
|
|
193
|
-
/**
|
|
194
|
-
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
195
|
-
* protojson. Every value the loop passes is a string, so every entry is a
|
|
196
|
-
* `stringValue`.
|
|
197
|
-
*/
|
|
198
|
-
export declare function typedContext(ctx: Record<string, string>): Record<string, {
|
|
199
|
-
stringValue: string;
|
|
200
|
-
}>;
|
|
201
193
|
export interface HarnessScanLauncherOptions {
|
|
202
194
|
/** Everything but the pipeline, which the launcher fills in per trigger. */
|
|
203
195
|
inputs: Omit<ScanMissionInputs, "pipeline">;
|
|
@@ -205,8 +197,6 @@ export interface HarnessScanLauncherOptions {
|
|
|
205
197
|
targetId: string;
|
|
206
198
|
/** Ceiling on one Scan. Defaults to {@link DEFAULT_SCAN_TIMEOUT_MS}. */
|
|
207
199
|
scanTimeoutMs?: number;
|
|
208
|
-
/** Override the definition, so gibson#1660 can supply the real one. */
|
|
209
|
-
definition?: (inputs: ScanMissionInputs) => unknown;
|
|
210
200
|
}
|
|
211
201
|
/**
|
|
212
202
|
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
@@ -215,5 +205,29 @@ export interface HarnessScanLauncherOptions {
|
|
|
215
205
|
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
216
206
|
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
217
207
|
* parent, a tenant or an originator, so nothing here can widen them.
|
|
208
|
+
*
|
|
209
|
+
* THE MISSION IS NAMED, NOT BUILT (ADR-0018, gibson#1688). The Scan mission is
|
|
210
|
+
* checked into gibson's `missioncatalog` and that copy is authoritative; this
|
|
211
|
+
* launcher sends its name and seven parameters. It used to build its own graph,
|
|
212
|
+
* because `CreateMission` had no way to ask for the catalog's copy — two
|
|
213
|
+
* descriptions of one mission, which is the parallel definition ADR-0027
|
|
214
|
+
* forbids. There is no fallback to the old path: a definition that only one of
|
|
215
|
+
* the two knew about is the drift the single copy exists to prevent.
|
|
216
|
+
*
|
|
217
|
+
* The request goes through the SDK's `buildCreateMissionRequest` rather than
|
|
218
|
+
* being assembled here, because that builder normalises the one trap in this
|
|
219
|
+
* call. `JSON.stringify(undefined)` encodes to zero bytes but
|
|
220
|
+
* `JSON.stringify(null)` encodes to the four-byte string `"null"`, so a graph
|
|
221
|
+
* field filled in unconditionally puts a non-empty body on the wire beside the
|
|
222
|
+
* catalog name; the daemon refuses the pair, and the failure reads as "the
|
|
223
|
+
* catalog path does not work" rather than "you passed a null definition".
|
|
224
|
+
*
|
|
225
|
+
* The response is then sent by hand rather than through `createTaskMission`,
|
|
226
|
+
* for one reason: `CreateMissionResponse` carries an in-band `error`, and the
|
|
227
|
+
* daemon puts its most useful refusals there — an unknown parameter key naming
|
|
228
|
+
* the key and listing the known set, or every missing parameter reported at
|
|
229
|
+
* once. `createTaskMission` reads only `mission`, so those would surface as
|
|
230
|
+
* "returned no mission" and the operator would lose the sentence that says what
|
|
231
|
+
* to fix.
|
|
218
232
|
*/
|
|
219
233
|
export declare function harnessScanLauncher(harness: TaskHarness, opts: HarnessScanLauncherOptions): ScanLauncher;
|
package/dist/watch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { remember, taskKnowledge } from "@zeroroot-ai/sdk";
|
|
1
|
+
import { buildCreateMissionRequest, remember, taskKnowledge } from "@zeroroot-ai/sdk";
|
|
2
2
|
import { TRIGGER_STATUS } from "./gitlab.js";
|
|
3
3
|
/**
|
|
4
4
|
* The always-on watch loop (zerocool-plugins#88).
|
|
@@ -222,81 +222,47 @@ export function worldCheckpoints(harness, application) {
|
|
|
222
222
|
export function checkpointQueryText(application) {
|
|
223
223
|
return `${CHECKPOINT_KIND} application=${application}`;
|
|
224
224
|
}
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// The Scan mission
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
225
228
|
/**
|
|
226
|
-
* The Scan mission
|
|
229
|
+
* The name of the checked-in Scan mission in gibson's mission catalog.
|
|
227
230
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
231
|
+
* One file in `internal/platform/missioncatalog/missions/` is one mission,
|
|
232
|
+
* named for its file: `scan.cue` is `"scan"`.
|
|
233
|
+
*/
|
|
234
|
+
export const SCAN_CATALOG_MISSION = "scan";
|
|
235
|
+
/**
|
|
236
|
+
* The seven parameters the checked-in Scan mission is rendered with.
|
|
232
237
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* no
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
238
|
+
* These names are the catalog's, not ours: `missioncatalog.Params.fields()` is
|
|
239
|
+
* the one declaration of them, and the daemon REFUSES an unrecognised key
|
|
240
|
+
* rather than ignoring it. That refusal is the smuggling defence — `Params` has
|
|
241
|
+
* no target or host field, so the runtime target binds from the mission's
|
|
242
|
+
* `target_id` alone and a caller cannot point a scan at a host the tenant never
|
|
243
|
+
* registered. A key renamed here without renaming it there fails loudly at the
|
|
244
|
+
* next launch, which is the intended outcome.
|
|
240
245
|
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
246
|
+
* Every one is required. CUE refuses an incomplete render rather than
|
|
247
|
+
* substituting an empty string, so a missing commit fails instead of scanning
|
|
248
|
+
* HEAD and a missing image fails instead of scanning nothing.
|
|
244
249
|
*/
|
|
245
|
-
export function
|
|
246
|
-
const {
|
|
247
|
-
const context = {
|
|
248
|
-
"zerocool.task": "source-analysis",
|
|
249
|
-
application,
|
|
250
|
-
"pipeline.id": String(pipeline.id),
|
|
251
|
-
"pipeline.url": pipeline.webUrl,
|
|
252
|
-
"repository.url": inputs.repositoryUrl,
|
|
253
|
-
"repository.ref": pipeline.ref,
|
|
254
|
-
"repository.commit": pipeline.sha,
|
|
255
|
-
...(inputs.imageRef ? { "image.ref": inputs.imageRef } : {}),
|
|
256
|
-
...(inputs.targetId ? { "target.id": inputs.targetId } : {}),
|
|
257
|
-
};
|
|
250
|
+
export function scanCatalogParams(inputs) {
|
|
251
|
+
const { pipeline } = inputs;
|
|
258
252
|
return {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
type: "NODE_TYPE_AGENT",
|
|
267
|
-
name: "scan",
|
|
268
|
-
description: "Source analysis of the commit the pipeline built.",
|
|
269
|
-
agentConfig: {
|
|
270
|
-
agentName: "zerocool",
|
|
271
|
-
task: {
|
|
272
|
-
id: "scan",
|
|
273
|
-
goal: `Scan the ${application} repository at commit ${pipeline.sha} and submit a Finding for ` +
|
|
274
|
-
"every real weakness you can support with evidence.",
|
|
275
|
-
context: typedContext(context),
|
|
276
|
-
},
|
|
277
|
-
},
|
|
278
|
-
},
|
|
279
|
-
},
|
|
280
|
-
entryPoints: ["scan"],
|
|
253
|
+
application: inputs.application,
|
|
254
|
+
repositoryUrl: inputs.repositoryUrl,
|
|
255
|
+
ref: pipeline.ref,
|
|
256
|
+
commit: pipeline.sha,
|
|
257
|
+
pipelineId: String(pipeline.id),
|
|
258
|
+
pipelineUrl: pipeline.webUrl,
|
|
259
|
+
imageRef: inputs.imageRef,
|
|
281
260
|
};
|
|
282
261
|
}
|
|
283
262
|
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
284
263
|
export function scanMissionName(inputs) {
|
|
285
264
|
return `scan ${inputs.application} pipeline ${inputs.pipeline.id}`;
|
|
286
265
|
}
|
|
287
|
-
/**
|
|
288
|
-
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
289
|
-
* protojson. Every value the loop passes is a string, so every entry is a
|
|
290
|
-
* `stringValue`.
|
|
291
|
-
*/
|
|
292
|
-
export function typedContext(ctx) {
|
|
293
|
-
const out = {};
|
|
294
|
-
for (const [k, v] of Object.entries(ctx)) {
|
|
295
|
-
if (v !== "")
|
|
296
|
-
out[k] = { stringValue: v };
|
|
297
|
-
}
|
|
298
|
-
return out;
|
|
299
|
-
}
|
|
300
266
|
/**
|
|
301
267
|
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
302
268
|
*
|
|
@@ -304,19 +270,43 @@ export function typedContext(ctx) {
|
|
|
304
270
|
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
305
271
|
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
306
272
|
* parent, a tenant or an originator, so nothing here can widen them.
|
|
273
|
+
*
|
|
274
|
+
* THE MISSION IS NAMED, NOT BUILT (ADR-0018, gibson#1688). The Scan mission is
|
|
275
|
+
* checked into gibson's `missioncatalog` and that copy is authoritative; this
|
|
276
|
+
* launcher sends its name and seven parameters. It used to build its own graph,
|
|
277
|
+
* because `CreateMission` had no way to ask for the catalog's copy — two
|
|
278
|
+
* descriptions of one mission, which is the parallel definition ADR-0027
|
|
279
|
+
* forbids. There is no fallback to the old path: a definition that only one of
|
|
280
|
+
* the two knew about is the drift the single copy exists to prevent.
|
|
281
|
+
*
|
|
282
|
+
* The request goes through the SDK's `buildCreateMissionRequest` rather than
|
|
283
|
+
* being assembled here, because that builder normalises the one trap in this
|
|
284
|
+
* call. `JSON.stringify(undefined)` encodes to zero bytes but
|
|
285
|
+
* `JSON.stringify(null)` encodes to the four-byte string `"null"`, so a graph
|
|
286
|
+
* field filled in unconditionally puts a non-empty body on the wire beside the
|
|
287
|
+
* catalog name; the daemon refuses the pair, and the failure reads as "the
|
|
288
|
+
* catalog path does not work" rather than "you passed a null definition".
|
|
289
|
+
*
|
|
290
|
+
* The response is then sent by hand rather than through `createTaskMission`,
|
|
291
|
+
* for one reason: `CreateMissionResponse` carries an in-band `error`, and the
|
|
292
|
+
* daemon puts its most useful refusals there — an unknown parameter key naming
|
|
293
|
+
* the key and listing the known set, or every missing parameter reported at
|
|
294
|
+
* once. `createTaskMission` reads only `mission`, so those would surface as
|
|
295
|
+
* "returned no mission" and the operator would lose the sentence that says what
|
|
296
|
+
* to fix.
|
|
307
297
|
*/
|
|
308
298
|
export function harnessScanLauncher(harness, opts) {
|
|
309
|
-
const build = opts.definition ?? scanMissionDefinition;
|
|
310
299
|
const timeoutMs = BigInt(opts.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS);
|
|
311
300
|
return {
|
|
312
301
|
async launch(pipeline) {
|
|
313
|
-
const inputs = { ...opts.inputs, pipeline
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
302
|
+
const inputs = { ...opts.inputs, pipeline };
|
|
303
|
+
const wire = buildCreateMissionRequest({
|
|
304
|
+
catalogMission: SCAN_CATALOG_MISSION,
|
|
305
|
+
catalogParams: scanCatalogParams(inputs),
|
|
317
306
|
targetId: opts.targetId,
|
|
318
307
|
name: scanMissionName(inputs),
|
|
319
308
|
});
|
|
309
|
+
const created = await harness.client.createMission({ context: harness.context, ...wire });
|
|
320
310
|
if (created.error) {
|
|
321
311
|
throw new Error(`CreateMission refused: ${created.error.message}`);
|
|
322
312
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroroot-ai/zerocool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@zeroroot-ai/sdk": "^0.
|
|
26
|
+
"@zeroroot-ai/sdk": "^0.11.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@opencode-ai/plugin": "*"
|