@zeroroot-ai/zerocool 0.3.1 → 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.
@@ -46,6 +46,8 @@
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
51
  * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
50
52
  * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
51
53
  *
@@ -53,8 +55,13 @@
53
55
  * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
54
56
  * the GIBSON_CG_JWT this process reads.
55
57
  */
56
- import type { AgentOutcome } from "@zeroroot-ai/sdk";
58
+ import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
59
+ import { type FindingsBackend } from "./findings.js";
60
+ import { type GitLabClient } from "./gitlab.js";
57
61
  import { runOpencode } from "./opencode-run.js";
62
+ import type { SemgrepRunner } from "./semgrep.js";
63
+ import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
64
+ import { type TriageModel } from "./source-analysis.js";
58
65
  /**
59
66
  * The per-dispatch grant, as the sandbox injects it. The child authenticates its
60
67
  * harness callbacks with this; {@link dispatchChildEnv} renders it back to env.
@@ -75,6 +82,14 @@ export interface CallbackGrant {
75
82
  export interface DispatchContext extends CallbackGrant {
76
83
  /** `task.goal` — the natural-language objective the sandbox handed over. */
77
84
  goal: string;
85
+ /**
86
+ * `task.context` — the string map the mission node set. Selects the task kind
87
+ * (`zerocool.task`) and carries what a task needs beyond a goal: the
88
+ * repository url and commit, a sub-path, the target id.
89
+ */
90
+ taskContext: Record<string, string>;
91
+ /** `GIBSON_MISSION_ID`, so a Finding names the mission it belongs to. */
92
+ missionId?: string;
78
93
  /** opencode edits files here; it is the run's workspace. */
79
94
  workspace: string;
80
95
  /** `provider/model`, e.g. `gibson/default`. Omitted lets opencode choose. */
@@ -101,6 +116,48 @@ export declare function dispatchChildEnv(g: CallbackGrant): NodeJS.ProcessEnv;
101
116
  * here rather than run opencode against an empty or malformed prompt.
102
117
  */
103
118
  export declare function decodeTaskGoal(taskB64: string | undefined): string;
119
+ /** The parts of a `gibson.types.v1.Task` a dispatch reads. */
120
+ export interface DecodedTask {
121
+ goal: string;
122
+ context: Record<string, string>;
123
+ metadata: Record<string, string>;
124
+ }
125
+ /**
126
+ * Decode the whole Task: the goal, and the `context` and `metadata` string
127
+ * maps a mission node sets. protojson renders both as plain objects, so a
128
+ * value that is not a string is dropped rather than guessed at.
129
+ */
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;
153
+ /** The task kinds a sandboxed dispatch can serve. */
154
+ export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
155
+ /**
156
+ * Which task this dispatch runs. The mission node says so in the Task's
157
+ * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
158
+ * Anything unrecognized is the default: drive opencode on the goal.
159
+ */
160
+ export declare function dispatchTaskKind(ctx: Pick<DispatchContext, "taskContext">, env?: NodeJS.ProcessEnv): DispatchTaskKind;
104
161
  /**
105
162
  * Read one sandboxed dispatch's context from the environment.
106
163
  *
@@ -123,6 +180,28 @@ export interface DispatchDeps {
123
180
  run?: typeof runOpencode;
124
181
  /** Live tap for each NDJSON event opencode prints. */
125
182
  onEvent?: (line: string) => void;
183
+ /** Source-analysis seams: no semgrep binary, no model, no daemon in a test. */
184
+ semgrep?: SemgrepRunner;
185
+ model?: TriageModel;
186
+ findings?: FindingsBackend;
187
+ /** Opens the task harness. Defaults to `openTaskHarness` on the dispatch grant. */
188
+ harness?: (ctx: DispatchContext) => TaskHarness;
189
+ /** Force the task kind, over the Task's own context. */
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;
126
205
  }
127
206
  /**
128
207
  * Run one dispatched Task headless and map it to a terminal outcome.
@@ -133,6 +212,42 @@ export interface DispatchDeps {
133
212
  * into a failed terminal result and a non-zero exit.
134
213
  */
135
214
  export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
215
+ /**
216
+ * The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
217
+ * involved: semgrep produces candidates, the model triages them through the
218
+ * harness `LLMComplete` on the task grant, and real ones are submitted through
219
+ * the harness `SubmitFinding` on the same grant. The task reads the checkout
220
+ * (the workspace, or `source.path` under it) and changes nothing.
221
+ *
222
+ * Task context keys it reads: `zerocool.task` (selector), `source.path`,
223
+ * `repository.url`, `repository.commit`, `target.id`.
224
+ */
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>;
136
251
  /**
137
252
  * Render a terminal outcome as the last NDJSON line on stdout.
138
253
  *
package/dist/dispatch.js CHANGED
@@ -1,5 +1,67 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * zerocool-dispatch — one sandboxed dispatched run, then exit.
4
+ *
5
+ * The third dispatched shape, and the one gibson launches (zerocool-plugins#57,
6
+ * gibson#1596, ADR-0016). `zerocool-agent` and `zerocool-serve` are long-lived
7
+ * hosts that enroll once, keep a host key, and POLL the daemon for work. This is
8
+ * the opposite: gibson runs an untrusted agent by launching an ephemeral setec
9
+ * sandbox per mission run and injecting the run's credentials as environment. So
10
+ * there is no enrollment, no host key, and no poll loop — the task and the grant
11
+ * are already in the environment when the process starts, and the process serves
12
+ * exactly one dispatch and exits so the sandbox is torn down.
13
+ *
14
+ * WHAT REPLACES THE BOOTSTRAP TOKEN. The poll-loop shapes authenticate as a
15
+ * long-lived COMPONENT: a human mints a one-time `GIBSON_BOOTSTRAP_TOKEN`, the
16
+ * first run trades it for a persistent host key, and every later run re-registers
17
+ * with that key (ADR-0045). A sandboxed run has neither. It authenticates with
18
+ * the PER-DISPATCH capability grant gibson mints for this run and injects as
19
+ * `GIBSON_CALLBACK_ENDPOINT` + `GIBSON_CG_JWT`. That grant carries exactly the
20
+ * authority of this dispatch, so this process never reads a bootstrap token and
21
+ * never touches a host key.
22
+ *
23
+ * THE CALLBACKS ARE GRANT-AUTHENTICATED. This process drives `opencode run`
24
+ * headless and passes the per-dispatch grant to the child (see
25
+ * {@link dispatchChildEnv}). The child's mission callbacks — findings, tool
26
+ * calls, knowledge reads — then reach the daemon's harness callback listener as
27
+ * the TASK, on the injected grant, not as some enrolled component (see
28
+ * `knowledge-source.ts`).
29
+ *
30
+ * THE OUTPUT IS A LIVE NDJSON STREAM. Every event opencode prints is forwarded
31
+ * to this process's stdout as it arrives, so the daemon can stream the run to the
32
+ * read-only console (a later slice consumes it). The last line is the terminal
33
+ * result. stdout is therefore a clean event stream — the driver's own logs go to
34
+ * stderr — and a clean exit lets the sandbox tear down.
35
+ *
36
+ * Environment (injected by the sandbox launch):
37
+ *
38
+ * These names are gibson's canonical dispatch contract — the constants in gibson
39
+ * `internal/engine/harness/sandboxed/agent.go` (envAgent*). This process conforms
40
+ * to them; it does not define its own.
41
+ *
42
+ * GIBSON_CALLBACK_ENDPOINT per-dispatch harness callback dial target (required)
43
+ * GIBSON_CG_JWT per-dispatch capability grant (CG-JWT) (required)
44
+ * GIBSON_AGENT_TASK_B64 base64 protojson gibson.types.v1.Task (required)
45
+ * GIBSON_MODEL provider/model resolved for the tenant at dispatch
46
+ * GIBSON_CALLBACK_INSECURE "1" to dial the callback listener without TLS (kind)
47
+ * GIBSON_MISSION_ID / GIBSON_MISSION_RUN_ID / GIBSON_AGENT_RUN_ID provenance
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
51
+ * GIBSON_OPENCODE_SESSION_ID continue an earlier opencode session
52
+ * GIBSON_TIMEOUT_MS hard deadline for the run, in ms
53
+ *
54
+ * The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
55
+ * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
56
+ * the GIBSON_CG_JWT this process reads.
57
+ */
58
+ import { openTaskHarness } from "@zeroroot-ai/sdk";
59
+ import { join } from "node:path";
60
+ import { taskFindingsBackend } from "./findings.js";
61
+ import { gitlabRest } from "./gitlab.js";
2
62
  import { runOpencode } from "./opencode-run.js";
63
+ import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
64
+ import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
3
65
  /**
4
66
  * Render the per-dispatch grant back to environment for the opencode child, so
5
67
  * the child's harness callbacks travel on the task grant rather than on any
@@ -26,6 +88,14 @@ export function dispatchChildEnv(g) {
26
88
  * here rather than run opencode against an empty or malformed prompt.
27
89
  */
28
90
  export function decodeTaskGoal(taskB64) {
91
+ return decodeTask(taskB64).goal;
92
+ }
93
+ /**
94
+ * Decode the whole Task: the goal, and the `context` and `metadata` string
95
+ * maps a mission node sets. protojson renders both as plain objects, so a
96
+ * value that is not a string is dropped rather than guessed at.
97
+ */
98
+ export function decodeTask(taskB64) {
29
99
  if (!taskB64) {
30
100
  throw new Error("GIBSON_AGENT_TASK_B64 is not set. The sandbox launch injects the task to pursue as the " +
31
101
  "base64 protojson of a gibson.types.v1.Task; without it there is nothing to run.");
@@ -43,7 +113,77 @@ export function decodeTaskGoal(taskB64) {
43
113
  throw new Error("GIBSON_AGENT_TASK_B64 decoded to a Task with no goal. Running opencode against an empty " +
44
114
  "prompt would report whatever it says back as a mission result.");
45
115
  }
46
- return goal;
116
+ return { goal, context: stringMap(task.context), metadata: stringMap(task.metadata) };
117
+ }
118
+ function stringMap(v) {
119
+ if (!v || typeof v !== "object")
120
+ return {};
121
+ const out = {};
122
+ for (const [k, val] of Object.entries(v)) {
123
+ const s = typedValueString(val);
124
+ if (s !== undefined)
125
+ out[k] = s;
126
+ }
127
+ return out;
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
+ }
175
+ /**
176
+ * Which task this dispatch runs. The mission node says so in the Task's
177
+ * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
178
+ * Anything unrecognized is the default: drive opencode on the goal.
179
+ */
180
+ export function dispatchTaskKind(ctx, env = {}) {
181
+ const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
182
+ if (kind === "source-analysis")
183
+ return "source-analysis";
184
+ if (kind === "watch")
185
+ return "watch";
186
+ return "opencode";
47
187
  }
48
188
  /**
49
189
  * Read one sandboxed dispatch's context from the environment.
@@ -71,7 +211,7 @@ export function readDispatchContext(env, opts = {}) {
71
211
  "grant to authenticate with; a sandboxed run must not fall back to any other identity, " +
72
212
  "so this fails instead.");
73
213
  }
74
- const goal = decodeTaskGoal(env.GIBSON_AGENT_TASK_B64);
214
+ const task = decodeTask(env.GIBSON_AGENT_TASK_B64);
75
215
  const timeoutRaw = env.GIBSON_TIMEOUT_MS ? Number(env.GIBSON_TIMEOUT_MS) : 0;
76
216
  return {
77
217
  callbackEndpoint: endpoint,
@@ -80,7 +220,9 @@ export function readDispatchContext(env, opts = {}) {
80
220
  missionRunId: env.GIBSON_MISSION_RUN_ID ?? "",
81
221
  agentRunId: env.GIBSON_AGENT_RUN_ID ?? "",
82
222
  traceId: env.GIBSON_TRACE_ID ?? "",
83
- goal,
223
+ goal: task.goal,
224
+ taskContext: task.context,
225
+ ...(env.GIBSON_MISSION_ID ? { missionId: env.GIBSON_MISSION_ID } : {}),
84
226
  workspace: env.ZEROCOOL_WORKSPACE ?? opts.cwd ?? process.cwd(),
85
227
  ...(env.GIBSON_MODEL ? { model: env.GIBSON_MODEL } : {}),
86
228
  ...(env.GIBSON_OPENCODE_SESSION_ID ? { sessionId: env.GIBSON_OPENCODE_SESSION_ID } : {}),
@@ -96,6 +238,13 @@ export function readDispatchContext(env, opts = {}) {
96
238
  * into a failed terminal result and a non-zero exit.
97
239
  */
98
240
  export async function runDispatch(ctx, deps = {}) {
241
+ const kind = deps.taskKind ?? dispatchTaskKind(ctx, process.env);
242
+ if (kind === "source-analysis") {
243
+ return runSourceAnalysisDispatch(ctx, deps);
244
+ }
245
+ if (kind === "watch") {
246
+ return runWatchDispatch(ctx, deps);
247
+ }
99
248
  const run = deps.run ?? runOpencode;
100
249
  const result = await run({
101
250
  goal: ctx.goal,
@@ -118,6 +267,173 @@ export async function runDispatch(ctx, deps = {}) {
118
267
  },
119
268
  };
120
269
  }
270
+ /**
271
+ * The source-analysis task, sandboxed (zerocool-plugins#87). opencode is not
272
+ * involved: semgrep produces candidates, the model triages them through the
273
+ * harness `LLMComplete` on the task grant, and real ones are submitted through
274
+ * the harness `SubmitFinding` on the same grant. The task reads the checkout
275
+ * (the workspace, or `source.path` under it) and changes nothing.
276
+ *
277
+ * Task context keys it reads: `zerocool.task` (selector), `source.path`,
278
+ * `repository.url`, `repository.commit`, `target.id`.
279
+ */
280
+ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
281
+ const opened = !deps.harness && !(deps.model && deps.findings);
282
+ const harness = deps.model && deps.findings
283
+ ? undefined
284
+ : (deps.harness ?? ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx);
285
+ const model = deps.model ?? harnessTriageModel(harness);
286
+ const findings = deps.findings ?? taskFindingsBackend(harness);
287
+ const sub = ctx.taskContext["source.path"];
288
+ const dir = sub ? join(ctx.workspace, sub) : ctx.workspace;
289
+ try {
290
+ const summary = await runSourceAnalysis({
291
+ dir,
292
+ model,
293
+ findings,
294
+ ...(deps.semgrep ? { semgrep: deps.semgrep } : {}),
295
+ ...(ctx.timeoutMs > 0 ? { semgrepTimeoutMs: ctx.timeoutMs } : {}),
296
+ provenance: {
297
+ ...(ctx.missionId ? { missionId: ctx.missionId } : {}),
298
+ ...(ctx.taskContext["repository.url"] ? { repository: ctx.taskContext["repository.url"] } : {}),
299
+ ...(ctx.taskContext["repository.commit"] ? { commit: ctx.taskContext["repository.commit"] } : {}),
300
+ ...(ctx.taskContext["target.id"] ? { targetId: ctx.taskContext["target.id"] } : {}),
301
+ },
302
+ ...(deps.onEvent ? { onEvent: (event) => deps.onEvent?.(JSON.stringify(event)) } : {}),
303
+ });
304
+ return {
305
+ output: formatSourceAnalysis(summary),
306
+ success: true,
307
+ findingIds: summary.findingIds,
308
+ metadata: {
309
+ task: "source-analysis",
310
+ matches: String(summary.matches),
311
+ candidates: String(summary.candidates),
312
+ real: String(summary.real),
313
+ noise: String(summary.noise),
314
+ submitted: String(summary.findingIds.length),
315
+ failed: String(summary.failed.length),
316
+ },
317
+ };
318
+ }
319
+ finally {
320
+ if (opened)
321
+ harness?.stop();
322
+ }
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
+ }
121
437
  /**
122
438
  * Render a terminal outcome as the last NDJSON line on stdout.
123
439
  *
@@ -1,5 +1,5 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin";
2
- import { type Finding, type GibsonSession } from "@zeroroot-ai/sdk";
2
+ import { type Finding, type GibsonSession, type TaskHarness } from "@zeroroot-ai/sdk";
3
3
  export interface FindingsBackend {
4
4
  /** Submit a finding; returns the identifier it was recorded under. */
5
5
  submit(f: Finding): Promise<string>;
@@ -8,6 +8,14 @@ export interface FindingsBackend {
8
8
  }
9
9
  /** Platform backend — findings land in the tenant knowledge graph. */
10
10
  export declare function gibsonFindingsBackend(session: GibsonSession): FindingsBackend;
11
+ /**
12
+ * Task backend — the callback service's typed `SubmitFinding` under the
13
+ * per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
14
+ * (mission and run), never to a component identity the sandbox does not hold.
15
+ * Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
16
+ * daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
17
+ */
18
+ export declare function taskFindingsBackend(harness: TaskHarness): FindingsBackend;
11
19
  /**
12
20
  * Standalone backend — findings are appended as JSONL. The same Finding shape is
13
21
  * written, so a local log can be replayed into Gibson later.
package/dist/findings.js CHANGED
@@ -26,6 +26,42 @@ export function gibsonFindingsBackend(session) {
26
26
  describe: () => "the tenant Gibson graph",
27
27
  };
28
28
  }
29
+ /**
30
+ * Task backend — the callback service's typed `SubmitFinding` under the
31
+ * per-dispatch grant, so a sandboxed run's findings are attributed to the TASK
32
+ * (mission and run), never to a component identity the sandbox does not hold.
33
+ * Fields map from the SDK's JSON finding onto `gibson.types.v1.Finding`; the
34
+ * daemon keeps the id `newFinding` minted. Same mapping as claude-gibson's.
35
+ */
36
+ export function taskFindingsBackend(harness) {
37
+ const severity = (s) => ({ critical: 1, high: 2, medium: 3, low: 4, info: 5 })[s] ?? 0;
38
+ return {
39
+ submit: async (f) => {
40
+ const res = await harness.client.submitFinding({
41
+ context: harness.context,
42
+ finding: {
43
+ id: f.id,
44
+ missionId: f.mission_id,
45
+ agentName: f.agent_name,
46
+ title: f.title,
47
+ description: f.description,
48
+ category: f.category,
49
+ severity: severity(f.severity),
50
+ confidence: f.confidence,
51
+ remediation: f.remediation ?? "",
52
+ targetId: f.target_id ?? "",
53
+ tags: f.tags ?? [],
54
+ references: f.references ?? [],
55
+ evidence: (f.evidence ?? []).map((e) => ({ type: e.type, title: e.title, content: e.content })),
56
+ },
57
+ });
58
+ if (res.error)
59
+ throw new Error(`SubmitFinding refused: ${res.error.message}`);
60
+ return f.id;
61
+ },
62
+ describe: () => "the tenant Gibson graph (dispatch grant)",
63
+ };
64
+ }
29
65
  /**
30
66
  * Standalone backend — findings are appended as JSONL. The same Finding shape is
31
67
  * written, so a local log can be replayed into Gibson later.
@@ -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;