@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.
@@ -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"); the Task's
50
- * own context `zerocool.task` is the normal way
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 GitLabClient } from "./gitlab.js";
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 handed to each Scan mission
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"); the Task's
50
- * own context `zerocool.task` is the normal way
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 { gitlabRest } from "./gitlab.js";
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 handed to each Scan mission
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 DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
30
- * Findings and writing a Finding's status both exist on the Go SDK as of
31
- * v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
32
- * carries no `applicationFindings` and its `observe` admits only host, domain,
33
- * subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
34
- * {@link FindingSource} and {@link FindingStatusWriter} have no platform
35
- * implementation, and the Fix is driven by whatever supplies them. This is a
36
- * seam, not a second code path: when the wire arrives there is one
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
- /** `P1`..`P4` once the read carries what a triage pass decided (gibson#1684). */
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. Platform implementation pending sdk-ts#51. */
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. Platform implementation pending sdk-ts#51. */
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 DELIBERATELY UNIMPLEMENTED HERE. Reading an Application's
29
- * Findings and writing a Finding's status both exist on the Go SDK as of
30
- * v0.174.0, and on neither the TypeScript SDK nor this package: `@zeroroot-ai/sdk`
31
- * carries no `applicationFindings` and its `observe` admits only host, domain,
32
- * subdomain, credential, account and memory. That is sdk-ts#51. Until it lands,
33
- * {@link FindingSource} and {@link FindingStatusWriter} have no platform
34
- * implementation, and the Fix is driven by whatever supplies them. This is a
35
- * seam, not a second code path: when the wire arrives there is one
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 built, when the watch was told how to name it. */
166
- imageRef?: string;
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 definition, as canonical proto JSON.
172
- *
173
- * STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
174
- * as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
175
- * that copy authoritative: this function is meant to become a mission name plus
176
- * seven parameters, not a second description of the same graph.
177
- *
178
- * It cannot yet. `missioncatalog.Render` has no production caller, and
179
- * `CreateMission` carries only `mission_definition_json` — there is no field
180
- * naming a checked-in mission and none carrying its parameters, so an agent has
181
- * no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
182
- * only way to originate a Scan is to send a graph, so this one stays, with the
183
- * shape the catalog definition expects: everything the fan-out needs is carried
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 scanMissionDefinition(inputs: ScanMissionInputs): unknown;
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 definition, as canonical proto JSON.
229
+ * The name of the checked-in Scan mission in gibson's mission catalog.
227
230
  *
228
- * STILL BUILT HERE, AND IT SHOULD NOT BE. gibson#1685 landed the Scan mission
229
- * as a checked-in definition in gibson's `missioncatalog`, and ADR-0018 makes
230
- * that copy authoritative: this function is meant to become a mission name plus
231
- * seven parameters, not a second description of the same graph.
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
- * It cannot yet. `missioncatalog.Render` has no production caller, and
234
- * `CreateMission` carries only `mission_definition_json` — there is no field
235
- * naming a checked-in mission and none carrying its parameters, so an agent has
236
- * no way to ask for the catalog's copy. That is gibson#1688. Until it lands the
237
- * only way to originate a Scan is to send a graph, so this one stays, with the
238
- * shape the catalog definition expects: everything the fan-out needs is carried
239
- * in the node's task context, and the swap is this function's body alone.
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
- * The definition is built as protojson by hand rather than through the
242
- * generated schema because the SDK's package exports only its index, and the
243
- * mission schema is not re-exported from it.
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 scanMissionDefinition(inputs) {
246
- const { application, pipeline } = inputs;
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
- name: scanMissionName(inputs),
260
- description: `Scan ${application} at ${pipeline.sha.slice(0, 12)}, originated by the always-on agent ` +
261
- `for pipeline ${pipeline.id}.`,
262
- version: "1.0.0",
263
- nodes: {
264
- scan: {
265
- id: "scan",
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, targetId: opts.targetId };
314
- const created = await harness.client.createMission({
315
- context: harness.context,
316
- missionDefinitionJson: new TextEncoder().encode(JSON.stringify(build(inputs))),
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.6.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.9.0"
26
+ "@zeroroot-ai/sdk": "^0.11.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@opencode-ai/plugin": "*"