@zeroroot-ai/zerocool 0.9.0 → 0.9.2

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.
@@ -33,18 +33,21 @@
33
33
  * result. stdout is therefore a clean event stream — the driver's own logs go to
34
34
  * stderr — and a clean exit lets the sandbox tear down.
35
35
  *
36
- * Environment (injected by the sandbox launch):
36
+ * THE LAUNCHER CONTRACT IS READ ONCE, BY THE SDK. `readSandboxDispatch` from
37
+ * `@zeroroot-ai/sdk` is the one reader of the launch environment in this
38
+ * package, and `sandboxHarness` opens the callbacks on what it read. The
39
+ * launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
40
+ * GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
41
+ * GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
42
+ * spelled in the SDK, against gibson `internal/engine/harness/sandboxed/agent.go`
43
+ * (the `envAgent*` constants). This file spells none of them. A second spelling
44
+ * here is how the two drift, which is the defect zerocool-plugins#7 records.
37
45
  *
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.
46
+ * FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
47
+ * of this host rather than part of the launcher contract. The launcher never
48
+ * writes any of them, so the SDK carries none of them:
41
49
  *
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
50
  * 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
51
  * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
49
52
  * ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
50
53
  * "fix"); the Task's own context `zerocool.task`
@@ -54,9 +57,9 @@
54
57
  *
55
58
  * The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
56
59
  * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
57
- * the GIBSON_CG_JWT this process reads.
60
+ * the grant the SDK read.
58
61
  */
59
- import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
62
+ import { type AgentOutcome, type SandboxDispatch, type TaskHarness } from "@zeroroot-ai/sdk";
60
63
  import { type FindingsBackend } from "./findings.js";
61
64
  import { type FindingSource, type FindingStatusWriter, type FixPlanner, type Workspace } from "./fix.js";
62
65
  import { type GitLabClient, type GitLabWriter } from "./gitlab.js";
@@ -65,8 +68,11 @@ import type { SemgrepRunner } from "./semgrep.js";
65
68
  import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
66
69
  import { type TriageModel } from "./source-analysis.js";
67
70
  /**
68
- * The per-dispatch grant, as the sandbox injects it. The child authenticates its
69
- * harness callbacks with this; {@link dispatchChildEnv} renders it back to env.
71
+ * The per-dispatch grant as the OPENCODE CHILD receives it.
72
+ *
73
+ * These are `ExecuteRequest` field names, not launcher environment names. The
74
+ * shape is shared with `serve-agent.ts`, which fills it from a polled work
75
+ * item, so the child sees one environment however the run was launched.
70
76
  */
71
77
  export interface CallbackGrant {
72
78
  /** `ExecuteRequest.callback_endpoint` — a bare `host:port` or an http(s) URL. */
@@ -80,27 +86,6 @@ export interface CallbackGrant {
80
86
  agentRunId?: string;
81
87
  traceId?: string;
82
88
  }
83
- /** Everything one sandboxed dispatch needs, read from the environment. */
84
- export interface DispatchContext extends CallbackGrant {
85
- /** `task.goal` — the natural-language objective the sandbox handed over. */
86
- goal: string;
87
- /**
88
- * `task.context` — the string map the mission node set. Selects the task kind
89
- * (`zerocool.task`) and carries what a task needs beyond a goal: the
90
- * repository url and commit, a sub-path, the target id.
91
- */
92
- taskContext: Record<string, string>;
93
- /** `GIBSON_MISSION_ID`, so a Finding names the mission it belongs to. */
94
- missionId?: string;
95
- /** opencode edits files here; it is the run's workspace. */
96
- workspace: string;
97
- /** `provider/model`, e.g. `gibson/default`. Omitted lets opencode choose. */
98
- model?: string;
99
- /** Continue an earlier opencode session instead of starting a new one. */
100
- sessionId?: string;
101
- /** Hard deadline for the run, in ms. `0` when none was set. */
102
- timeoutMs: number;
103
- }
104
89
  /**
105
90
  * Render the per-dispatch grant back to environment for the opencode child, so
106
91
  * the child's harness callbacks travel on the task grant rather than on any
@@ -112,46 +97,45 @@ export interface DispatchContext extends CallbackGrant {
112
97
  */
113
98
  export declare function dispatchChildEnv(g: CallbackGrant): NodeJS.ProcessEnv;
114
99
  /**
115
- * Decode the base64 protojson `gibson.types.v1.Task` the sandbox launch injects
116
- * as `GIBSON_AGENT_TASK_B64` (gibson agent.go `envAgentTaskB64`) and return its
117
- * goal. gibson sends the full typed Task, not a plain goal string, so decode it
118
- * here rather than run opencode against an empty or malformed prompt.
100
+ * Everything one sandboxed dispatch needs.
101
+ *
102
+ * The launcher contract, exactly as `readSandboxDispatch` read it, plus the
103
+ * local options of this host. Extending {@link SandboxDispatch} rather than
104
+ * copying its fields keeps one name for each thing: the grant is `grant`, the
105
+ * dial target is `callbackEndpoint`, and there is no second spelling to drift.
119
106
  */
120
- export declare function decodeTaskGoal(taskB64: string | undefined): string;
121
- /** The parts of a `gibson.types.v1.Task` a dispatch reads. */
122
- export interface DecodedTask {
123
- goal: string;
124
- context: Record<string, string>;
125
- metadata: Record<string, string>;
107
+ export interface DispatchContext extends SandboxDispatch {
108
+ /**
109
+ * `task.context` read as strings. The mission node sets it. It selects the
110
+ * task kind (`zerocool.task`) and carries what a task needs beyond a goal:
111
+ * the repository url and commit, a sub-path, the target id.
112
+ */
113
+ taskContext: Record<string, string>;
114
+ /** Dial the callback listener without TLS. A local option, not the contract. */
115
+ insecure: boolean;
116
+ /** opencode edits files here; it is the run's workspace. */
117
+ workspace: string;
118
+ /** Continue an earlier opencode session instead of starting a new one. */
119
+ sessionId?: string;
120
+ /** Hard deadline for the run, in ms. `0` when none was set. */
121
+ timeoutMs: number;
126
122
  }
127
123
  /**
128
- * Decode the whole Task: the goal, and the `context` and `metadata` string
129
- * maps a mission node sets. protojson renders both as plain objects, so a
130
- * value that is not a string is dropped rather than guessed at.
131
- */
132
- export declare function decodeTask(taskB64: string | undefined): DecodedTask;
133
- /**
134
- * Read one `Task.context` / `Task.metadata` entry as a string.
135
- *
136
- * Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
137
- * protojson renders every value as a one-key object naming the oneof arm —
138
- * `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
139
- * string. gibson marshals the Task with `protojson.Marshal` over
140
- * `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
141
- * this is the only shape a dispatched agent ever receives. Reading these maps
142
- * as plain strings silently dropped every entry, which is why a mission node's
143
- * `zerocool.task`, `repository.commit` and `target.id` never reached the run.
124
+ * Read one `Task.context` entry as a string.
144
125
  *
145
- * A bare string is still accepted, because a hand-built Task written as plain
146
- * JSON is the normal way to drive a dispatch by hand. Both snake_case and
147
- * lowerCamel arm names are read: protojson emits lowerCamel by default but can
148
- * be told to keep the proto field names, and a decoder that accepts only one of
149
- * them fails on a Task that is otherwise valid.
126
+ * `Task.context` is `map<string, gibson.common.v1.TypedValue>`. The SDK parses
127
+ * the launcher's protojson with the generated schema, so every value arrives
128
+ * as the protobuf-es oneof shape and never as a bare string. Reading these
129
+ * maps as plain strings dropped every entry, which is why a mission node's
130
+ * `zerocool.task`, `repository.commit` and `target.id` never reached a run.
150
131
  *
151
- * `nullValue` yields no entry: a null is the absence of a value, and rendering
152
- * it as the string "null" would hand a task a target id of `"null"`.
132
+ * A null yields no entry: a null is the absence of a value, and rendering it
133
+ * would hand a task a target id of `"null"`. Bytes, arrays and maps yield no
134
+ * entry either — a task reads these keys as single strings.
153
135
  */
154
- export declare function typedValueString(val: unknown): string | undefined;
136
+ export declare function typedValueString(v: SandboxDispatch["task"]["context"][string]): string | undefined;
137
+ /** The whole context map as strings. Entries with no readable value are dropped. */
138
+ export declare function taskContextStrings(context: SandboxDispatch["task"]["context"]): Record<string, string>;
155
139
  /** The task kinds a sandboxed dispatch can serve. */
156
140
  export type DispatchTaskKind = "opencode" | "source-analysis" | "watch" | "fix";
157
141
  /**
@@ -161,21 +145,32 @@ export type DispatchTaskKind = "opencode" | "source-analysis" | "watch" | "fix";
161
145
  */
162
146
  export declare function dispatchTaskKind(ctx: Pick<DispatchContext, "taskContext">, env?: NodeJS.ProcessEnv): DispatchTaskKind;
163
147
  /**
164
- * Read one sandboxed dispatch's context from the environment.
148
+ * Read one sandboxed dispatch from the environment.
149
+ *
150
+ * The launcher contract comes from `readSandboxDispatch`, which is the one
151
+ * reader of those names on the TypeScript side. It fails, never falls back: a
152
+ * launch without a grant, an endpoint or a task is a defect in the launch, not
153
+ * something to guess around by running opencode with no task or by making
154
+ * unauthenticated calls.
165
155
  *
166
156
  * Deliberately never reads `GIBSON_BOOTSTRAP_TOKEN` or a host key: a sandboxed
167
157
  * run is authenticated by its per-dispatch grant alone, and reaching for a
168
158
  * bootstrap token here would reintroduce the enrollment handshake this shape
169
159
  * exists to avoid.
170
160
  *
171
- * The grant and the goal are required. A missing piece throws with the reason,
172
- * because a sandbox that launched this process without them is a defect in the
173
- * launch, not something to guess around by running opencode with no task or by
174
- * making unauthenticated calls.
161
+ * What this adds on top are this host's own options — the workspace, the
162
+ * deadline, the opencode session to continue, and whether to dial the callback
163
+ * listener without TLS. The launcher writes none of them, so the SDK carries
164
+ * none of them, and reading them here is not a second spelling of the contract.
175
165
  */
176
166
  export declare function readDispatchContext(env: NodeJS.ProcessEnv, opts?: {
177
167
  cwd?: string;
178
168
  }): DispatchContext;
169
+ /**
170
+ * The task harness for this dispatch: the grant the launch injected, nothing
171
+ * else. `sandboxHarness` is the SDK's opener for exactly this shape.
172
+ */
173
+ export declare function dispatchHarness(ctx: DispatchContext): TaskHarness;
179
174
  /** Injectable seams, so a test drives a dispatch without spawning opencode. */
180
175
  export interface DispatchDeps {
181
176
  /** Overridable opencode driver. */
@@ -186,7 +181,7 @@ export interface DispatchDeps {
186
181
  semgrep?: SemgrepRunner;
187
182
  model?: TriageModel;
188
183
  findings?: FindingsBackend;
189
- /** Opens the task harness. Defaults to `openTaskHarness` on the dispatch grant. */
184
+ /** Opens the task harness. Defaults to {@link dispatchHarness} on the dispatch grant. */
190
185
  harness?: (ctx: DispatchContext) => TaskHarness;
191
186
  /** Force the task kind, over the Task's own context. */
192
187
  taskKind?: DispatchTaskKind;
package/dist/dispatch.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Elastic-2.0
3
+ // Copyright 2026 Zero Root AI
2
4
  /**
3
5
  * zerocool-dispatch — one sandboxed dispatched run, then exit.
4
6
  *
@@ -33,18 +35,21 @@
33
35
  * result. stdout is therefore a clean event stream — the driver's own logs go to
34
36
  * stderr — and a clean exit lets the sandbox tear down.
35
37
  *
36
- * Environment (injected by the sandbox launch):
38
+ * THE LAUNCHER CONTRACT IS READ ONCE, BY THE SDK. `readSandboxDispatch` from
39
+ * `@zeroroot-ai/sdk` is the one reader of the launch environment in this
40
+ * package, and `sandboxHarness` opens the callbacks on what it read. The
41
+ * launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
42
+ * GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
43
+ * GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
44
+ * spelled in the SDK, against gibson `internal/engine/harness/sandboxed/agent.go`
45
+ * (the `envAgent*` constants). This file spells none of them. A second spelling
46
+ * here is how the two drift, which is the defect zerocool-plugins#7 records.
37
47
  *
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.
48
+ * FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
49
+ * of this host rather than part of the launcher contract. The launcher never
50
+ * writes any of them, so the SDK carries none of them:
41
51
  *
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
52
  * 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
53
  * ZEROCOOL_WORKSPACE working directory (defaults to the cwd)
49
54
  * ZEROCOOL_TASK force a task kind ("source-analysis", "watch",
50
55
  * "fix"); the Task's own context `zerocool.task`
@@ -54,9 +59,9 @@
54
59
  *
55
60
  * The child `opencode run` still receives the grant as GIBSON_CALLBACK_TOKEN via
56
61
  * {@link dispatchChildEnv} — that is the opencode plugin's own contract, fed from
57
- * the GIBSON_CG_JWT this process reads.
62
+ * the grant the SDK read.
58
63
  */
59
- import { observe, openTaskHarness, taskKnowledge } from "@zeroroot-ai/sdk";
64
+ import { observe, readSandboxDispatch, sandboxHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
60
65
  import { join } from "node:path";
61
66
  import { taskFindingsBackend } from "./findings.js";
62
67
  import { harnessFindingSource, harnessFindingStatus, runFix, } from "./fix.js";
@@ -84,96 +89,42 @@ export function dispatchChildEnv(g) {
84
89
  };
85
90
  }
86
91
  /**
87
- * Decode the base64 protojson `gibson.types.v1.Task` the sandbox launch injects
88
- * as `GIBSON_AGENT_TASK_B64` (gibson agent.go `envAgentTaskB64`) and return its
89
- * goal. gibson sends the full typed Task, not a plain goal string, so decode it
90
- * here rather than run opencode against an empty or malformed prompt.
91
- */
92
- export function decodeTaskGoal(taskB64) {
93
- return decodeTask(taskB64).goal;
94
- }
95
- /**
96
- * Decode the whole Task: the goal, and the `context` and `metadata` string
97
- * maps a mission node sets. protojson renders both as plain objects, so a
98
- * value that is not a string is dropped rather than guessed at.
92
+ * Read one `Task.context` entry as a string.
93
+ *
94
+ * `Task.context` is `map<string, gibson.common.v1.TypedValue>`. The SDK parses
95
+ * the launcher's protojson with the generated schema, so every value arrives
96
+ * as the protobuf-es oneof shape and never as a bare string. Reading these
97
+ * maps as plain strings dropped every entry, which is why a mission node's
98
+ * `zerocool.task`, `repository.commit` and `target.id` never reached a run.
99
+ *
100
+ * A null yields no entry: a null is the absence of a value, and rendering it
101
+ * would hand a task a target id of `"null"`. Bytes, arrays and maps yield no
102
+ * entry either — a task reads these keys as single strings.
99
103
  */
100
- export function decodeTask(taskB64) {
101
- if (!taskB64) {
102
- throw new Error("GIBSON_AGENT_TASK_B64 is not set. The sandbox launch injects the task to pursue as the " +
103
- "base64 protojson of a gibson.types.v1.Task; without it there is nothing to run.");
104
+ export function typedValueString(v) {
105
+ switch (v.kind.case) {
106
+ case "stringValue":
107
+ return v.kind.value;
108
+ case "intValue":
109
+ return String(v.kind.value);
110
+ case "doubleValue":
111
+ return String(v.kind.value);
112
+ case "boolValue":
113
+ return String(v.kind.value);
114
+ default:
115
+ return undefined;
104
116
  }
105
- let task;
106
- try {
107
- task = JSON.parse(Buffer.from(taskB64, "base64").toString("utf8"));
108
- }
109
- catch (e) {
110
- throw new Error("GIBSON_AGENT_TASK_B64 is not valid base64 protojson of a gibson.types.v1.Task: " +
111
- (e instanceof Error ? e.message : String(e)));
112
- }
113
- const goal = typeof task.goal === "string" ? task.goal.trim() : "";
114
- if (!goal) {
115
- throw new Error("GIBSON_AGENT_TASK_B64 decoded to a Task with no goal. Running opencode against an empty " +
116
- "prompt would report whatever it says back as a mission result.");
117
- }
118
- return { goal, context: stringMap(task.context), metadata: stringMap(task.metadata) };
119
117
  }
120
- function stringMap(v) {
121
- if (!v || typeof v !== "object")
122
- return {};
118
+ /** The whole context map as strings. Entries with no readable value are dropped. */
119
+ export function taskContextStrings(context) {
123
120
  const out = {};
124
- for (const [k, val] of Object.entries(v)) {
125
- const s = typedValueString(val);
121
+ for (const [k, v] of Object.entries(context ?? {})) {
122
+ const s = typedValueString(v);
126
123
  if (s !== undefined)
127
124
  out[k] = s;
128
125
  }
129
126
  return out;
130
127
  }
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 function typedValueString(val) {
153
- if (typeof val === "string")
154
- return val;
155
- if (!val || typeof val !== "object")
156
- return undefined;
157
- const v = val;
158
- const arm = (camel, snake) => (v[camel] !== undefined ? v[camel] : v[snake]);
159
- const s = arm("stringValue", "string_value");
160
- if (typeof s === "string")
161
- return s;
162
- // protojson renders int64 and uint64 as strings, and double as a number.
163
- const i = arm("intValue", "int_value");
164
- if (typeof i === "string" || typeof i === "number")
165
- return String(i);
166
- const u = arm("uintValue", "uint_value");
167
- if (typeof u === "string" || typeof u === "number")
168
- return String(u);
169
- const d = arm("doubleValue", "double_value");
170
- if (typeof d === "number")
171
- return String(d);
172
- const b = arm("boolValue", "bool_value");
173
- if (typeof b === "boolean")
174
- return String(b);
175
- return undefined;
176
- }
177
128
  /**
178
129
  * Which task this dispatch runs. The mission node says so in the Task's
179
130
  * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
@@ -190,49 +141,43 @@ export function dispatchTaskKind(ctx, env = {}) {
190
141
  return "opencode";
191
142
  }
192
143
  /**
193
- * Read one sandboxed dispatch's context from the environment.
144
+ * Read one sandboxed dispatch from the environment.
145
+ *
146
+ * The launcher contract comes from `readSandboxDispatch`, which is the one
147
+ * reader of those names on the TypeScript side. It fails, never falls back: a
148
+ * launch without a grant, an endpoint or a task is a defect in the launch, not
149
+ * something to guess around by running opencode with no task or by making
150
+ * unauthenticated calls.
194
151
  *
195
152
  * Deliberately never reads `GIBSON_BOOTSTRAP_TOKEN` or a host key: a sandboxed
196
153
  * run is authenticated by its per-dispatch grant alone, and reaching for a
197
154
  * bootstrap token here would reintroduce the enrollment handshake this shape
198
155
  * exists to avoid.
199
156
  *
200
- * The grant and the goal are required. A missing piece throws with the reason,
201
- * because a sandbox that launched this process without them is a defect in the
202
- * launch, not something to guess around by running opencode with no task or by
203
- * making unauthenticated calls.
157
+ * What this adds on top are this host's own options — the workspace, the
158
+ * deadline, the opencode session to continue, and whether to dial the callback
159
+ * listener without TLS. The launcher writes none of them, so the SDK carries
160
+ * none of them, and reading them here is not a second spelling of the contract.
204
161
  */
205
162
  export function readDispatchContext(env, opts = {}) {
206
- const endpoint = env.GIBSON_CALLBACK_ENDPOINT;
207
- const token = env.GIBSON_CG_JWT;
208
- if (!endpoint) {
209
- throw new Error("GIBSON_CALLBACK_ENDPOINT is not set. A sandboxed dispatched run is authenticated by " +
210
- "the per-dispatch grant the sandbox launch injects, not by a bootstrap token — the " +
211
- "launch must supply the callback endpoint and the grant.");
212
- }
213
- if (!token) {
214
- throw new Error("GIBSON_CALLBACK_ENDPOINT is set but GIBSON_CG_JWT is not. The callbacks would have no " +
215
- "grant to authenticate with; a sandboxed run must not fall back to any other identity, " +
216
- "so this fails instead.");
217
- }
218
- const task = decodeTask(env.GIBSON_AGENT_TASK_B64);
163
+ const dispatch = readSandboxDispatch(env);
219
164
  const timeoutRaw = env.GIBSON_TIMEOUT_MS ? Number(env.GIBSON_TIMEOUT_MS) : 0;
220
165
  return {
221
- callbackEndpoint: endpoint,
222
- callbackToken: token,
166
+ ...dispatch,
167
+ taskContext: taskContextStrings(dispatch.task.context),
223
168
  insecure: env.GIBSON_CALLBACK_INSECURE === "1",
224
- missionRunId: env.GIBSON_MISSION_RUN_ID ?? "",
225
- agentRunId: env.GIBSON_AGENT_RUN_ID ?? "",
226
- traceId: env.GIBSON_TRACE_ID ?? "",
227
- goal: task.goal,
228
- taskContext: task.context,
229
- ...(env.GIBSON_MISSION_ID ? { missionId: env.GIBSON_MISSION_ID } : {}),
230
169
  workspace: env.ZEROCOOL_WORKSPACE ?? opts.cwd ?? process.cwd(),
231
- ...(env.GIBSON_MODEL ? { model: env.GIBSON_MODEL } : {}),
232
170
  ...(env.GIBSON_OPENCODE_SESSION_ID ? { sessionId: env.GIBSON_OPENCODE_SESSION_ID } : {}),
233
171
  timeoutMs: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 0,
234
172
  };
235
173
  }
174
+ /**
175
+ * The task harness for this dispatch: the grant the launch injected, nothing
176
+ * else. `sandboxHarness` is the SDK's opener for exactly this shape.
177
+ */
178
+ export function dispatchHarness(ctx) {
179
+ return sandboxHarness(ctx, { insecure: ctx.insecure });
180
+ }
236
181
  /**
237
182
  * Run one dispatched Task headless and map it to a terminal outcome.
238
183
  *
@@ -259,7 +204,16 @@ export async function runDispatch(ctx, deps = {}) {
259
204
  ...(ctx.model ? { model: ctx.model } : {}),
260
205
  ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}),
261
206
  ...(ctx.timeoutMs > 0 ? { timeoutMs: ctx.timeoutMs } : {}),
262
- env: dispatchChildEnv(ctx),
207
+ // The child speaks the opencode plugin's own contract, whose token name is
208
+ // GIBSON_CALLBACK_TOKEN. It is fed from the grant the SDK read.
209
+ env: dispatchChildEnv({
210
+ callbackEndpoint: ctx.callbackEndpoint,
211
+ callbackToken: ctx.grant,
212
+ insecure: ctx.insecure,
213
+ missionRunId: ctx.missionRunId,
214
+ agentRunId: ctx.agentRunId,
215
+ traceId: ctx.traceId,
216
+ }),
263
217
  ...(deps.onEvent ? { onEvent: deps.onEvent } : {}),
264
218
  });
265
219
  return {
@@ -288,7 +242,7 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
288
242
  const opened = !deps.harness && !(deps.model && deps.findings);
289
243
  const harness = deps.model && deps.findings
290
244
  ? undefined
291
- : (deps.harness ?? ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx);
245
+ : (deps.harness ?? dispatchHarness)(ctx);
292
246
  const model = deps.model ?? harnessTriageModel(harness);
293
247
  const findings = deps.findings ?? taskFindingsBackend(harness);
294
248
  const sub = ctx.taskContext["source.path"];
@@ -385,10 +339,7 @@ export async function runWatchDispatch(ctx, deps = {}) {
385
339
  // A harness is opened unless every seam that would use one was injected.
386
340
  const needsHarness = !w.gitlab || !w.checkpoints || !w.scans || !w.credential;
387
341
  const opened = needsHarness && !deps.harness;
388
- const harness = needsHarness
389
- ? (deps.harness ??
390
- ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
391
- : undefined;
342
+ const harness = needsHarness ? (deps.harness ?? dispatchHarness)(ctx) : undefined;
392
343
  try {
393
344
  const gitlab = w.gitlab ??
394
345
  gitlabRest({
@@ -493,10 +444,7 @@ export async function runFixDispatch(ctx, deps = {}) {
493
444
  }
494
445
  const needsHarness = !f.gitlab || !f.findings || !f.status || !f.credential;
495
446
  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;
447
+ const harness = needsHarness ? (deps.harness ?? dispatchHarness)(ctx) : undefined;
500
448
  try {
501
449
  const gitlab = f.gitlab ??
502
450
  gitlabRestWriter({
package/dist/findings.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { submitFinding, } from "@zeroroot-ai/sdk";
2
4
  import { appendFile, mkdir } from "node:fs/promises";
3
5
  import { dirname } from "node:path";
package/dist/fix.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  /**
2
4
  * The Fix — what the always-on agent does with a Finding (zerocool-plugins#89).
3
5
  *
package/dist/gitlab.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  /**
2
4
  * GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
3
5
  *
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { decodeToolInput } from "@zeroroot-ai/sdk";
2
4
  export const HTTP_PROBE_TIMEOUT_MS = 15_000;
3
5
  /** Perform the probe. Exported separately from the handler so it is testable without a work item. */
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { connectGibson, startCompletionsShim } from "@zeroroot-ai/sdk";
2
4
  import { existsSync } from "node:fs";
3
5
  import { homedir } from "node:os";
@@ -111,7 +113,7 @@ export const GibsonPlugin = async () => {
111
113
  // Which grant this process reads the knowledge graph with. A dispatched run
112
114
  // reads as the TASK; an interactive one keeps the component grant. Chosen once
113
115
  // here so nothing downstream has to ask.
114
- const { knowledge, scope: knowledgeScope } = selectKnowledgeSource(live);
116
+ const { knowledge, scope: knowledgeScope, stop: stopKnowledge } = selectKnowledgeSource(live);
115
117
  console.error(`[zerocool] knowledge reads use the ${knowledgeScope} grant`);
116
118
  const injectKnowledge = ambientKnowledge(knowledge, process.env.ZEROCOOL_AMBIENT_QUERY ?? "prior findings and security facts for this codebase");
117
119
  return {
@@ -147,6 +149,9 @@ export const GibsonPlugin = async () => {
147
149
  context.sessionID = sessionID;
148
150
  },
149
151
  dispose: async () => {
152
+ // A task-scoped source holds a grant-renewal timer. Leaving it running
153
+ // would keep renewing a grant for a session that has ended.
154
+ stopKnowledge();
150
155
  session?.stop();
151
156
  await shim?.close();
152
157
  },
@@ -1,12 +1,35 @@
1
- import { type GibsonSession, type KnowledgeSource } from "@zeroroot-ai/sdk";
1
+ import { type GibsonSession, type KnowledgeSource, type OpenTaskHarnessOptions, type TaskHarness } from "@zeroroot-ai/sdk";
2
+ /** The chosen knowledge source, and the way to release what it opened. */
3
+ export interface SelectedKnowledge {
4
+ knowledge: KnowledgeSource;
5
+ scope: "task" | "component";
6
+ /**
7
+ * Release the task harness. Safe to call on either scope, and safe to call
8
+ * twice. A component-scoped run opens nothing, so this does nothing.
9
+ */
10
+ stop: () => void;
11
+ }
12
+ /** Seams, so a test can read what a dispatched run actually sends. */
13
+ export interface KnowledgeSourceDeps {
14
+ /** Opens the task harness. Defaults to `openTaskHarness` on the dispatch grant. */
15
+ openHarness?: (opts: OpenTaskHarnessOptions) => TaskHarness;
16
+ }
2
17
  /**
3
18
  * Pick the grant this process should read the knowledge graph with.
4
19
  *
5
20
  * A DISPATCHED run must read as the TASK, not as the component. gibson mints a
6
- * per-dispatch capability grant and `zerocool-agent` passes it to the child as
7
- * GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the component's
8
- * own grant instead is broader authority than the dispatch intended and leaves
9
- * no per-task attribution on anything the run reads — the gap ADR-0006 recorded.
21
+ * per-dispatch capability grant, and the driver passes it to this opencode
22
+ * child as GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the
23
+ * component's own grant instead is broader authority than the dispatch
24
+ * intended and leaves no per-task attribution on anything the run reads — the
25
+ * gap ADR-0006 recorded.
26
+ *
27
+ * THESE TWO NAMES ARE THE CHILD CONTRACT, NOT THE LAUNCHER CONTRACT. The
28
+ * launcher writes GIBSON_CG_JWT and GIBSON_AGENT_TASK_B64 into the sandbox,
29
+ * and `readSandboxDispatch` in `@zeroroot-ai/sdk` is the one reader of those
30
+ * (see `dispatch.ts`). This module runs one process further down, inside
31
+ * opencode, whose environment `dispatchChildEnv` writes. It never sees the
32
+ * launch environment, so it reads the child names and nothing else.
10
33
  *
11
34
  * An INTERACTIVE run has no callback seam and keeps the component grant, which
12
35
  * is correct: a human started it, and there is no task to scope to.
@@ -14,8 +37,10 @@ import { type GibsonSession, type KnowledgeSource } from "@zeroroot-ai/sdk";
14
37
  * A dispatch that carries an endpoint but no token FAILS rather than falling
15
38
  * back. Falling back is how the gap reappears silently, and a caller who wanted
16
39
  * task-scoped reads would get component-scoped ones without being told.
40
+ *
41
+ * `openTaskHarness` derives the `ContextInfo` every callback RPC carries from
42
+ * the grant's own claims, so a task-scoped read reaches the daemon naming the
43
+ * mission it belongs to. It also renews the grant on a timer, which is why the
44
+ * caller must {@link SelectedKnowledge.stop} the source when the session ends.
17
45
  */
18
- export declare function selectKnowledgeSource(session: GibsonSession): {
19
- knowledge: KnowledgeSource;
20
- scope: "task" | "component";
21
- };
46
+ export declare function selectKnowledgeSource(session: GibsonSession, deps?: KnowledgeSourceDeps): SelectedKnowledge;
@@ -1,12 +1,22 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { componentKnowledge, openTaskHarness, taskKnowledge, } from "@zeroroot-ai/sdk";
2
4
  /**
3
5
  * Pick the grant this process should read the knowledge graph with.
4
6
  *
5
7
  * A DISPATCHED run must read as the TASK, not as the component. gibson mints a
6
- * per-dispatch capability grant and `zerocool-agent` passes it to the child as
7
- * GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the component's
8
- * own grant instead is broader authority than the dispatch intended and leaves
9
- * no per-task attribution on anything the run reads — the gap ADR-0006 recorded.
8
+ * per-dispatch capability grant, and the driver passes it to this opencode
9
+ * child as GIBSON_CALLBACK_ENDPOINT / GIBSON_CALLBACK_TOKEN. Reading with the
10
+ * component's own grant instead is broader authority than the dispatch
11
+ * intended and leaves no per-task attribution on anything the run reads — the
12
+ * gap ADR-0006 recorded.
13
+ *
14
+ * THESE TWO NAMES ARE THE CHILD CONTRACT, NOT THE LAUNCHER CONTRACT. The
15
+ * launcher writes GIBSON_CG_JWT and GIBSON_AGENT_TASK_B64 into the sandbox,
16
+ * and `readSandboxDispatch` in `@zeroroot-ai/sdk` is the one reader of those
17
+ * (see `dispatch.ts`). This module runs one process further down, inside
18
+ * opencode, whose environment `dispatchChildEnv` writes. It never sees the
19
+ * launch environment, so it reads the child names and nothing else.
10
20
  *
11
21
  * An INTERACTIVE run has no callback seam and keeps the component grant, which
12
22
  * is correct: a human started it, and there is no task to scope to.
@@ -14,25 +24,36 @@ import { componentKnowledge, openTaskHarness, taskKnowledge, } from "@zeroroot-a
14
24
  * A dispatch that carries an endpoint but no token FAILS rather than falling
15
25
  * back. Falling back is how the gap reappears silently, and a caller who wanted
16
26
  * task-scoped reads would get component-scoped ones without being told.
27
+ *
28
+ * `openTaskHarness` derives the `ContextInfo` every callback RPC carries from
29
+ * the grant's own claims, so a task-scoped read reaches the daemon naming the
30
+ * mission it belongs to. It also renews the grant on a timer, which is why the
31
+ * caller must {@link SelectedKnowledge.stop} the source when the session ends.
17
32
  */
18
- export function selectKnowledgeSource(session) {
33
+ export function selectKnowledgeSource(session, deps = {}) {
19
34
  const endpoint = process.env.GIBSON_CALLBACK_ENDPOINT;
20
35
  const token = process.env.GIBSON_CALLBACK_TOKEN;
21
36
  if (!endpoint) {
22
- return { knowledge: componentKnowledge(session.clients.component), scope: "component" };
37
+ return {
38
+ knowledge: componentKnowledge(session.clients.component),
39
+ scope: "component",
40
+ stop: () => { },
41
+ };
23
42
  }
24
43
  if (!token) {
25
44
  throw new Error("GIBSON_CALLBACK_ENDPOINT is set but GIBSON_CALLBACK_TOKEN is not. A dispatched run " +
26
45
  "must read with its task grant; falling back to the component grant would silently " +
27
46
  "widen this run's authority.");
28
47
  }
48
+ const harness = (deps.openHarness ?? openTaskHarness)({
49
+ endpoint,
50
+ token,
51
+ // A local or kind daemon may serve the callback listener without TLS.
52
+ insecure: process.env.GIBSON_CALLBACK_INSECURE === "1",
53
+ });
29
54
  return {
30
- knowledge: taskKnowledge(openTaskHarness({
31
- endpoint,
32
- token,
33
- // A local or kind daemon may serve the callback listener without TLS.
34
- insecure: process.env.GIBSON_CALLBACK_INSECURE === "1",
35
- })),
55
+ knowledge: taskKnowledge(harness),
36
56
  scope: "task",
57
+ stop: () => harness.stop(),
37
58
  };
38
59
  }
package/dist/knowledge.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { formatKnowledgeForPrompt, } from "@zeroroot-ai/sdk";
2
4
  /**
3
5
  * Knowledge — read the tenant graph so the agent sharpens across runs
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  /**
2
4
  * How opencode reaches the Gibson MCP server (ADR-0008, slice A7).
3
5
  *
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { spawn } from "node:child_process";
2
4
  /** Parse the NDJSON `opencode run --format json` writes to stdout. */
3
5
  export function parseOpencodeEvents(stdout) {
package/dist/semgrep.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { spawn } from "node:child_process";
2
4
  import { fileURLToPath } from "node:url";
3
5
  /**
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Elastic-2.0
3
+ // Copyright 2026 Zero Root AI
2
4
  /**
3
5
  * zerocool-agent — run zerocool headless as a daemon-driven `kind=agent`.
4
6
  *
package/dist/serve.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Elastic-2.0
3
+ // Copyright 2026 Zero Root AI
2
4
  /**
3
5
  * zerocool-serve — run zerocool headless as a dispatched fleet component.
4
6
  *
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { newFinding } from "@zeroroot-ai/sdk";
2
4
  import { readFile } from "node:fs/promises";
3
5
  import { join } from "node:path";
package/dist/watch.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: Elastic-2.0
2
+ // Copyright 2026 Zero Root AI
1
3
  import { buildCreateMissionRequest, remember, taskKnowledge } from "@zeroroot-ai/sdk";
2
4
  import { TRIGGER_STATUS } from "./gitlab.js";
3
5
  /**
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zeroroot-ai/zerocool",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "opencode host adapter for Gibson (zerocool): zero-config LLM through the harness, ambient knowledge, and the Gibson MCP server registered for you. The tools come from @zeroroot-ai/gibson-mcp.",
5
- "license": "MIT",
5
+ "license": "Elastic-2.0",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/zeroroot-ai/zerocool-plugins.git",
@@ -23,13 +23,13 @@
23
23
  "test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
24
24
  },
25
25
  "dependencies": {
26
- "@zeroroot-ai/sdk": "^0.11.0"
26
+ "@zeroroot-ai/sdk": "^0.12.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@opencode-ai/plugin": "*"
30
30
  },
31
31
  "devDependencies": {
32
- "@opencode-ai/plugin": "^1.18.11"
32
+ "@opencode-ai/plugin": "^1.18.27"
33
33
  },
34
34
  "bin": {
35
35
  "zerocool-serve": "dist/serve.js",