@zeroroot-ai/zerocool 0.4.0 → 0.5.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 +64 -1
- package/dist/dispatch.js +174 -4
- package/dist/gitlab.d.ts +74 -0
- package/dist/gitlab.js +82 -0
- package/dist/watch.d.ts +214 -0
- package/dist/watch.js +341 -0
- package/package.json +1 -1
package/dist/dispatch.d.ts
CHANGED
|
@@ -57,8 +57,10 @@
|
|
|
57
57
|
*/
|
|
58
58
|
import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
|
|
59
59
|
import { type FindingsBackend } from "./findings.js";
|
|
60
|
+
import { type GitLabClient } from "./gitlab.js";
|
|
60
61
|
import { runOpencode } from "./opencode-run.js";
|
|
61
62
|
import type { SemgrepRunner } from "./semgrep.js";
|
|
63
|
+
import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
|
|
62
64
|
import { type TriageModel } from "./source-analysis.js";
|
|
63
65
|
/**
|
|
64
66
|
* The per-dispatch grant, as the sandbox injects it. The child authenticates its
|
|
@@ -126,8 +128,30 @@ export interface DecodedTask {
|
|
|
126
128
|
* value that is not a string is dropped rather than guessed at.
|
|
127
129
|
*/
|
|
128
130
|
export declare function decodeTask(taskB64: string | undefined): DecodedTask;
|
|
131
|
+
/**
|
|
132
|
+
* Read one `Task.context` / `Task.metadata` entry as a string.
|
|
133
|
+
*
|
|
134
|
+
* Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
|
|
135
|
+
* protojson renders every value as a one-key object naming the oneof arm —
|
|
136
|
+
* `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
|
|
137
|
+
* string. gibson marshals the Task with `protojson.Marshal` over
|
|
138
|
+
* `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
|
|
139
|
+
* this is the only shape a dispatched agent ever receives. Reading these maps
|
|
140
|
+
* as plain strings silently dropped every entry, which is why a mission node's
|
|
141
|
+
* `zerocool.task`, `repository.commit` and `target.id` never reached the run.
|
|
142
|
+
*
|
|
143
|
+
* A bare string is still accepted, because a hand-built Task written as plain
|
|
144
|
+
* JSON is the normal way to drive a dispatch by hand. Both snake_case and
|
|
145
|
+
* lowerCamel arm names are read: protojson emits lowerCamel by default but can
|
|
146
|
+
* be told to keep the proto field names, and a decoder that accepts only one of
|
|
147
|
+
* them fails on a Task that is otherwise valid.
|
|
148
|
+
*
|
|
149
|
+
* `nullValue` yields no entry: a null is the absence of a value, and rendering
|
|
150
|
+
* it as the string "null" would hand a task a target id of `"null"`.
|
|
151
|
+
*/
|
|
152
|
+
export declare function typedValueString(val: unknown): string | undefined;
|
|
129
153
|
/** The task kinds a sandboxed dispatch can serve. */
|
|
130
|
-
export type DispatchTaskKind = "opencode" | "source-analysis";
|
|
154
|
+
export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
|
|
131
155
|
/**
|
|
132
156
|
* Which task this dispatch runs. The mission node says so in the Task's
|
|
133
157
|
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
@@ -164,6 +188,20 @@ export interface DispatchDeps {
|
|
|
164
188
|
harness?: (ctx: DispatchContext) => TaskHarness;
|
|
165
189
|
/** Force the task kind, over the Task's own context. */
|
|
166
190
|
taskKind?: DispatchTaskKind;
|
|
191
|
+
/** Watch seams: no GitLab, no World and no child missions in a test. */
|
|
192
|
+
watch?: WatchDeps;
|
|
193
|
+
}
|
|
194
|
+
/** The watch task's injectable seams. Everything the loop reaches out through. */
|
|
195
|
+
export interface WatchDeps {
|
|
196
|
+
gitlab?: GitLabClient;
|
|
197
|
+
checkpoints?: WatchCheckpoints;
|
|
198
|
+
scans?: ScanLauncher;
|
|
199
|
+
/** Resolve the GitLab token. Defaults to the harness `GetCredential`. */
|
|
200
|
+
credential?: (name: string) => Promise<string>;
|
|
201
|
+
sleep?: (ms: number) => Promise<void>;
|
|
202
|
+
/** Bound the loop. Unset runs until the sandbox is torn down. */
|
|
203
|
+
maxPolls?: number;
|
|
204
|
+
signal?: AbortSignal;
|
|
167
205
|
}
|
|
168
206
|
/**
|
|
169
207
|
* Run one dispatched Task headless and map it to a terminal outcome.
|
|
@@ -185,6 +223,31 @@ export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps):
|
|
|
185
223
|
* `repository.url`, `repository.commit`, `target.id`.
|
|
186
224
|
*/
|
|
187
225
|
export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
226
|
+
/**
|
|
227
|
+
* The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
|
|
228
|
+
* dispatch does not finish until the mission is cancelled or the sandbox is
|
|
229
|
+
* torn down. It polls the application's GitLab project and originates a Scan
|
|
230
|
+
* mission per finished pipeline on the branch, from inside this very mission
|
|
231
|
+
* (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
|
|
232
|
+
*
|
|
233
|
+
* Task context keys it reads:
|
|
234
|
+
* `zerocool.task` selector, "watch"
|
|
235
|
+
* `application` the Application this watch follows (required)
|
|
236
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
237
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
238
|
+
* `gitlab.ref` branch to watch; defaults to `main`
|
|
239
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
240
|
+
* `repository.url` clone url handed to each Scan mission
|
|
241
|
+
* `target.id` the registered target each Scan mission binds to
|
|
242
|
+
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
243
|
+
*/
|
|
244
|
+
export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
245
|
+
/**
|
|
246
|
+
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
247
|
+
* is returned to the caller and never logged: it is the GitLab token, and the
|
|
248
|
+
* console stream this dispatch writes is shown in the browser.
|
|
249
|
+
*/
|
|
250
|
+
export declare function harnessCredential(harness: TaskHarness): (name: string) => Promise<string>;
|
|
188
251
|
/**
|
|
189
252
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
190
253
|
*
|
package/dist/dispatch.js
CHANGED
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
import { openTaskHarness } from "@zeroroot-ai/sdk";
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
import { taskFindingsBackend } from "./findings.js";
|
|
61
|
+
import { gitlabRest } from "./gitlab.js";
|
|
61
62
|
import { runOpencode } from "./opencode-run.js";
|
|
63
|
+
import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
|
|
62
64
|
import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
|
|
63
65
|
/**
|
|
64
66
|
* Render the per-dispatch grant back to environment for the opencode child, so
|
|
@@ -118,11 +120,58 @@ function stringMap(v) {
|
|
|
118
120
|
return {};
|
|
119
121
|
const out = {};
|
|
120
122
|
for (const [k, val] of Object.entries(v)) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
const s = typedValueString(val);
|
|
124
|
+
if (s !== undefined)
|
|
125
|
+
out[k] = s;
|
|
123
126
|
}
|
|
124
127
|
return out;
|
|
125
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Read one `Task.context` / `Task.metadata` entry as a string.
|
|
131
|
+
*
|
|
132
|
+
* Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
|
|
133
|
+
* protojson renders every value as a one-key object naming the oneof arm —
|
|
134
|
+
* `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
|
|
135
|
+
* string. gibson marshals the Task with `protojson.Marshal` over
|
|
136
|
+
* `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
|
|
137
|
+
* this is the only shape a dispatched agent ever receives. Reading these maps
|
|
138
|
+
* as plain strings silently dropped every entry, which is why a mission node's
|
|
139
|
+
* `zerocool.task`, `repository.commit` and `target.id` never reached the run.
|
|
140
|
+
*
|
|
141
|
+
* A bare string is still accepted, because a hand-built Task written as plain
|
|
142
|
+
* JSON is the normal way to drive a dispatch by hand. Both snake_case and
|
|
143
|
+
* lowerCamel arm names are read: protojson emits lowerCamel by default but can
|
|
144
|
+
* be told to keep the proto field names, and a decoder that accepts only one of
|
|
145
|
+
* them fails on a Task that is otherwise valid.
|
|
146
|
+
*
|
|
147
|
+
* `nullValue` yields no entry: a null is the absence of a value, and rendering
|
|
148
|
+
* it as the string "null" would hand a task a target id of `"null"`.
|
|
149
|
+
*/
|
|
150
|
+
export function typedValueString(val) {
|
|
151
|
+
if (typeof val === "string")
|
|
152
|
+
return val;
|
|
153
|
+
if (!val || typeof val !== "object")
|
|
154
|
+
return undefined;
|
|
155
|
+
const v = val;
|
|
156
|
+
const arm = (camel, snake) => (v[camel] !== undefined ? v[camel] : v[snake]);
|
|
157
|
+
const s = arm("stringValue", "string_value");
|
|
158
|
+
if (typeof s === "string")
|
|
159
|
+
return s;
|
|
160
|
+
// protojson renders int64 and uint64 as strings, and double as a number.
|
|
161
|
+
const i = arm("intValue", "int_value");
|
|
162
|
+
if (typeof i === "string" || typeof i === "number")
|
|
163
|
+
return String(i);
|
|
164
|
+
const u = arm("uintValue", "uint_value");
|
|
165
|
+
if (typeof u === "string" || typeof u === "number")
|
|
166
|
+
return String(u);
|
|
167
|
+
const d = arm("doubleValue", "double_value");
|
|
168
|
+
if (typeof d === "number")
|
|
169
|
+
return String(d);
|
|
170
|
+
const b = arm("boolValue", "bool_value");
|
|
171
|
+
if (typeof b === "boolean")
|
|
172
|
+
return String(b);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
126
175
|
/**
|
|
127
176
|
* Which task this dispatch runs. The mission node says so in the Task's
|
|
128
177
|
* context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
|
|
@@ -130,7 +179,11 @@ function stringMap(v) {
|
|
|
130
179
|
*/
|
|
131
180
|
export function dispatchTaskKind(ctx, env = {}) {
|
|
132
181
|
const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
|
|
133
|
-
|
|
182
|
+
if (kind === "source-analysis")
|
|
183
|
+
return "source-analysis";
|
|
184
|
+
if (kind === "watch")
|
|
185
|
+
return "watch";
|
|
186
|
+
return "opencode";
|
|
134
187
|
}
|
|
135
188
|
/**
|
|
136
189
|
* Read one sandboxed dispatch's context from the environment.
|
|
@@ -185,9 +238,13 @@ export function readDispatchContext(env, opts = {}) {
|
|
|
185
238
|
* into a failed terminal result and a non-zero exit.
|
|
186
239
|
*/
|
|
187
240
|
export async function runDispatch(ctx, deps = {}) {
|
|
188
|
-
|
|
241
|
+
const kind = deps.taskKind ?? dispatchTaskKind(ctx, process.env);
|
|
242
|
+
if (kind === "source-analysis") {
|
|
189
243
|
return runSourceAnalysisDispatch(ctx, deps);
|
|
190
244
|
}
|
|
245
|
+
if (kind === "watch") {
|
|
246
|
+
return runWatchDispatch(ctx, deps);
|
|
247
|
+
}
|
|
191
248
|
const run = deps.run ?? runOpencode;
|
|
192
249
|
const result = await run({
|
|
193
250
|
goal: ctx.goal,
|
|
@@ -264,6 +321,119 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
|
|
|
264
321
|
harness?.stop();
|
|
265
322
|
}
|
|
266
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
|
|
326
|
+
* dispatch does not finish until the mission is cancelled or the sandbox is
|
|
327
|
+
* torn down. It polls the application's GitLab project and originates a Scan
|
|
328
|
+
* mission per finished pipeline on the branch, from inside this very mission
|
|
329
|
+
* (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
|
|
330
|
+
*
|
|
331
|
+
* Task context keys it reads:
|
|
332
|
+
* `zerocool.task` selector, "watch"
|
|
333
|
+
* `application` the Application this watch follows (required)
|
|
334
|
+
* `gitlab.project` `group/project` on the instance (required)
|
|
335
|
+
* `gitlab.url` instance base url; defaults to gitlab.com
|
|
336
|
+
* `gitlab.ref` branch to watch; defaults to `main`
|
|
337
|
+
* `gitlab.credential` tenant secret holding the project access token
|
|
338
|
+
* `repository.url` clone url handed to each Scan mission
|
|
339
|
+
* `target.id` the registered target each Scan mission binds to
|
|
340
|
+
* `watch.interval_ms` poll cadence; defaults to 30s
|
|
341
|
+
*/
|
|
342
|
+
export async function runWatchDispatch(ctx, deps = {}) {
|
|
343
|
+
const w = deps.watch ?? {};
|
|
344
|
+
const application = ctx.taskContext.application || "";
|
|
345
|
+
const projectPath = ctx.taskContext["gitlab.project"] || "";
|
|
346
|
+
const ref = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
|
|
347
|
+
const targetId = ctx.taskContext["target.id"] || "";
|
|
348
|
+
const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
|
|
349
|
+
if (!application) {
|
|
350
|
+
throw new Error("watch: the mission node set no `application` in the task context. The watch keys its " +
|
|
351
|
+
"checkpoints and its Scan missions by Application; without one it cannot tell its own " +
|
|
352
|
+
"state from another watch's.");
|
|
353
|
+
}
|
|
354
|
+
if (!w.gitlab && !projectPath) {
|
|
355
|
+
throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
356
|
+
"project to poll.");
|
|
357
|
+
}
|
|
358
|
+
// A harness is opened unless every seam that would use one was injected.
|
|
359
|
+
const needsHarness = !w.gitlab || !w.checkpoints || !w.scans || !w.credential;
|
|
360
|
+
const opened = needsHarness && !deps.harness;
|
|
361
|
+
const harness = needsHarness
|
|
362
|
+
? (deps.harness ??
|
|
363
|
+
((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
|
|
364
|
+
: undefined;
|
|
365
|
+
try {
|
|
366
|
+
const gitlab = w.gitlab ??
|
|
367
|
+
gitlabRest({
|
|
368
|
+
projectPath,
|
|
369
|
+
token: await (w.credential ?? harnessCredential(harness))(credentialName),
|
|
370
|
+
...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
|
|
371
|
+
});
|
|
372
|
+
const checkpoints = w.checkpoints ?? worldCheckpoints(harness, application);
|
|
373
|
+
const scans = w.scans ??
|
|
374
|
+
harnessScanLauncher(harness, {
|
|
375
|
+
targetId,
|
|
376
|
+
inputs: {
|
|
377
|
+
application,
|
|
378
|
+
repositoryUrl: ctx.taskContext["repository.url"] || "",
|
|
379
|
+
...(ctx.taskContext["image.ref"] ? { imageRef: ctx.taskContext["image.ref"] } : {}),
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
const intervalMs = Number(ctx.taskContext["watch.interval_ms"] || Number.NaN);
|
|
383
|
+
const summary = await runWatchLoop({
|
|
384
|
+
application,
|
|
385
|
+
ref,
|
|
386
|
+
gitlab,
|
|
387
|
+
checkpoints,
|
|
388
|
+
scans,
|
|
389
|
+
...(Number.isFinite(intervalMs) && intervalMs > 0 ? { intervalMs } : {}),
|
|
390
|
+
...(w.maxPolls !== undefined ? { maxPolls: w.maxPolls } : {}),
|
|
391
|
+
...(w.signal ? { signal: w.signal } : {}),
|
|
392
|
+
...(w.sleep ? { sleep: w.sleep } : {}),
|
|
393
|
+
...(deps.onEvent ? { onEvent: (e) => deps.onEvent?.(JSON.stringify(e)) } : {}),
|
|
394
|
+
});
|
|
395
|
+
return {
|
|
396
|
+
output: `watch ${application}@${ref}: ${summary.polls} polls, ${summary.scans} scan missions, ` +
|
|
397
|
+
`${summary.errors} errors; last pipeline ${summary.lastPipelineId || "none"}`,
|
|
398
|
+
success: true,
|
|
399
|
+
metadata: {
|
|
400
|
+
task: "watch",
|
|
401
|
+
application,
|
|
402
|
+
ref,
|
|
403
|
+
polls: String(summary.polls),
|
|
404
|
+
scans: String(summary.scans),
|
|
405
|
+
errors: String(summary.errors),
|
|
406
|
+
last_pipeline_id: String(summary.lastPipelineId),
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
if (opened)
|
|
412
|
+
harness?.stop();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Resolve a tenant secret through the harness on the dispatch grant. The value
|
|
417
|
+
* is returned to the caller and never logged: it is the GitLab token, and the
|
|
418
|
+
* console stream this dispatch writes is shown in the browser.
|
|
419
|
+
*/
|
|
420
|
+
export function harnessCredential(harness) {
|
|
421
|
+
return async (name) => {
|
|
422
|
+
const res = await harness.client.getCredential({ context: harness.context, name });
|
|
423
|
+
if (res.error) {
|
|
424
|
+
throw new Error(`GetCredential(${name}) refused: ${res.error.message}`);
|
|
425
|
+
}
|
|
426
|
+
const secret = res.credential?.secretData;
|
|
427
|
+
const value = secret?.case === "apiKey" || secret?.case === "bearerToken" || secret?.case === "customSecret"
|
|
428
|
+
? secret.value
|
|
429
|
+
: undefined;
|
|
430
|
+
if (!value) {
|
|
431
|
+
throw new Error(`GetCredential(${name}) returned no usable secret. A GitLab project access token is ` +
|
|
432
|
+
"stored as an api key, a bearer token, or a single-valued custom secret.");
|
|
433
|
+
}
|
|
434
|
+
return value;
|
|
435
|
+
};
|
|
436
|
+
}
|
|
267
437
|
/**
|
|
268
438
|
* Render a terminal outcome as the last NDJSON line on stdout.
|
|
269
439
|
*
|
package/dist/gitlab.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
|
|
3
|
+
*
|
|
4
|
+
* The agent polls one project's pipelines on one branch. It never receives a
|
|
5
|
+
* webhook and GitLab holds no Gibson credential: the poll runs outbound from
|
|
6
|
+
* the sandbox on the tenant's own project access token, resolved through the
|
|
7
|
+
* harness `GetCredential` under the dispatch grant. That direction is the point
|
|
8
|
+
* — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
|
|
9
|
+
* only from inside a mission it was dispatched to), so the agent watches
|
|
10
|
+
* GitLab rather than GitLab calling Gibson.
|
|
11
|
+
*
|
|
12
|
+
* FINISHED MEANS `success`. A pipeline that failed published no image and left
|
|
13
|
+
* `main` unchanged, so there is nothing new to scan; treating it as a trigger
|
|
14
|
+
* would run a Scan mission against the image the previous pipeline built and
|
|
15
|
+
* report its findings against the new commit. `canceled` and `skipped` are the
|
|
16
|
+
* same case. The status this polls for is therefore `success`, named once in
|
|
17
|
+
* {@link TRIGGER_STATUS} rather than spelled at each call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
|
|
20
|
+
* fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
|
|
21
|
+
* network and no token.
|
|
22
|
+
*/
|
|
23
|
+
/** The pipeline status that triggers a Scan mission. See the module note. */
|
|
24
|
+
export declare const TRIGGER_STATUS = "success";
|
|
25
|
+
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
|
+
export declare const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/** One pipeline, reduced to what a trigger decision and a Scan mission need. */
|
|
28
|
+
export interface Pipeline {
|
|
29
|
+
/** GitLab's own pipeline id. Monotonic per project, and the checkpoint key. */
|
|
30
|
+
id: number;
|
|
31
|
+
/** The commit the pipeline ran on — the Scan mission's `repository.commit`. */
|
|
32
|
+
sha: string;
|
|
33
|
+
/** `success` for anything this module returns; carried for the console line. */
|
|
34
|
+
status: string;
|
|
35
|
+
/** The branch, echoed back so a caller can log what it matched. */
|
|
36
|
+
ref: string;
|
|
37
|
+
/** GitLab's page for the pipeline, for the console line and the MR note. */
|
|
38
|
+
webUrl: string;
|
|
39
|
+
}
|
|
40
|
+
/** The GitLab reads the watch loop makes. One method, so a stub is three lines. */
|
|
41
|
+
export interface GitLabClient {
|
|
42
|
+
/**
|
|
43
|
+
* The most recent successful pipeline on `ref`, or `undefined` when the
|
|
44
|
+
* project has none. Rejects when GitLab refuses or is unreachable — the loop
|
|
45
|
+
* reports that and keeps polling.
|
|
46
|
+
*/
|
|
47
|
+
latestFinishedPipeline(ref: string): Promise<Pipeline | undefined>;
|
|
48
|
+
}
|
|
49
|
+
/** What {@link gitlabRest} needs to reach one project. */
|
|
50
|
+
export interface GitLabRestOptions {
|
|
51
|
+
/** `group/project`, unencoded. Encoded once here, at the call. */
|
|
52
|
+
projectPath: string;
|
|
53
|
+
/** The project access token, resolved from the tenant secret store. */
|
|
54
|
+
token: string;
|
|
55
|
+
/** The instance. Defaults to {@link GITLAB_DEFAULT_URL}. */
|
|
56
|
+
baseUrl?: string;
|
|
57
|
+
/** Injectable transport. Defaults to the global `fetch`. */
|
|
58
|
+
fetch?: typeof globalThis.fetch;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
62
|
+
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
63
|
+
* would scan whatever the workspace happened to hold.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parsePipeline(raw: unknown): Pipeline | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* A {@link GitLabClient} over GitLab's REST API.
|
|
68
|
+
*
|
|
69
|
+
* The token travels in `PRIVATE-TOKEN`, which is what a project access token
|
|
70
|
+
* authenticates with. It is never logged and never placed in a URL, so it
|
|
71
|
+
* cannot reach the console stream through an error message that quotes the
|
|
72
|
+
* request.
|
|
73
|
+
*/
|
|
74
|
+
export declare function gitlabRest(opts: GitLabRestOptions): GitLabClient;
|
package/dist/gitlab.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
|
|
3
|
+
*
|
|
4
|
+
* The agent polls one project's pipelines on one branch. It never receives a
|
|
5
|
+
* webhook and GitLab holds no Gibson credential: the poll runs outbound from
|
|
6
|
+
* the sandbox on the tenant's own project access token, resolved through the
|
|
7
|
+
* harness `GetCredential` under the dispatch grant. That direction is the point
|
|
8
|
+
* — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
|
|
9
|
+
* only from inside a mission it was dispatched to), so the agent watches
|
|
10
|
+
* GitLab rather than GitLab calling Gibson.
|
|
11
|
+
*
|
|
12
|
+
* FINISHED MEANS `success`. A pipeline that failed published no image and left
|
|
13
|
+
* `main` unchanged, so there is nothing new to scan; treating it as a trigger
|
|
14
|
+
* would run a Scan mission against the image the previous pipeline built and
|
|
15
|
+
* report its findings against the new commit. `canceled` and `skipped` are the
|
|
16
|
+
* same case. The status this polls for is therefore `success`, named once in
|
|
17
|
+
* {@link TRIGGER_STATUS} rather than spelled at each call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
|
|
20
|
+
* fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
|
|
21
|
+
* network and no token.
|
|
22
|
+
*/
|
|
23
|
+
/** The pipeline status that triggers a Scan mission. See the module note. */
|
|
24
|
+
export const TRIGGER_STATUS = "success";
|
|
25
|
+
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
|
+
export const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/**
|
|
28
|
+
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
29
|
+
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
30
|
+
* would scan whatever the workspace happened to hold.
|
|
31
|
+
*/
|
|
32
|
+
export function parsePipeline(raw) {
|
|
33
|
+
if (!raw || typeof raw !== "object")
|
|
34
|
+
return undefined;
|
|
35
|
+
const p = raw;
|
|
36
|
+
const id = typeof p.id === "number" ? p.id : Number.NaN;
|
|
37
|
+
const sha = typeof p.sha === "string" ? p.sha : "";
|
|
38
|
+
if (!Number.isInteger(id) || id <= 0 || !sha)
|
|
39
|
+
return undefined;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
sha,
|
|
43
|
+
status: typeof p.status === "string" ? p.status : "",
|
|
44
|
+
ref: typeof p.ref === "string" ? p.ref : "",
|
|
45
|
+
webUrl: typeof p.web_url === "string" ? p.web_url : "",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A {@link GitLabClient} over GitLab's REST API.
|
|
50
|
+
*
|
|
51
|
+
* The token travels in `PRIVATE-TOKEN`, which is what a project access token
|
|
52
|
+
* authenticates with. It is never logged and never placed in a URL, so it
|
|
53
|
+
* cannot reach the console stream through an error message that quotes the
|
|
54
|
+
* request.
|
|
55
|
+
*/
|
|
56
|
+
export function gitlabRest(opts) {
|
|
57
|
+
const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
58
|
+
const project = encodeURIComponent(opts.projectPath);
|
|
59
|
+
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
60
|
+
return {
|
|
61
|
+
async latestFinishedPipeline(ref) {
|
|
62
|
+
const url = `${base}/api/v4/projects/${project}/pipelines` +
|
|
63
|
+
`?ref=${encodeURIComponent(ref)}&status=${TRIGGER_STATUS}&order_by=id&sort=desc&per_page=1`;
|
|
64
|
+
const res = await doFetch(url, {
|
|
65
|
+
headers: { "PRIVATE-TOKEN": opts.token, Accept: "application/json" },
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${res.status} ${res.statusText}`);
|
|
69
|
+
}
|
|
70
|
+
const body = await res.json();
|
|
71
|
+
if (!Array.isArray(body)) {
|
|
72
|
+
throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${typeof body}, expected an array`);
|
|
73
|
+
}
|
|
74
|
+
for (const row of body) {
|
|
75
|
+
const p = parsePipeline(row);
|
|
76
|
+
if (p)
|
|
77
|
+
return p;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type TaskHarness } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { type GitLabClient, type Pipeline } from "./gitlab.js";
|
|
3
|
+
/**
|
|
4
|
+
* The always-on watch loop (zerocool-plugins#88).
|
|
5
|
+
*
|
|
6
|
+
* Dispatched into a `watch-<application>` mission, the agent does not run a
|
|
7
|
+
* task and exit. It polls the application's GitLab project every 30 seconds,
|
|
8
|
+
* and on each finished pipeline on `main` it has not seen it originates a Scan
|
|
9
|
+
* mission, waits for it, and goes back to polling. The loop ends when the
|
|
10
|
+
* mission is cancelled or the sandbox is torn down, not on its own.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
|
|
13
|
+
* originate a mission only from inside one it was dispatched to. A GitLab job
|
|
14
|
+
* is not inside a mission, so it cannot originate a Scan mission whatever
|
|
15
|
+
* credential it holds. The person originates the long-lived watch mission once;
|
|
16
|
+
* every Scan mission after that is originated by the agent from inside it,
|
|
17
|
+
* through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
|
|
18
|
+
* gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
|
|
19
|
+
* harness rather than the request, so the child is attributed to this mission
|
|
20
|
+
* and a caller outside a live mission is refused outright.
|
|
21
|
+
*
|
|
22
|
+
* ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
|
|
23
|
+
* most recent successful pipeline on the branch — never for a backlog. That is
|
|
24
|
+
* what makes the checkpoint safe to lose: an unreadable checkpoint costs one
|
|
25
|
+
* redundant Scan of the current head, never a stampede over a project's
|
|
26
|
+
* history. A checkpoint is an optimisation against duplicate work, not a
|
|
27
|
+
* correctness gate, and the loop is written so that is true.
|
|
28
|
+
*
|
|
29
|
+
* THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
|
|
30
|
+
* anything written to its disk dies with it. The last pipeline seen goes into
|
|
31
|
+
* the World as a memory observation under the dispatch grant, which is the same
|
|
32
|
+
* write path every other thing the agent learns takes.
|
|
33
|
+
*/
|
|
34
|
+
/** The observation `kind` the checkpoint is written under. */
|
|
35
|
+
export declare const CHECKPOINT_KIND = "watch-checkpoint";
|
|
36
|
+
/** Default poll interval. The issue's cadence, and slow enough for GitLab. */
|
|
37
|
+
export declare const DEFAULT_INTERVAL_MS = 30000;
|
|
38
|
+
/** Default ceiling on one Scan mission before the loop stops waiting on it. */
|
|
39
|
+
export declare const DEFAULT_SCAN_TIMEOUT_MS: number;
|
|
40
|
+
/** The branch a watch follows unless the task says otherwise. */
|
|
41
|
+
export declare const DEFAULT_REF = "main";
|
|
42
|
+
/**
|
|
43
|
+
* Poll state, kept in the World so a restarted agent resumes.
|
|
44
|
+
*
|
|
45
|
+
* `last` returning 0 means "nothing recorded, or the record could not be read".
|
|
46
|
+
* Both are the same instruction to the loop — scan the current head once — so
|
|
47
|
+
* there is nothing here for a caller to distinguish or get wrong.
|
|
48
|
+
*/
|
|
49
|
+
export interface WatchCheckpoints {
|
|
50
|
+
last(): Promise<number>;
|
|
51
|
+
record(p: Pipeline): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** Originates and awaits one Scan mission. */
|
|
54
|
+
export interface ScanLauncher {
|
|
55
|
+
/** Create and run the Scan mission for `p`. Returns its mission id. */
|
|
56
|
+
launch(p: Pipeline): Promise<string>;
|
|
57
|
+
/** Block until that mission is terminal. Returns its status, for the console. */
|
|
58
|
+
wait(missionId: string): Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
/** One line of the console stream. Rendered as NDJSON by the dispatch driver. */
|
|
61
|
+
export type WatchEvent = {
|
|
62
|
+
type: "watch.start";
|
|
63
|
+
application: string;
|
|
64
|
+
ref: string;
|
|
65
|
+
lastPipelineId: number;
|
|
66
|
+
} | {
|
|
67
|
+
type: "watch.poll";
|
|
68
|
+
application: string;
|
|
69
|
+
ref: string;
|
|
70
|
+
pipelineId: number;
|
|
71
|
+
status: string;
|
|
72
|
+
fresh: boolean;
|
|
73
|
+
} | {
|
|
74
|
+
type: "watch.scan";
|
|
75
|
+
application: string;
|
|
76
|
+
pipelineId: number;
|
|
77
|
+
commit: string;
|
|
78
|
+
missionId: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: "watch.scan.done";
|
|
81
|
+
application: string;
|
|
82
|
+
pipelineId: number;
|
|
83
|
+
missionId: string;
|
|
84
|
+
status: string;
|
|
85
|
+
} | {
|
|
86
|
+
type: "watch.error";
|
|
87
|
+
application: string;
|
|
88
|
+
stage: "checkpoint" | "poll" | "scan" | "record";
|
|
89
|
+
message: string;
|
|
90
|
+
};
|
|
91
|
+
/** What one watch run reports back when it is asked to stop. */
|
|
92
|
+
export interface WatchSummary {
|
|
93
|
+
polls: number;
|
|
94
|
+
scans: number;
|
|
95
|
+
errors: number;
|
|
96
|
+
lastPipelineId: number;
|
|
97
|
+
}
|
|
98
|
+
export interface WatchLoopOptions {
|
|
99
|
+
/** The Application this watch follows, e.g. `customer-portal`. */
|
|
100
|
+
application: string;
|
|
101
|
+
/** The branch whose pipelines trigger a Scan. */
|
|
102
|
+
ref?: string;
|
|
103
|
+
gitlab: GitLabClient;
|
|
104
|
+
checkpoints: WatchCheckpoints;
|
|
105
|
+
scans: ScanLauncher;
|
|
106
|
+
/** Poll cadence. Defaults to {@link DEFAULT_INTERVAL_MS}. */
|
|
107
|
+
intervalMs?: number;
|
|
108
|
+
/** Stop after this many polls. Unset runs until aborted — the real posture. */
|
|
109
|
+
maxPolls?: number;
|
|
110
|
+
/** Cooperative stop, so a caller can end the loop between polls. */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Console stream. Every poll emits one line so the ops wall shows life. */
|
|
113
|
+
onEvent?: (e: WatchEvent) => void;
|
|
114
|
+
/** Test seams. */
|
|
115
|
+
sleep?: (ms: number) => Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run the watch loop.
|
|
119
|
+
*
|
|
120
|
+
* Nothing in here throws for an expected failure. A GitLab outage, a refused
|
|
121
|
+
* Scan, a checkpoint that cannot be written — each is reported to the console
|
|
122
|
+
* and the loop keeps polling, because an always-on agent that exits on the
|
|
123
|
+
* first transient error is not always-on. The one thing it will not do is
|
|
124
|
+
* record a checkpoint for a Scan it failed to originate: that pipeline is
|
|
125
|
+
* retried on the next poll.
|
|
126
|
+
*/
|
|
127
|
+
export declare function runWatchLoop(opts: WatchLoopOptions): Promise<WatchSummary>;
|
|
128
|
+
/**
|
|
129
|
+
* The checkpoint fact, in one parseable line.
|
|
130
|
+
*
|
|
131
|
+
* A memory is free text by design, so the loop writes a line it can read back
|
|
132
|
+
* without depending on which properties the projector chose to keep. The
|
|
133
|
+
* application is part of the line, so one tenant's several watches never read
|
|
134
|
+
* each other's checkpoints.
|
|
135
|
+
*/
|
|
136
|
+
export declare function checkpointText(application: string, p: Pipeline): string;
|
|
137
|
+
/** The checkpoint a line carries, or `undefined` when it is not one. */
|
|
138
|
+
export declare function parseCheckpoint(text: string): {
|
|
139
|
+
application: string;
|
|
140
|
+
pipelineId: number;
|
|
141
|
+
commit: string;
|
|
142
|
+
} | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Checkpoints kept in the tenant World under the dispatch grant.
|
|
145
|
+
*
|
|
146
|
+
* The write is `Observe` with a memory shape, which the Taxonomy gate lands as
|
|
147
|
+
* an `Observation` node. The read is the graph query for those observations.
|
|
148
|
+
*
|
|
149
|
+
* DAEMON STATE, and why the loop is built to survive it: the SDK records that
|
|
150
|
+
* `QueryNodes` is declared but not wired at the daemon's registration site and
|
|
151
|
+
* answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
|
|
152
|
+
* wired, `last` reports 0 and the loop scans the current head once per restart
|
|
153
|
+
* rather than resuming — which is the same behaviour as a first run, and is why
|
|
154
|
+
* the loop never reads a backlog.
|
|
155
|
+
*/
|
|
156
|
+
export declare function worldCheckpoints(harness: TaskHarness, application: string): WatchCheckpoints;
|
|
157
|
+
/** The recall text for an application's checkpoints. Exported so a test names it once. */
|
|
158
|
+
export declare function checkpointQueryText(application: string): string;
|
|
159
|
+
/** What a Scan mission needs to know about the pipeline that triggered it. */
|
|
160
|
+
export interface ScanMissionInputs {
|
|
161
|
+
application: string;
|
|
162
|
+
pipeline: Pipeline;
|
|
163
|
+
/** The project's clone url, so the Scan mission knows what to check out. */
|
|
164
|
+
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;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The Scan mission definition, as canonical proto JSON.
|
|
172
|
+
*
|
|
173
|
+
* A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
|
|
174
|
+
* Trivy on the image, source analysis on the commit, and the runtime tools
|
|
175
|
+
* against the target, fanned out and joined. Until it lands, this originates
|
|
176
|
+
* the one node that exists today: zerocool's source-analysis task
|
|
177
|
+
* (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
|
|
178
|
+
* needs is already carried in the node's task context, so gibson#1660 replaces
|
|
179
|
+
* the body of this one function and nothing else in the loop changes.
|
|
180
|
+
*
|
|
181
|
+
* The definition is built as protojson by hand rather than through the
|
|
182
|
+
* generated schema because the SDK's package exports only its index, and the
|
|
183
|
+
* mission schema is not re-exported from it.
|
|
184
|
+
*/
|
|
185
|
+
export declare function scanMissionDefinition(inputs: ScanMissionInputs): unknown;
|
|
186
|
+
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
187
|
+
export declare function scanMissionName(inputs: ScanMissionInputs): string;
|
|
188
|
+
/**
|
|
189
|
+
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
190
|
+
* protojson. Every value the loop passes is a string, so every entry is a
|
|
191
|
+
* `stringValue`.
|
|
192
|
+
*/
|
|
193
|
+
export declare function typedContext(ctx: Record<string, string>): Record<string, {
|
|
194
|
+
stringValue: string;
|
|
195
|
+
}>;
|
|
196
|
+
export interface HarnessScanLauncherOptions {
|
|
197
|
+
/** Everything but the pipeline, which the launcher fills in per trigger. */
|
|
198
|
+
inputs: Omit<ScanMissionInputs, "pipeline">;
|
|
199
|
+
/** The target the child mission binds to. Its scope for every observation. */
|
|
200
|
+
targetId: string;
|
|
201
|
+
/** Ceiling on one Scan. Defaults to {@link DEFAULT_SCAN_TIMEOUT_MS}. */
|
|
202
|
+
scanTimeoutMs?: number;
|
|
203
|
+
/** Override the definition, so gibson#1660 can supply the real one. */
|
|
204
|
+
definition?: (inputs: ScanMissionInputs) => unknown;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
208
|
+
*
|
|
209
|
+
* `CreateMission` here is the in-mission origination ADR-0063 permits: the
|
|
210
|
+
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
211
|
+
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
212
|
+
* parent, a tenant or an originator, so nothing here can widen them.
|
|
213
|
+
*/
|
|
214
|
+
export declare function harnessScanLauncher(harness: TaskHarness, opts: HarnessScanLauncherOptions): ScanLauncher;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { remember, taskKnowledge } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { TRIGGER_STATUS } from "./gitlab.js";
|
|
3
|
+
/**
|
|
4
|
+
* The always-on watch loop (zerocool-plugins#88).
|
|
5
|
+
*
|
|
6
|
+
* Dispatched into a `watch-<application>` mission, the agent does not run a
|
|
7
|
+
* task and exit. It polls the application's GitLab project every 30 seconds,
|
|
8
|
+
* and on each finished pipeline on `main` it has not seen it originates a Scan
|
|
9
|
+
* mission, waits for it, and goes back to polling. The loop ends when the
|
|
10
|
+
* mission is cancelled or the sandbox is torn down, not on its own.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
|
|
13
|
+
* originate a mission only from inside one it was dispatched to. A GitLab job
|
|
14
|
+
* is not inside a mission, so it cannot originate a Scan mission whatever
|
|
15
|
+
* credential it holds. The person originates the long-lived watch mission once;
|
|
16
|
+
* every Scan mission after that is originated by the agent from inside it,
|
|
17
|
+
* through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
|
|
18
|
+
* gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
|
|
19
|
+
* harness rather than the request, so the child is attributed to this mission
|
|
20
|
+
* and a caller outside a live mission is refused outright.
|
|
21
|
+
*
|
|
22
|
+
* ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
|
|
23
|
+
* most recent successful pipeline on the branch — never for a backlog. That is
|
|
24
|
+
* what makes the checkpoint safe to lose: an unreadable checkpoint costs one
|
|
25
|
+
* redundant Scan of the current head, never a stampede over a project's
|
|
26
|
+
* history. A checkpoint is an optimisation against duplicate work, not a
|
|
27
|
+
* correctness gate, and the loop is written so that is true.
|
|
28
|
+
*
|
|
29
|
+
* THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
|
|
30
|
+
* anything written to its disk dies with it. The last pipeline seen goes into
|
|
31
|
+
* the World as a memory observation under the dispatch grant, which is the same
|
|
32
|
+
* write path every other thing the agent learns takes.
|
|
33
|
+
*/
|
|
34
|
+
/** The observation `kind` the checkpoint is written under. */
|
|
35
|
+
export const CHECKPOINT_KIND = "watch-checkpoint";
|
|
36
|
+
/** Default poll interval. The issue's cadence, and slow enough for GitLab. */
|
|
37
|
+
export const DEFAULT_INTERVAL_MS = 30_000;
|
|
38
|
+
/** Default ceiling on one Scan mission before the loop stops waiting on it. */
|
|
39
|
+
export const DEFAULT_SCAN_TIMEOUT_MS = 30 * 60_000;
|
|
40
|
+
/** The branch a watch follows unless the task says otherwise. */
|
|
41
|
+
export const DEFAULT_REF = "main";
|
|
42
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
43
|
+
/**
|
|
44
|
+
* Run the watch loop.
|
|
45
|
+
*
|
|
46
|
+
* Nothing in here throws for an expected failure. A GitLab outage, a refused
|
|
47
|
+
* Scan, a checkpoint that cannot be written — each is reported to the console
|
|
48
|
+
* and the loop keeps polling, because an always-on agent that exits on the
|
|
49
|
+
* first transient error is not always-on. The one thing it will not do is
|
|
50
|
+
* record a checkpoint for a Scan it failed to originate: that pipeline is
|
|
51
|
+
* retried on the next poll.
|
|
52
|
+
*/
|
|
53
|
+
export async function runWatchLoop(opts) {
|
|
54
|
+
const ref = opts.ref || DEFAULT_REF;
|
|
55
|
+
const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
56
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
57
|
+
const emit = (e) => opts.onEvent?.(e);
|
|
58
|
+
const summary = { polls: 0, scans: 0, errors: 0, lastPipelineId: 0 };
|
|
59
|
+
// A checkpoint that cannot be read is not fatal: the loop then treats the
|
|
60
|
+
// current head as fresh and scans it once. See the module note.
|
|
61
|
+
try {
|
|
62
|
+
summary.lastPipelineId = await opts.checkpoints.last();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
summary.errors++;
|
|
66
|
+
emit({ type: "watch.error", application: opts.application, stage: "checkpoint", message: message(e) });
|
|
67
|
+
}
|
|
68
|
+
emit({
|
|
69
|
+
type: "watch.start",
|
|
70
|
+
application: opts.application,
|
|
71
|
+
ref,
|
|
72
|
+
lastPipelineId: summary.lastPipelineId,
|
|
73
|
+
});
|
|
74
|
+
while (!opts.signal?.aborted && (opts.maxPolls === undefined || summary.polls < opts.maxPolls)) {
|
|
75
|
+
summary.polls++;
|
|
76
|
+
let pipeline;
|
|
77
|
+
try {
|
|
78
|
+
pipeline = await opts.gitlab.latestFinishedPipeline(ref);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
summary.errors++;
|
|
82
|
+
emit({ type: "watch.error", application: opts.application, stage: "poll", message: message(e) });
|
|
83
|
+
await pause();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const fresh = !!pipeline && pipeline.id > summary.lastPipelineId;
|
|
87
|
+
emit({
|
|
88
|
+
type: "watch.poll",
|
|
89
|
+
application: opts.application,
|
|
90
|
+
ref,
|
|
91
|
+
pipelineId: pipeline?.id ?? 0,
|
|
92
|
+
status: pipeline?.status ?? TRIGGER_STATUS,
|
|
93
|
+
fresh,
|
|
94
|
+
});
|
|
95
|
+
if (!pipeline || !fresh) {
|
|
96
|
+
await pause();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Originate, then record, then wait. Recording before the wait means a
|
|
100
|
+
// sandbox that dies mid-Scan does not re-originate the same Scan on
|
|
101
|
+
// restart; recording only after a successful launch means a refused launch
|
|
102
|
+
// is retried on the next poll instead of being silently skipped.
|
|
103
|
+
let missionId;
|
|
104
|
+
try {
|
|
105
|
+
missionId = await opts.scans.launch(pipeline);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
summary.errors++;
|
|
109
|
+
emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
|
|
110
|
+
await pause();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
summary.scans++;
|
|
114
|
+
emit({
|
|
115
|
+
type: "watch.scan",
|
|
116
|
+
application: opts.application,
|
|
117
|
+
pipelineId: pipeline.id,
|
|
118
|
+
commit: pipeline.sha,
|
|
119
|
+
missionId,
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
await opts.checkpoints.record(pipeline);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
summary.errors++;
|
|
126
|
+
emit({ type: "watch.error", application: opts.application, stage: "record", message: message(e) });
|
|
127
|
+
}
|
|
128
|
+
summary.lastPipelineId = pipeline.id;
|
|
129
|
+
try {
|
|
130
|
+
const status = await opts.scans.wait(missionId);
|
|
131
|
+
emit({
|
|
132
|
+
type: "watch.scan.done",
|
|
133
|
+
application: opts.application,
|
|
134
|
+
pipelineId: pipeline.id,
|
|
135
|
+
missionId,
|
|
136
|
+
status,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
summary.errors++;
|
|
141
|
+
emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
|
|
142
|
+
}
|
|
143
|
+
await pause();
|
|
144
|
+
}
|
|
145
|
+
return summary;
|
|
146
|
+
async function pause() {
|
|
147
|
+
if (opts.signal?.aborted)
|
|
148
|
+
return;
|
|
149
|
+
if (opts.maxPolls !== undefined && summary.polls >= opts.maxPolls)
|
|
150
|
+
return;
|
|
151
|
+
await sleep(interval);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function message(e) {
|
|
155
|
+
return e instanceof Error ? e.message : String(e);
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// The World-backed checkpoint
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* The checkpoint fact, in one parseable line.
|
|
162
|
+
*
|
|
163
|
+
* A memory is free text by design, so the loop writes a line it can read back
|
|
164
|
+
* without depending on which properties the projector chose to keep. The
|
|
165
|
+
* application is part of the line, so one tenant's several watches never read
|
|
166
|
+
* each other's checkpoints.
|
|
167
|
+
*/
|
|
168
|
+
export function checkpointText(application, p) {
|
|
169
|
+
return `${CHECKPOINT_KIND} application=${application} pipeline=${p.id} commit=${p.sha}`;
|
|
170
|
+
}
|
|
171
|
+
/** The checkpoint a line carries, or `undefined` when it is not one. */
|
|
172
|
+
export function parseCheckpoint(text) {
|
|
173
|
+
if (!text.startsWith(CHECKPOINT_KIND))
|
|
174
|
+
return undefined;
|
|
175
|
+
const application = /\bapplication=(\S+)/.exec(text)?.[1] ?? "";
|
|
176
|
+
const pipelineId = Number(/\bpipeline=(\d+)/.exec(text)?.[1] ?? Number.NaN);
|
|
177
|
+
const commit = /\bcommit=(\S+)/.exec(text)?.[1] ?? "";
|
|
178
|
+
if (!application || !Number.isInteger(pipelineId) || pipelineId <= 0)
|
|
179
|
+
return undefined;
|
|
180
|
+
return { application, pipelineId, commit };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Checkpoints kept in the tenant World under the dispatch grant.
|
|
184
|
+
*
|
|
185
|
+
* The write is `Observe` with a memory shape, which the Taxonomy gate lands as
|
|
186
|
+
* an `Observation` node. The read is the graph query for those observations.
|
|
187
|
+
*
|
|
188
|
+
* DAEMON STATE, and why the loop is built to survive it: the SDK records that
|
|
189
|
+
* `QueryNodes` is declared but not wired at the daemon's registration site and
|
|
190
|
+
* answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
|
|
191
|
+
* wired, `last` reports 0 and the loop scans the current head once per restart
|
|
192
|
+
* rather than resuming — which is the same behaviour as a first run, and is why
|
|
193
|
+
* the loop never reads a backlog.
|
|
194
|
+
*/
|
|
195
|
+
export function worldCheckpoints(harness, application) {
|
|
196
|
+
const knowledge = taskKnowledge(harness);
|
|
197
|
+
return {
|
|
198
|
+
async last() {
|
|
199
|
+
const hits = await knowledge.query({
|
|
200
|
+
text: checkpointQueryText(application),
|
|
201
|
+
nodeTypes: ["Observation"],
|
|
202
|
+
topK: 50,
|
|
203
|
+
});
|
|
204
|
+
let max = 0;
|
|
205
|
+
for (const hit of hits) {
|
|
206
|
+
const parsed = parseCheckpoint(String(hit.content ?? ""));
|
|
207
|
+
if (parsed && parsed.application === application && parsed.pipelineId > max) {
|
|
208
|
+
max = parsed.pipelineId;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return max;
|
|
212
|
+
},
|
|
213
|
+
record: (p) => remember(harness, {
|
|
214
|
+
text: checkpointText(application, p),
|
|
215
|
+
kind: CHECKPOINT_KIND,
|
|
216
|
+
tags: ["watch", `application:${application}`, `pipeline:${p.id}`],
|
|
217
|
+
sourceRef: p.webUrl,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The recall text for an application's checkpoints. Exported so a test names it once. */
|
|
222
|
+
export function checkpointQueryText(application) {
|
|
223
|
+
return `${CHECKPOINT_KIND} application=${application}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The Scan mission definition, as canonical proto JSON.
|
|
227
|
+
*
|
|
228
|
+
* A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
|
|
229
|
+
* Trivy on the image, source analysis on the commit, and the runtime tools
|
|
230
|
+
* against the target, fanned out and joined. Until it lands, this originates
|
|
231
|
+
* the one node that exists today: zerocool's source-analysis task
|
|
232
|
+
* (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
|
|
233
|
+
* needs is already carried in the node's task context, so gibson#1660 replaces
|
|
234
|
+
* the body of this one function and nothing else in the loop changes.
|
|
235
|
+
*
|
|
236
|
+
* The definition is built as protojson by hand rather than through the
|
|
237
|
+
* generated schema because the SDK's package exports only its index, and the
|
|
238
|
+
* mission schema is not re-exported from it.
|
|
239
|
+
*/
|
|
240
|
+
export function scanMissionDefinition(inputs) {
|
|
241
|
+
const { application, pipeline } = inputs;
|
|
242
|
+
const context = {
|
|
243
|
+
"zerocool.task": "source-analysis",
|
|
244
|
+
application,
|
|
245
|
+
"pipeline.id": String(pipeline.id),
|
|
246
|
+
"pipeline.url": pipeline.webUrl,
|
|
247
|
+
"repository.url": inputs.repositoryUrl,
|
|
248
|
+
"repository.ref": pipeline.ref,
|
|
249
|
+
"repository.commit": pipeline.sha,
|
|
250
|
+
...(inputs.imageRef ? { "image.ref": inputs.imageRef } : {}),
|
|
251
|
+
...(inputs.targetId ? { "target.id": inputs.targetId } : {}),
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
name: scanMissionName(inputs),
|
|
255
|
+
description: `Scan ${application} at ${pipeline.sha.slice(0, 12)}, originated by the always-on agent ` +
|
|
256
|
+
`for pipeline ${pipeline.id}.`,
|
|
257
|
+
version: "1.0.0",
|
|
258
|
+
nodes: {
|
|
259
|
+
scan: {
|
|
260
|
+
id: "scan",
|
|
261
|
+
type: "NODE_TYPE_AGENT",
|
|
262
|
+
name: "scan",
|
|
263
|
+
description: "Source analysis of the commit the pipeline built.",
|
|
264
|
+
agentConfig: {
|
|
265
|
+
agentName: "zerocool",
|
|
266
|
+
task: {
|
|
267
|
+
id: "scan",
|
|
268
|
+
goal: `Scan the ${application} repository at commit ${pipeline.sha} and submit a Finding for ` +
|
|
269
|
+
"every real weakness you can support with evidence.",
|
|
270
|
+
context: typedContext(context),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
entryPoints: ["scan"],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
279
|
+
export function scanMissionName(inputs) {
|
|
280
|
+
return `scan ${inputs.application} pipeline ${inputs.pipeline.id}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
284
|
+
* protojson. Every value the loop passes is a string, so every entry is a
|
|
285
|
+
* `stringValue`.
|
|
286
|
+
*/
|
|
287
|
+
export function typedContext(ctx) {
|
|
288
|
+
const out = {};
|
|
289
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
290
|
+
if (v !== "")
|
|
291
|
+
out[k] = { stringValue: v };
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
297
|
+
*
|
|
298
|
+
* `CreateMission` here is the in-mission origination ADR-0063 permits: the
|
|
299
|
+
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
300
|
+
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
301
|
+
* parent, a tenant or an originator, so nothing here can widen them.
|
|
302
|
+
*/
|
|
303
|
+
export function harnessScanLauncher(harness, opts) {
|
|
304
|
+
const build = opts.definition ?? scanMissionDefinition;
|
|
305
|
+
const timeoutMs = BigInt(opts.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS);
|
|
306
|
+
return {
|
|
307
|
+
async launch(pipeline) {
|
|
308
|
+
const inputs = { ...opts.inputs, pipeline, targetId: opts.targetId };
|
|
309
|
+
const created = await harness.client.createMission({
|
|
310
|
+
context: harness.context,
|
|
311
|
+
missionDefinitionJson: new TextEncoder().encode(JSON.stringify(build(inputs))),
|
|
312
|
+
targetId: opts.targetId,
|
|
313
|
+
name: scanMissionName(inputs),
|
|
314
|
+
});
|
|
315
|
+
if (created.error) {
|
|
316
|
+
throw new Error(`CreateMission refused: ${created.error.message}`);
|
|
317
|
+
}
|
|
318
|
+
const missionId = created.mission?.id ?? "";
|
|
319
|
+
if (!missionId) {
|
|
320
|
+
throw new Error("CreateMission returned no mission id");
|
|
321
|
+
}
|
|
322
|
+
const run = await harness.client.runMission({
|
|
323
|
+
context: harness.context,
|
|
324
|
+
missionId,
|
|
325
|
+
wait: false,
|
|
326
|
+
timeoutMs: 0n,
|
|
327
|
+
});
|
|
328
|
+
if (run.error) {
|
|
329
|
+
throw new Error(`RunMission refused for ${missionId}: ${run.error.message}`);
|
|
330
|
+
}
|
|
331
|
+
return missionId;
|
|
332
|
+
},
|
|
333
|
+
async wait(missionId) {
|
|
334
|
+
const res = await harness.client.waitForMission({ context: harness.context, missionId, timeoutMs });
|
|
335
|
+
if (res.error) {
|
|
336
|
+
throw new Error(`WaitForMission failed for ${missionId}: ${res.error.message}`);
|
|
337
|
+
}
|
|
338
|
+
return String(res.result?.status ?? "");
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
package/package.json
CHANGED