@zeroroot-ai/zerocool 0.9.2 → 0.9.4
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 +11 -5
- package/dist/dispatch.js +18 -8
- package/dist/fix.d.ts +15 -6
- package/dist/fix.js +19 -10
- package/dist/gitlab.d.ts +33 -3
- package/dist/gitlab.js +81 -12
- package/dist/http-probe.d.ts +35 -6
- package/dist/http-probe.js +106 -2
- package/dist/index.js +1 -2
- package/dist/mcp-config.d.ts +8 -3
- package/dist/mcp-config.js +8 -3
- package/dist/opencode-run.d.ts +8 -1
- package/dist/opencode-run.js +76 -1
- package/dist/serve-agent.js +4 -5
- package/package.json +3 -3
package/dist/dispatch.d.ts
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
* launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
|
|
40
40
|
* GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
|
|
41
41
|
* GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
|
|
42
|
-
* spelled in the SDK, against
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* spelled in the SDK, against the launcher's own constants. This file spells
|
|
43
|
+
* none of them. A second spelling here is how the two drift, which is the
|
|
44
|
+
* defect zerocool-plugins#7 records.
|
|
45
45
|
*
|
|
46
46
|
* FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
|
|
47
47
|
* of this host rather than part of the launcher contract. The launcher never
|
|
@@ -189,6 +189,8 @@ export interface DispatchDeps {
|
|
|
189
189
|
watch?: WatchDeps;
|
|
190
190
|
/** Fix seams: no GitLab, no graph and no checkout in a test. */
|
|
191
191
|
fix?: FixDeps;
|
|
192
|
+
/** The component's environment, where the connector configuration lives. Defaults to `process.env`. */
|
|
193
|
+
env?: NodeJS.ProcessEnv;
|
|
192
194
|
}
|
|
193
195
|
/**
|
|
194
196
|
* The fix task's injectable seams.
|
|
@@ -254,7 +256,8 @@ export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: D
|
|
|
254
256
|
* `zerocool.task` selector, "watch"
|
|
255
257
|
* `application` the Application this watch follows (required)
|
|
256
258
|
* `gitlab.project` `group/project` on the instance (required)
|
|
257
|
-
* `gitlab.url` instance base url
|
|
259
|
+
* `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
|
|
260
|
+
* defaults to the one allowed host
|
|
258
261
|
* `gitlab.ref` branch to watch; defaults to `main`
|
|
259
262
|
* `gitlab.credential` tenant secret holding the project access token
|
|
260
263
|
* `repository.url` clone url the Scan mission checks out (required)
|
|
@@ -287,12 +290,15 @@ export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDe
|
|
|
287
290
|
* `zerocool.task` selector, "fix"
|
|
288
291
|
* `application` the Application whose Findings are worked (required)
|
|
289
292
|
* `gitlab.project` `group/project` on the instance (required)
|
|
290
|
-
* `gitlab.url` instance base url
|
|
293
|
+
* `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
|
|
294
|
+
* defaults to the one allowed host
|
|
291
295
|
* `gitlab.ref` branch merge requests target; defaults to `main`
|
|
292
296
|
* `gitlab.credential` tenant secret holding the project access token
|
|
293
297
|
* `repository.commit` the commit the Scan ran against — what the status lands on
|
|
294
298
|
* `pipeline.url` the pipeline page, linked from the commit status
|
|
295
299
|
* `fix.max_merge_requests` cap on merge requests opened in one pass
|
|
300
|
+
* `fix.auto_merge` `true` arms merge-when-pipeline-succeeds on the merge
|
|
301
|
+
* requests this pass opens. Default off: a person merges.
|
|
296
302
|
*/
|
|
297
303
|
export declare function runFixDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
|
|
298
304
|
/**
|
package/dist/dispatch.js
CHANGED
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
* launcher names — GIBSON_CG_JWT, GIBSON_CALLBACK_ENDPOINT,
|
|
42
42
|
* GIBSON_AGENT_TASK_B64, GIBSON_MISSION_ID, GIBSON_MISSION_RUN_ID,
|
|
43
43
|
* GIBSON_AGENT_RUN_ID, GIBSON_MODEL, GIBSON_TRACE_ID, GIBSON_SPAN_ID — are
|
|
44
|
-
* spelled in the SDK, against
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* spelled in the SDK, against the launcher's own constants. This file spells
|
|
45
|
+
* none of them. A second spelling here is how the two drift, which is the
|
|
46
|
+
* defect zerocool-plugins#7 records.
|
|
47
47
|
*
|
|
48
48
|
* FOUR VARIABLES ARE STILL READ HERE, and every one of them is a local option
|
|
49
49
|
* of this host rather than part of the launcher contract. The launcher never
|
|
@@ -65,7 +65,7 @@ import { observe, readSandboxDispatch, sandboxHarness, taskKnowledge, } from "@z
|
|
|
65
65
|
import { join } from "node:path";
|
|
66
66
|
import { taskFindingsBackend } from "./findings.js";
|
|
67
67
|
import { harnessFindingSource, harnessFindingStatus, runFix, } from "./fix.js";
|
|
68
|
-
import { gitlabRest, gitlabRestWriter } from "./gitlab.js";
|
|
68
|
+
import { allowedGitLabHosts, gitlabRest, gitlabRestWriter, resolveGitLabBaseUrl } from "./gitlab.js";
|
|
69
69
|
import { runOpencode } from "./opencode-run.js";
|
|
70
70
|
import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
|
|
71
71
|
import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
|
|
@@ -293,7 +293,8 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
|
|
|
293
293
|
* `zerocool.task` selector, "watch"
|
|
294
294
|
* `application` the Application this watch follows (required)
|
|
295
295
|
* `gitlab.project` `group/project` on the instance (required)
|
|
296
|
-
* `gitlab.url` instance base url
|
|
296
|
+
* `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
|
|
297
|
+
* defaults to the one allowed host
|
|
297
298
|
* `gitlab.ref` branch to watch; defaults to `main`
|
|
298
299
|
* `gitlab.credential` tenant secret holding the project access token
|
|
299
300
|
* `repository.url` clone url the Scan mission checks out (required)
|
|
@@ -319,6 +320,9 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
319
320
|
throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
320
321
|
"project to poll.");
|
|
321
322
|
}
|
|
323
|
+
// The host the tenant's token is sent to is checked before the token is
|
|
324
|
+
// fetched, against the connector configuration and never the mission node.
|
|
325
|
+
const gitlabBaseUrl = w.gitlab ? undefined : resolveGitLabBaseUrl(ctx.taskContext["gitlab.url"], allowedGitLabHosts(deps.env ?? process.env));
|
|
322
326
|
// Checked here, not at the first trigger. The checked-in Scan mission requires
|
|
323
327
|
// all seven of its parameters — CUE refuses an incomplete render rather than
|
|
324
328
|
// substituting an empty string — so a watch missing one is already broken. It
|
|
@@ -345,7 +349,7 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
345
349
|
gitlabRest({
|
|
346
350
|
projectPath,
|
|
347
351
|
token: await (w.credential ?? harnessCredential(harness))(credentialName),
|
|
348
|
-
|
|
352
|
+
baseUrl: gitlabBaseUrl,
|
|
349
353
|
});
|
|
350
354
|
const checkpoints = w.checkpoints ?? worldCheckpoints(harness, application);
|
|
351
355
|
const scans = w.scans ??
|
|
@@ -410,12 +414,15 @@ export async function runWatchDispatch(ctx, deps = {}) {
|
|
|
410
414
|
* `zerocool.task` selector, "fix"
|
|
411
415
|
* `application` the Application whose Findings are worked (required)
|
|
412
416
|
* `gitlab.project` `group/project` on the instance (required)
|
|
413
|
-
* `gitlab.url` instance base url
|
|
417
|
+
* `gitlab.url` instance base url, https, host in ZEROCOOL_GITLAB_HOSTS;
|
|
418
|
+
* defaults to the one allowed host
|
|
414
419
|
* `gitlab.ref` branch merge requests target; defaults to `main`
|
|
415
420
|
* `gitlab.credential` tenant secret holding the project access token
|
|
416
421
|
* `repository.commit` the commit the Scan ran against — what the status lands on
|
|
417
422
|
* `pipeline.url` the pipeline page, linked from the commit status
|
|
418
423
|
* `fix.max_merge_requests` cap on merge requests opened in one pass
|
|
424
|
+
* `fix.auto_merge` `true` arms merge-when-pipeline-succeeds on the merge
|
|
425
|
+
* requests this pass opens. Default off: a person merges.
|
|
419
426
|
*/
|
|
420
427
|
export async function runFixDispatch(ctx, deps = {}) {
|
|
421
428
|
const f = deps.fix ?? {};
|
|
@@ -433,6 +440,8 @@ export async function runFixDispatch(ctx, deps = {}) {
|
|
|
433
440
|
throw new Error("fix: the mission node set no `gitlab.project` in the task context, so there is no " +
|
|
434
441
|
"project to open a merge request against.");
|
|
435
442
|
}
|
|
443
|
+
// Same check as the watch: the host is the connector's choice, not the node's.
|
|
444
|
+
const gitlabBaseUrl = f.gitlab ? undefined : resolveGitLabBaseUrl(ctx.taskContext["gitlab.url"], allowedGitLabHosts(deps.env ?? process.env));
|
|
436
445
|
// No default planner and no default workspace: see FixDeps. A Fix that
|
|
437
446
|
// cannot change anything or cannot test what it changed must not report a
|
|
438
447
|
// clean pass over a backlog it never touched.
|
|
@@ -450,7 +459,7 @@ export async function runFixDispatch(ctx, deps = {}) {
|
|
|
450
459
|
gitlabRestWriter({
|
|
451
460
|
projectPath,
|
|
452
461
|
token: await (f.credential ?? harnessCredential(harness))(credentialName),
|
|
453
|
-
|
|
462
|
+
baseUrl: gitlabBaseUrl,
|
|
454
463
|
});
|
|
455
464
|
const findings = f.findings ?? harnessFindingSource(taskKnowledge(harness));
|
|
456
465
|
const status = f.status ??
|
|
@@ -460,6 +469,7 @@ export async function runFixDispatch(ctx, deps = {}) {
|
|
|
460
469
|
application,
|
|
461
470
|
commit,
|
|
462
471
|
targetRef,
|
|
472
|
+
autoMerge: ctx.taskContext["fix.auto_merge"] === "true",
|
|
463
473
|
findings,
|
|
464
474
|
status,
|
|
465
475
|
planner: f.planner,
|
package/dist/fix.d.ts
CHANGED
|
@@ -4,10 +4,17 @@ import type { FileChange, GitLabWriter, MergeRequest } from "./gitlab.js";
|
|
|
4
4
|
*
|
|
5
5
|
* After a Scan mission lands, the agent works the Application's open Findings
|
|
6
6
|
* in priority order. For each one it can act on it rewrites the repository,
|
|
7
|
-
* runs the repository's own tests, and only then opens a merge request
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* runs the repository's own tests, and only then opens a merge request for a
|
|
8
|
+
* person to review. It records the scan verdict on the commit as
|
|
9
|
+
* `gibson/scan` and posts one note saying what it fixed, what it did not, and
|
|
10
|
+
* why.
|
|
11
|
+
*
|
|
12
|
+
* A PERSON MERGES, UNLESS THE APPLICATION OPTED IN. The planner is steered by
|
|
13
|
+
* the Finding and by the repository content it triaged, which is the
|
|
14
|
+
* untrusted input of this whole task. So a merge request is never armed to
|
|
15
|
+
* merge itself by default. `autoMerge` arms `merge_when_pipeline_succeeds`
|
|
16
|
+
* on the requests one pass opens, and it is set only from the Application's
|
|
17
|
+
* explicit `fix.auto_merge` opt-in in the task context.
|
|
11
18
|
*
|
|
12
19
|
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
13
20
|
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
@@ -176,6 +183,8 @@ export interface RunFixOptions {
|
|
|
176
183
|
pipelineUrl?: string;
|
|
177
184
|
/** Cap on merge requests opened in one pass, so a backlog cannot flood review. */
|
|
178
185
|
maxMergeRequests?: number;
|
|
186
|
+
/** Arm auto-merge on the merge requests this pass opens. Default false. See the module note. */
|
|
187
|
+
autoMerge?: boolean;
|
|
179
188
|
onEvent?: (e: FixEvent) => void;
|
|
180
189
|
}
|
|
181
190
|
/** Default ceiling on merge requests opened by one pass. */
|
|
@@ -203,8 +212,8 @@ export declare function fixBranch(finding: FixableFinding): string;
|
|
|
203
212
|
export declare function countsBySeverity(findings: FixableFinding[]): Record<string, number>;
|
|
204
213
|
/** The one-line verdict GitLab shows beside the commit. */
|
|
205
214
|
export declare function statusDescription(summary: FixSummary): string;
|
|
206
|
-
/** The merge request description. Names the Finding it closes and
|
|
207
|
-
export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan): string;
|
|
215
|
+
/** The merge request description. Names the Finding it closes, how, and who merges. */
|
|
216
|
+
export declare function mergeRequestBody(finding: FixableFinding, plan: FixPlan, autoMerge?: boolean): string;
|
|
208
217
|
/** The note posted on each merge request: what this pass did, in full. */
|
|
209
218
|
export declare function noteBody(summary: FixSummary): string;
|
|
210
219
|
/**
|
package/dist/fix.js
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
*
|
|
6
6
|
* After a Scan mission lands, the agent works the Application's open Findings
|
|
7
7
|
* in priority order. For each one it can act on it rewrites the repository,
|
|
8
|
-
* runs the repository's own tests, and only then opens a merge request
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* runs the repository's own tests, and only then opens a merge request for a
|
|
9
|
+
* person to review. It records the scan verdict on the commit as
|
|
10
|
+
* `gibson/scan` and posts one note saying what it fixed, what it did not, and
|
|
11
|
+
* why.
|
|
12
|
+
*
|
|
13
|
+
* A PERSON MERGES, UNLESS THE APPLICATION OPTED IN. The planner is steered by
|
|
14
|
+
* the Finding and by the repository content it triaged, which is the
|
|
15
|
+
* untrusted input of this whole task. So a merge request is never armed to
|
|
16
|
+
* merge itself by default. `autoMerge` arms `merge_when_pipeline_succeeds`
|
|
17
|
+
* on the requests one pass opens, and it is set only from the Application's
|
|
18
|
+
* explicit `fix.auto_merge` opt-in in the task context.
|
|
12
19
|
*
|
|
13
20
|
* THE ORDER IS TESTS, THEN PUSH. A fix that breaks the build is worse than the
|
|
14
21
|
* Finding: it stops every other fix behind it. So the change is applied to a
|
|
@@ -112,7 +119,8 @@ export async function runFix(opts) {
|
|
|
112
119
|
try {
|
|
113
120
|
const branch = fixBranch(finding);
|
|
114
121
|
await opts.gitlab.commitToBranch(branch, opts.targetRef, `${plan.summary}\n\n${plan.detail}`, changes);
|
|
115
|
-
const
|
|
122
|
+
const autoMerge = opts.autoMerge === true;
|
|
123
|
+
const mr = await opts.gitlab.openMergeRequest(branch, opts.targetRef, plan.summary, mergeRequestBody(finding, plan, autoMerge), { autoMerge });
|
|
116
124
|
emit({ type: "fix.mr", findingId: finding.id, iid: mr.iid, webUrl: mr.webUrl });
|
|
117
125
|
opened++;
|
|
118
126
|
await opts.status.markFixing(finding.id, mr);
|
|
@@ -193,8 +201,8 @@ export function statusDescription(summary) {
|
|
|
193
201
|
return "no open findings";
|
|
194
202
|
return `${fixing} merge request(s) opened, ${merged} merged, ${unfixed} left for a human`;
|
|
195
203
|
}
|
|
196
|
-
/** The merge request description. Names the Finding it closes and
|
|
197
|
-
export function mergeRequestBody(finding, plan) {
|
|
204
|
+
/** The merge request description. Names the Finding it closes, how, and who merges. */
|
|
205
|
+
export function mergeRequestBody(finding, plan, autoMerge = false) {
|
|
198
206
|
return [
|
|
199
207
|
plan.detail,
|
|
200
208
|
"",
|
|
@@ -203,9 +211,10 @@ export function mergeRequestBody(finding, plan) {
|
|
|
203
211
|
`Where: ${finding.placeLabel} \`${finding.placeKey}\``,
|
|
204
212
|
`Severity: ${finding.severity}${finding.priority ? ` · priority ${finding.priority}` : ""}`,
|
|
205
213
|
"",
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
214
|
+
autoMerge
|
|
215
|
+
? "Opened by the Gibson always-on agent. This Application opted in to auto-merge, so it merges itself when the pipeline succeeds."
|
|
216
|
+
: "Opened by the Gibson always-on agent for review. A person merges it.",
|
|
217
|
+
"The finding is marked fixed on merge and verified only by a later rescan.",
|
|
209
218
|
].join("\n");
|
|
210
219
|
}
|
|
211
220
|
/** The note posted on each merge request: what this pass did, in full. */
|
package/dist/gitlab.d.ts
CHANGED
|
@@ -24,6 +24,27 @@
|
|
|
24
24
|
export declare const TRIGGER_STATUS = "success";
|
|
25
25
|
/** GitLab's public host, when a tenant names no other instance. */
|
|
26
26
|
export declare const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
27
|
+
/**
|
|
28
|
+
* The connector configuration that says which GitLab hosts a tenant's token
|
|
29
|
+
* may be sent to: a comma-separated list of host names in the component's
|
|
30
|
+
* environment, set by the operator, never by a mission node. Default:
|
|
31
|
+
* `gitlab.com`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const GITLAB_HOSTS_ENV = "ZEROCOOL_GITLAB_HOSTS";
|
|
34
|
+
/** Read the allow list from the environment. */
|
|
35
|
+
export declare function allowedGitLabHosts(env: NodeJS.ProcessEnv): string[];
|
|
36
|
+
/**
|
|
37
|
+
* The instance base URL a dispatch may use, checked against the allow list.
|
|
38
|
+
*
|
|
39
|
+
* `gitlab.url` comes from the mission node's task context, and the token it
|
|
40
|
+
* would be sent with is the tenant's project access token. So the value is
|
|
41
|
+
* not trusted on its own: the scheme must be https, the host must be one the
|
|
42
|
+
* connector configuration names, and the URL carries no credential, query or
|
|
43
|
+
* fragment. An empty `gitlab.url` means the one allowed host, and is refused
|
|
44
|
+
* when the allow list names several, because guessing would pick a host the
|
|
45
|
+
* mission author did not.
|
|
46
|
+
*/
|
|
47
|
+
export declare function resolveGitLabBaseUrl(requested: string | undefined, allowedHosts: string[]): string;
|
|
27
48
|
/** One pipeline, reduced to what a trigger decision and a Scan mission need. */
|
|
28
49
|
export interface Pipeline {
|
|
29
50
|
/** GitLab's own pipeline id. Monotonic per project, and the checkpoint key. */
|
|
@@ -52,7 +73,7 @@ export interface GitLabRestOptions {
|
|
|
52
73
|
projectPath: string;
|
|
53
74
|
/** The project access token, resolved from the tenant secret store. */
|
|
54
75
|
token: string;
|
|
55
|
-
/** The instance. Defaults to {@link GITLAB_DEFAULT_URL}. */
|
|
76
|
+
/** The instance, https only. Defaults to {@link GITLAB_DEFAULT_URL}. Resolve it with {@link resolveGitLabBaseUrl}. */
|
|
56
77
|
baseUrl?: string;
|
|
57
78
|
/** Injectable transport. Defaults to the global `fetch`. */
|
|
58
79
|
fetch?: typeof globalThis.fetch;
|
|
@@ -85,6 +106,11 @@ export interface MergeRequest {
|
|
|
85
106
|
/** GitLab's own state: `opened`, `merged`, `closed`, `locked`. */
|
|
86
107
|
state: string;
|
|
87
108
|
}
|
|
109
|
+
/** What {@link GitLabWriter.openMergeRequest} may be told beyond the request itself. */
|
|
110
|
+
export interface OpenMergeRequestOptions {
|
|
111
|
+
/** Arm `merge_when_pipeline_succeeds`. Default false: a person merges. */
|
|
112
|
+
autoMerge?: boolean;
|
|
113
|
+
}
|
|
88
114
|
/** One file the Fix rewrote, as the commit API wants it. */
|
|
89
115
|
export interface FileChange {
|
|
90
116
|
path: string;
|
|
@@ -98,8 +124,12 @@ export interface GitLabWriter {
|
|
|
98
124
|
* commit is not a state this can leave behind.
|
|
99
125
|
*/
|
|
100
126
|
commitToBranch(branch: string, startRef: string, message: string, changes: FileChange[]): Promise<string>;
|
|
101
|
-
/**
|
|
102
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Open a merge request for a person to review. Auto-merge on a green
|
|
129
|
+
* pipeline is armed only when `opts.autoMerge` is true, which the Fix sets
|
|
130
|
+
* from the Application's explicit opt-in and nothing else.
|
|
131
|
+
*/
|
|
132
|
+
openMergeRequest(branch: string, targetRef: string, title: string, description: string, opts?: OpenMergeRequestOptions): Promise<MergeRequest>;
|
|
103
133
|
/** The current state of a merge request, so a later pass can see it merged. */
|
|
104
134
|
mergeRequestState(iid: number): Promise<MergeRequest>;
|
|
105
135
|
/** Post a note on a merge request. */
|
package/dist/gitlab.js
CHANGED
|
@@ -26,6 +26,69 @@
|
|
|
26
26
|
export const TRIGGER_STATUS = "success";
|
|
27
27
|
/** GitLab's public host, when a tenant names no other instance. */
|
|
28
28
|
export const GITLAB_DEFAULT_URL = "https://gitlab.com";
|
|
29
|
+
/**
|
|
30
|
+
* The connector configuration that says which GitLab hosts a tenant's token
|
|
31
|
+
* may be sent to: a comma-separated list of host names in the component's
|
|
32
|
+
* environment, set by the operator, never by a mission node. Default:
|
|
33
|
+
* `gitlab.com`.
|
|
34
|
+
*/
|
|
35
|
+
export const GITLAB_HOSTS_ENV = "ZEROCOOL_GITLAB_HOSTS";
|
|
36
|
+
/** Read the allow list from the environment. */
|
|
37
|
+
export function allowedGitLabHosts(env) {
|
|
38
|
+
const raw = env[GITLAB_HOSTS_ENV] ?? "";
|
|
39
|
+
const hosts = raw
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((h) => h.trim().toLowerCase())
|
|
42
|
+
.filter((h) => h.length > 0);
|
|
43
|
+
return hosts.length > 0 ? hosts : ["gitlab.com"];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The instance base URL a dispatch may use, checked against the allow list.
|
|
47
|
+
*
|
|
48
|
+
* `gitlab.url` comes from the mission node's task context, and the token it
|
|
49
|
+
* would be sent with is the tenant's project access token. So the value is
|
|
50
|
+
* not trusted on its own: the scheme must be https, the host must be one the
|
|
51
|
+
* connector configuration names, and the URL carries no credential, query or
|
|
52
|
+
* fragment. An empty `gitlab.url` means the one allowed host, and is refused
|
|
53
|
+
* when the allow list names several, because guessing would pick a host the
|
|
54
|
+
* mission author did not.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveGitLabBaseUrl(requested, allowedHosts) {
|
|
57
|
+
const allowed = allowedHosts.map((h) => h.toLowerCase());
|
|
58
|
+
if (allowed.length === 0)
|
|
59
|
+
throw new Error(`${GITLAB_HOSTS_ENV} names no GitLab host, so no token can be sent anywhere`);
|
|
60
|
+
const wanted = (requested ?? "").trim();
|
|
61
|
+
if (!wanted) {
|
|
62
|
+
if (allowed.length === 1)
|
|
63
|
+
return `https://${allowed[0]}`;
|
|
64
|
+
throw new Error(`gitlab.url is not set and ${GITLAB_HOSTS_ENV} allows several hosts (${allowed.join(", ")}); the task context must name one`);
|
|
65
|
+
}
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(wanted);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error(`gitlab.url ${JSON.stringify(wanted)} is not a URL`);
|
|
72
|
+
}
|
|
73
|
+
if (url.protocol !== "https:") {
|
|
74
|
+
throw new Error(`gitlab.url ${JSON.stringify(wanted)} is not https; the tenant's token travels only over https`);
|
|
75
|
+
}
|
|
76
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
77
|
+
throw new Error(`gitlab.url ${JSON.stringify(wanted)} carries a credential, a query or a fragment; only scheme, host and path are read`);
|
|
78
|
+
}
|
|
79
|
+
const host = url.hostname.toLowerCase();
|
|
80
|
+
if (!allowed.includes(host)) {
|
|
81
|
+
throw new Error(`gitlab.url host ${JSON.stringify(host)} is not in ${GITLAB_HOSTS_ENV} (${allowed.join(", ")}); the connector configuration names the hosts a token may be sent to`);
|
|
82
|
+
}
|
|
83
|
+
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
|
|
84
|
+
}
|
|
85
|
+
/** Both clients take the base through here, so a caller cannot hand them a plaintext host. */
|
|
86
|
+
function httpsBase(baseUrl) {
|
|
87
|
+
const base = (baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
|
|
88
|
+
if (!base.startsWith("https://"))
|
|
89
|
+
throw new Error(`GitLab base url ${JSON.stringify(base)} is not https`);
|
|
90
|
+
return base;
|
|
91
|
+
}
|
|
29
92
|
/**
|
|
30
93
|
* Read one pipeline out of GitLab's JSON. A row without an id or a sha is
|
|
31
94
|
* dropped rather than guessed at: a Scan mission keyed on a missing commit
|
|
@@ -56,7 +119,7 @@ export function parsePipeline(raw) {
|
|
|
56
119
|
* request.
|
|
57
120
|
*/
|
|
58
121
|
export function gitlabRest(opts) {
|
|
59
|
-
const base = (opts.baseUrl
|
|
122
|
+
const base = httpsBase(opts.baseUrl);
|
|
60
123
|
const project = encodeURIComponent(opts.projectPath);
|
|
61
124
|
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
62
125
|
return {
|
|
@@ -93,7 +156,7 @@ export const SCAN_STATUS_CONTEXT = "gibson/scan";
|
|
|
93
156
|
* code — never a header.
|
|
94
157
|
*/
|
|
95
158
|
export function gitlabRestWriter(opts) {
|
|
96
|
-
const base = (opts.baseUrl
|
|
159
|
+
const base = httpsBase(opts.baseUrl);
|
|
97
160
|
const project = encodeURIComponent(opts.projectPath);
|
|
98
161
|
const doFetch = opts.fetch ?? globalThis.fetch;
|
|
99
162
|
const api = `${base}/api/v4/projects/${project}`;
|
|
@@ -141,7 +204,7 @@ export function gitlabRestWriter(opts) {
|
|
|
141
204
|
}
|
|
142
205
|
return sha;
|
|
143
206
|
},
|
|
144
|
-
async openMergeRequest(branch, targetRef, title, description) {
|
|
207
|
+
async openMergeRequest(branch, targetRef, title, description, opts) {
|
|
145
208
|
const created = asMergeRequest(await call("POST", "/merge_requests", {
|
|
146
209
|
source_branch: branch,
|
|
147
210
|
target_branch: targetRef,
|
|
@@ -149,15 +212,21 @@ export function gitlabRestWriter(opts) {
|
|
|
149
212
|
description,
|
|
150
213
|
remove_source_branch: true,
|
|
151
214
|
}), branch);
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
215
|
+
// The merge request is open for a person. Machine-written code lands on
|
|
216
|
+
// the customer's branch by itself only where the Application opted in:
|
|
217
|
+
// anything that steers the planner, including the repository content it
|
|
218
|
+
// triaged, would otherwise land as soon as CI went green.
|
|
219
|
+
if (opts?.autoMerge === true) {
|
|
220
|
+
// Auto-merge is a separate call, and a refusal is not fatal: the merge
|
|
221
|
+
// request is open and correct either way, and a human can merge it. A
|
|
222
|
+
// thrown error here would lose a good merge request over a race with
|
|
223
|
+
// the pipeline that has not started yet.
|
|
224
|
+
try {
|
|
225
|
+
await call("PUT", `/merge_requests/${created.iid}/merge`, { merge_when_pipeline_succeeds: true });
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Reported by the caller through the note, not swallowed silently.
|
|
229
|
+
}
|
|
161
230
|
}
|
|
162
231
|
return created;
|
|
163
232
|
},
|
package/dist/http-probe.d.ts
CHANGED
|
@@ -7,11 +7,19 @@ import { decodeToolInput, type GibsonClients, type ToolInvocation } from "@zeror
|
|
|
7
7
|
* dispatch path — mission node → work queue → this process → result → mission
|
|
8
8
|
* completion — without the result depending on anything about the target.
|
|
9
9
|
*
|
|
10
|
-
* SAFETY. This is a fetch, not a scanner: one request, no redirect
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* SAFETY. This is a fetch, not a scanner: one request, no redirect followed,
|
|
11
|
+
* no body execution, and a hard timeout. The body is measured, never returned:
|
|
12
|
+
* a probe that echoes arbitrary remote content back into a mission's knowledge
|
|
13
|
+
* graph is an injection surface, and the size is what the caller asked for.
|
|
14
|
+
*
|
|
15
|
+
* NO INTERNAL TARGET. The probe runs inside the agent container, next to the
|
|
16
|
+
* instance metadata service, the Kubernetes API and every internal service.
|
|
17
|
+
* A dispatch names the URL, so the host is resolved first and every address
|
|
18
|
+
* it resolves to must be a public unicast address. Loopback, link-local,
|
|
19
|
+
* RFC 1918, carrier-grade NAT, IPv6 unique-local and link-local, the
|
|
20
|
+
* unspecified address and multicast are refused before any connection. A
|
|
21
|
+
* redirect is reported as its status and never followed, so a public host
|
|
22
|
+
* cannot bounce the probe inward.
|
|
15
23
|
*/
|
|
16
24
|
export interface ProbeResult {
|
|
17
25
|
url: string;
|
|
@@ -23,8 +31,29 @@ export interface ProbeResult {
|
|
|
23
31
|
elapsedMs: number;
|
|
24
32
|
}
|
|
25
33
|
export declare const HTTP_PROBE_TIMEOUT_MS = 15000;
|
|
34
|
+
/** One resolved address, as `dns.lookup` with `all: true` returns it. */
|
|
35
|
+
export interface ResolvedAddress {
|
|
36
|
+
address: string;
|
|
37
|
+
family: number;
|
|
38
|
+
}
|
|
39
|
+
/** The seams a test replaces: name resolution and the transport. */
|
|
40
|
+
export interface ProbeDeps {
|
|
41
|
+
lookup?: (host: string) => Promise<ResolvedAddress[]>;
|
|
42
|
+
fetch?: typeof globalThis.fetch;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* True when the address is not a public unicast address. Written as the
|
|
46
|
+
* ranges the probe refuses, so each one is named and testable.
|
|
47
|
+
*/
|
|
48
|
+
export declare function isInternalAddress(address: string): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the host and refuse it when any address it maps to is internal. A
|
|
51
|
+
* host that resolves to nothing is refused too: the probe has nothing to
|
|
52
|
+
* check, so it does not connect.
|
|
53
|
+
*/
|
|
54
|
+
export declare function assertPublicHost(hostname: string, lookup: (host: string) => Promise<ResolvedAddress[]>): Promise<void>;
|
|
26
55
|
/** Perform the probe. Exported separately from the handler so it is testable without a work item. */
|
|
27
|
-
export declare function probe(url: string, timeoutMs?: number): Promise<ProbeResult>;
|
|
56
|
+
export declare function probe(url: string, timeoutMs?: number, deps?: ProbeDeps): Promise<ProbeResult>;
|
|
28
57
|
/**
|
|
29
58
|
* Work handler: takes a claimed tool invocation and returns the probe result.
|
|
30
59
|
*
|
package/dist/http-probe.js
CHANGED
|
@@ -1,18 +1,122 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Elastic-2.0
|
|
2
2
|
// Copyright 2026 Zero Root AI
|
|
3
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
3
5
|
import { decodeToolInput } from "@zeroroot-ai/sdk";
|
|
4
6
|
export const HTTP_PROBE_TIMEOUT_MS = 15_000;
|
|
7
|
+
const defaultLookup = async (host) => dnsLookup(host, { all: true });
|
|
8
|
+
/** Parse dotted IPv4 into its four octets, or undefined. */
|
|
9
|
+
function ipv4Octets(address) {
|
|
10
|
+
if (isIP(address) !== 4)
|
|
11
|
+
return undefined;
|
|
12
|
+
return address.split(".").map((o) => Number(o));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* True when the address is not a public unicast address. Written as the
|
|
16
|
+
* ranges the probe refuses, so each one is named and testable.
|
|
17
|
+
*/
|
|
18
|
+
export function isInternalAddress(address) {
|
|
19
|
+
const v4 = ipv4Octets(address);
|
|
20
|
+
if (v4) {
|
|
21
|
+
const [a, b] = v4;
|
|
22
|
+
if (a === 0)
|
|
23
|
+
return true; // 0.0.0.0/8, "this network"
|
|
24
|
+
if (a === 10)
|
|
25
|
+
return true; // RFC 1918
|
|
26
|
+
if (a === 127)
|
|
27
|
+
return true; // loopback
|
|
28
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
29
|
+
return true; // 100.64.0.0/10, carrier-grade NAT
|
|
30
|
+
if (a === 169 && b === 254)
|
|
31
|
+
return true; // link-local, and the cloud metadata service
|
|
32
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
33
|
+
return true; // RFC 1918
|
|
34
|
+
if (a === 192 && b === 168)
|
|
35
|
+
return true; // RFC 1918
|
|
36
|
+
if (a >= 224)
|
|
37
|
+
return true; // multicast and reserved
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (isIP(address) !== 6)
|
|
41
|
+
return true; // not an address at all: refuse
|
|
42
|
+
const groups = ipv6Groups(address);
|
|
43
|
+
if (!groups)
|
|
44
|
+
return true;
|
|
45
|
+
const [first] = groups;
|
|
46
|
+
const leadingZero = groups.slice(0, 5).every((g) => g === 0);
|
|
47
|
+
// ::ffff:a.b.c.d (IPv4-mapped) and ::a.b.c.d (IPv4-compatible, deprecated)
|
|
48
|
+
// are judged as their IPv4 part. The URL parser writes the mapped form in
|
|
49
|
+
// hex, so the last two groups are read back as four octets.
|
|
50
|
+
if (leadingZero && (groups[5] === 0xffff || (groups[5] === 0 && (groups[6] !== 0 || groups[7] > 1)))) {
|
|
51
|
+
const hi = groups[6];
|
|
52
|
+
const lo = groups[7];
|
|
53
|
+
return isInternalAddress(`${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`);
|
|
54
|
+
}
|
|
55
|
+
if (groups.every((g) => g === 0))
|
|
56
|
+
return true; // ::, unspecified
|
|
57
|
+
if (leadingZero && groups[5] === 0 && groups[6] === 0 && groups[7] === 1)
|
|
58
|
+
return true; // ::1, loopback
|
|
59
|
+
if ((first & 0xfe00) === 0xfc00)
|
|
60
|
+
return true; // fc00::/7, unique local
|
|
61
|
+
if ((first & 0xffc0) === 0xfe80)
|
|
62
|
+
return true; // fe80::/10, link-local
|
|
63
|
+
if ((first & 0xff00) === 0xff00)
|
|
64
|
+
return true; // ff00::/8, multicast
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Expand an IPv6 address into its eight 16-bit groups, or undefined. */
|
|
68
|
+
function ipv6Groups(address) {
|
|
69
|
+
let text = address.toLowerCase();
|
|
70
|
+
const zone = text.indexOf("%");
|
|
71
|
+
if (zone >= 0)
|
|
72
|
+
text = text.slice(0, zone);
|
|
73
|
+
// A trailing dotted quad becomes two hex groups.
|
|
74
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
75
|
+
if (dotted) {
|
|
76
|
+
const [a, b, c, d] = dotted[1].split(".").map(Number);
|
|
77
|
+
text = `${text.slice(0, -dotted[1].length)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
|
|
78
|
+
}
|
|
79
|
+
const halves = text.split("::");
|
|
80
|
+
if (halves.length > 2)
|
|
81
|
+
return undefined;
|
|
82
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
83
|
+
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
84
|
+
const fill = 8 - head.length - tail.length;
|
|
85
|
+
if (fill < 0 || (halves.length === 1 && fill !== 0))
|
|
86
|
+
return undefined;
|
|
87
|
+
const parts = [...head, ...Array(fill).fill("0"), ...tail];
|
|
88
|
+
const groups = parts.map((p) => Number.parseInt(p || "0", 16));
|
|
89
|
+
return groups.some((g) => Number.isNaN(g) || g < 0 || g > 0xffff) ? undefined : groups;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolve the host and refuse it when any address it maps to is internal. A
|
|
93
|
+
* host that resolves to nothing is refused too: the probe has nothing to
|
|
94
|
+
* check, so it does not connect.
|
|
95
|
+
*/
|
|
96
|
+
export async function assertPublicHost(hostname, lookup) {
|
|
97
|
+
const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
98
|
+
const addresses = isIP(bare) ? [{ address: bare, family: isIP(bare) }] : await lookup(bare);
|
|
99
|
+
if (addresses.length === 0)
|
|
100
|
+
throw new Error(`http_probe refuses ${hostname}: it resolves to no address`);
|
|
101
|
+
for (const { address } of addresses) {
|
|
102
|
+
if (isInternalAddress(address)) {
|
|
103
|
+
throw new Error(`http_probe refuses ${hostname}: it resolves to ${address}, which is not a public address`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
5
107
|
/** Perform the probe. Exported separately from the handler so it is testable without a work item. */
|
|
6
|
-
export async function probe(url, timeoutMs = HTTP_PROBE_TIMEOUT_MS) {
|
|
108
|
+
export async function probe(url, timeoutMs = HTTP_PROBE_TIMEOUT_MS, deps = {}) {
|
|
7
109
|
const parsed = new URL(url);
|
|
8
110
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
9
111
|
throw new Error(`unsupported scheme ${parsed.protocol} (http and https only)`);
|
|
10
112
|
}
|
|
113
|
+
await assertPublicHost(parsed.hostname, deps.lookup ?? defaultLookup);
|
|
114
|
+
const doFetch = deps.fetch ?? globalThis.fetch;
|
|
11
115
|
const started = Date.now();
|
|
12
116
|
const ctl = new AbortController();
|
|
13
117
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
14
118
|
try {
|
|
15
|
-
const res = await
|
|
119
|
+
const res = await doFetch(parsed.toString(), { method: "GET", redirect: "manual", signal: ctl.signal });
|
|
16
120
|
const body = await res.arrayBuffer();
|
|
17
121
|
return {
|
|
18
122
|
url: parsed.toString(),
|
package/dist/index.js
CHANGED
|
@@ -54,8 +54,7 @@ export const GibsonPlugin = async () => {
|
|
|
54
54
|
// first Capability Grant handshake and the host key it registers is persisted
|
|
55
55
|
// at `hostKeyPath`. Every later start re-registers by proving possession of
|
|
56
56
|
// that host key — the daemon routes on credential type: `host+jwt` is
|
|
57
|
-
// re-registration, anything else is first registration
|
|
58
|
-
// (gibson `internal/server/daemon/capabilitygrant_register.go:134-155`).
|
|
57
|
+
// re-registration, anything else is first registration.
|
|
59
58
|
//
|
|
60
59
|
// So the bootstrap token is passed ONLY when no host key exists yet. Replaying
|
|
61
60
|
// a one-time token on every start would be rejected, and it would mean asking
|
package/dist/mcp-config.d.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* the `config` hook, so a user configures nothing.
|
|
7
7
|
*
|
|
8
8
|
* The command is the published package, not a path inside this plugin: the
|
|
9
|
-
* server is shared by every host and released on the SDK train
|
|
10
|
-
*
|
|
9
|
+
* server is shared by every host and released on the SDK train.
|
|
10
|
+
*
|
|
11
|
+
* The version is exact. `npx --yes` runs whatever the registry serves with no
|
|
12
|
+
* prompt, so a floating tag would run one compromised publish on every
|
|
13
|
+
* machine at session start. The pin is the one in tools/hosts/package.json,
|
|
14
|
+
* which Dependabot bumps. `pins.test.ts` in claude-gibson fails when the two
|
|
15
|
+
* differ or when any install path floats again.
|
|
11
16
|
*/
|
|
12
|
-
export declare const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@
|
|
17
|
+
export declare const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@0.3.0";
|
|
13
18
|
export interface OpencodeMcpLocal {
|
|
14
19
|
type: "local";
|
|
15
20
|
command: string[];
|
package/dist/mcp-config.js
CHANGED
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
* the `config` hook, so a user configures nothing.
|
|
9
9
|
*
|
|
10
10
|
* The command is the published package, not a path inside this plugin: the
|
|
11
|
-
* server is shared by every host and released on the SDK train
|
|
12
|
-
*
|
|
11
|
+
* server is shared by every host and released on the SDK train.
|
|
12
|
+
*
|
|
13
|
+
* The version is exact. `npx --yes` runs whatever the registry serves with no
|
|
14
|
+
* prompt, so a floating tag would run one compromised publish on every
|
|
15
|
+
* machine at session start. The pin is the one in tools/hosts/package.json,
|
|
16
|
+
* which Dependabot bumps. `pins.test.ts` in claude-gibson fails when the two
|
|
17
|
+
* differ or when any install path floats again.
|
|
13
18
|
*/
|
|
14
|
-
export const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@
|
|
19
|
+
export const GIBSON_MCP_PACKAGE = "@zeroroot-ai/gibson-mcp@0.3.0";
|
|
15
20
|
/** The `mcp.gibson` entry. `environment` carries nothing secret. */
|
|
16
21
|
export function gibsonMcpServer(env = process.env) {
|
|
17
22
|
const environment = {};
|
package/dist/opencode-run.d.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface OpencodeRunOptions {
|
|
|
52
52
|
sessionId?: string;
|
|
53
53
|
/** Hard deadline. The child is killed when it elapses. */
|
|
54
54
|
timeoutMs?: number;
|
|
55
|
-
/** Extra environment for the child, merged over the parent
|
|
55
|
+
/** Extra environment for the child, merged over the allow-listed parent environment. */
|
|
56
56
|
env?: NodeJS.ProcessEnv;
|
|
57
57
|
/** Binary to run. Overridable for tests and for a pinned install. */
|
|
58
58
|
bin?: string;
|
|
@@ -78,6 +78,13 @@ export declare function createLineStreamer(onLine: (line: string) => void): {
|
|
|
78
78
|
push: (chunk: string) => void;
|
|
79
79
|
flush: () => void;
|
|
80
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* The environment the opencode child gets: the allow list above from `parent`,
|
|
83
|
+
* then `extra` on top. `extra` is the per-dispatch grant from
|
|
84
|
+
* `dispatchChildEnv`, which is the only way a `GIBSON_CALLBACK_*` name reaches
|
|
85
|
+
* the child.
|
|
86
|
+
*/
|
|
87
|
+
export declare function opencodeChildEnv(parent: NodeJS.ProcessEnv, extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
81
88
|
/** Build the argv for a headless run. Exported so a test can assert it. */
|
|
82
89
|
export declare function opencodeArgs(opts: OpencodeRunOptions): string[];
|
|
83
90
|
/**
|
package/dist/opencode-run.js
CHANGED
|
@@ -80,6 +80,81 @@ export function createLineStreamer(onLine) {
|
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Environment names and prefixes the opencode child may see. Everything else
|
|
85
|
+
* is dropped, so the launcher's component grant (`GIBSON_CG_JWT`), the
|
|
86
|
+
* one-time enrollment token (`GIBSON_BOOTSTRAP_TOKEN`) and the launch task
|
|
87
|
+
* (`GIBSON_AGENT_TASK_B64`) never reach a process that runs with `--auto`
|
|
88
|
+
* and has a shell. `knowledge-source.ts` states the child contract: it reads
|
|
89
|
+
* `GIBSON_CALLBACK_ENDPOINT` and `GIBSON_CALLBACK_TOKEN`, which the caller
|
|
90
|
+
* passes in `opts.env`, and nothing of the launch environment.
|
|
91
|
+
*
|
|
92
|
+
* `GIBSON_` names are listed one by one and never by prefix: the grant and
|
|
93
|
+
* the token share that prefix with the addressing the child plugin reads.
|
|
94
|
+
* The provider credential passes through because opencode itself reads it
|
|
95
|
+
* for a model that is not served through the Gibson shim, the same posture
|
|
96
|
+
* `claude-member/src/env.ts` takes for Claude Code.
|
|
97
|
+
*/
|
|
98
|
+
const CHILD_ENV_ALLOW_EXACT = [
|
|
99
|
+
"PATH",
|
|
100
|
+
"HOME",
|
|
101
|
+
"USER",
|
|
102
|
+
"SHELL",
|
|
103
|
+
"TZ",
|
|
104
|
+
"TERM",
|
|
105
|
+
"TMPDIR",
|
|
106
|
+
"NO_PROXY",
|
|
107
|
+
"no_proxy",
|
|
108
|
+
"HTTP_PROXY",
|
|
109
|
+
"http_proxy",
|
|
110
|
+
"HTTPS_PROXY",
|
|
111
|
+
"https_proxy",
|
|
112
|
+
"CLOUD_ML_REGION",
|
|
113
|
+
// Addressing the child plugin reads (index.ts, mcp-config.ts). No secret.
|
|
114
|
+
"GIBSON_PLATFORM_URL",
|
|
115
|
+
"GIBSON_DAEMON_URL",
|
|
116
|
+
"GIBSON_HOST_KEY_PATH",
|
|
117
|
+
"GIBSON_AGENT_MODE",
|
|
118
|
+
"GIBSON_SHIM_PORT",
|
|
119
|
+
"GIBSON_TARGET_ID",
|
|
120
|
+
"GIBSON_CA_CERT",
|
|
121
|
+
"GIBSON_OPENCODE_SESSION_ID",
|
|
122
|
+
];
|
|
123
|
+
const CHILD_ENV_ALLOW_PREFIX = [
|
|
124
|
+
"LANG",
|
|
125
|
+
"LC_",
|
|
126
|
+
"XDG_",
|
|
127
|
+
"NODE_",
|
|
128
|
+
"SSL_CERT_",
|
|
129
|
+
"OPENCODE_",
|
|
130
|
+
"ZEROCOOL_",
|
|
131
|
+
"ANTHROPIC_",
|
|
132
|
+
"OPENAI_",
|
|
133
|
+
"AWS_",
|
|
134
|
+
"GOOGLE_",
|
|
135
|
+
"AZURE_",
|
|
136
|
+
];
|
|
137
|
+
/**
|
|
138
|
+
* The environment the opencode child gets: the allow list above from `parent`,
|
|
139
|
+
* then `extra` on top. `extra` is the per-dispatch grant from
|
|
140
|
+
* `dispatchChildEnv`, which is the only way a `GIBSON_CALLBACK_*` name reaches
|
|
141
|
+
* the child.
|
|
142
|
+
*/
|
|
143
|
+
export function opencodeChildEnv(parent, extra = {}) {
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const [k, v] of Object.entries(parent)) {
|
|
146
|
+
if (v === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
if (!CHILD_ENV_ALLOW_EXACT.includes(k) && !CHILD_ENV_ALLOW_PREFIX.some((p) => k.startsWith(p)))
|
|
149
|
+
continue;
|
|
150
|
+
out[k] = v;
|
|
151
|
+
}
|
|
152
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
153
|
+
if (v !== undefined)
|
|
154
|
+
out[k] = v;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
83
158
|
/** Build the argv for a headless run. Exported so a test can assert it. */
|
|
84
159
|
export function opencodeArgs(opts) {
|
|
85
160
|
const args = ["run", "--format", "json", "--dir", opts.dir];
|
|
@@ -109,7 +184,7 @@ export async function runOpencode(opts) {
|
|
|
109
184
|
return await new Promise((resolve, reject) => {
|
|
110
185
|
const child = spawn(bin, args, {
|
|
111
186
|
cwd: opts.dir,
|
|
112
|
-
env:
|
|
187
|
+
env: opencodeChildEnv(process.env, opts.env),
|
|
113
188
|
stdio: ["ignore", "pipe", "pipe"],
|
|
114
189
|
...(opts.timeoutMs && opts.timeoutMs > 0 ? { timeout: opts.timeoutMs, killSignal: "SIGTERM" } : {}),
|
|
115
190
|
});
|
package/dist/serve-agent.js
CHANGED
|
@@ -57,11 +57,10 @@ export async function agentHandler(item, opts) {
|
|
|
57
57
|
// pass it back — see the ADR on why this is not yet automatic.
|
|
58
58
|
sessionId: item.context.opencode_session_id,
|
|
59
59
|
timeoutMs: item.timeoutMs,
|
|
60
|
-
// The task-scoped callback seam.
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// environment however it was launched.
|
|
60
|
+
// The task-scoped callback seam. The platform mints the grant per dispatch,
|
|
61
|
+
// so a dispatched run reaches the harness as the TASK rather than as this
|
|
62
|
+
// component. The same passthrough the sandboxed shape uses (`dispatch.ts`),
|
|
63
|
+
// so the child sees one environment however it was launched.
|
|
65
64
|
env: dispatchChildEnv({
|
|
66
65
|
callbackEndpoint: item.callbackEndpoint,
|
|
67
66
|
callbackToken: item.callbackToken,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroroot-ai/zerocool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -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.
|
|
26
|
+
"@zeroroot-ai/sdk": "^0.13.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"@opencode-ai/plugin": "*"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@opencode-ai/plugin": "^1.18.
|
|
32
|
+
"@opencode-ai/plugin": "^1.18.29"
|
|
33
33
|
},
|
|
34
34
|
"bin": {
|
|
35
35
|
"zerocool-serve": "dist/serve.js",
|