@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.
- package/dist/dispatch.d.ts +116 -1
- package/dist/dispatch.js +319 -3
- package/dist/findings.d.ts +9 -1
- package/dist/findings.js +36 -0
- package/dist/gitlab.d.ts +74 -0
- package/dist/gitlab.js +82 -0
- package/dist/knowledge-source.js +2 -2
- package/dist/semgrep.d.ts +76 -0
- package/dist/semgrep.js +111 -0
- package/dist/source-analysis.d.ts +134 -0
- package/dist/source-analysis.js +251 -0
- package/dist/watch.d.ts +214 -0
- package/dist/watch.js +341 -0
- package/package.json +4 -3
- package/semgrep/rules.yaml +178 -0
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type TaskHarness } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { type GitLabClient, type Pipeline } from "./gitlab.js";
|
|
3
|
+
/**
|
|
4
|
+
* The always-on watch loop (zerocool-plugins#88).
|
|
5
|
+
*
|
|
6
|
+
* Dispatched into a `watch-<application>` mission, the agent does not run a
|
|
7
|
+
* task and exit. It polls the application's GitLab project every 30 seconds,
|
|
8
|
+
* and on each finished pipeline on `main` it has not seen it originates a Scan
|
|
9
|
+
* mission, waits for it, and goes back to polling. The loop ends when the
|
|
10
|
+
* mission is cancelled or the sandbox is torn down, not on its own.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
|
|
13
|
+
* originate a mission only from inside one it was dispatched to. A GitLab job
|
|
14
|
+
* is not inside a mission, so it cannot originate a Scan mission whatever
|
|
15
|
+
* credential it holds. The person originates the long-lived watch mission once;
|
|
16
|
+
* every Scan mission after that is originated by the agent from inside it,
|
|
17
|
+
* through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
|
|
18
|
+
* gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
|
|
19
|
+
* harness rather than the request, so the child is attributed to this mission
|
|
20
|
+
* and a caller outside a live mission is refused outright.
|
|
21
|
+
*
|
|
22
|
+
* ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
|
|
23
|
+
* most recent successful pipeline on the branch — never for a backlog. That is
|
|
24
|
+
* what makes the checkpoint safe to lose: an unreadable checkpoint costs one
|
|
25
|
+
* redundant Scan of the current head, never a stampede over a project's
|
|
26
|
+
* history. A checkpoint is an optimisation against duplicate work, not a
|
|
27
|
+
* correctness gate, and the loop is written so that is true.
|
|
28
|
+
*
|
|
29
|
+
* THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
|
|
30
|
+
* anything written to its disk dies with it. The last pipeline seen goes into
|
|
31
|
+
* the World as a memory observation under the dispatch grant, which is the same
|
|
32
|
+
* write path every other thing the agent learns takes.
|
|
33
|
+
*/
|
|
34
|
+
/** The observation `kind` the checkpoint is written under. */
|
|
35
|
+
export declare const CHECKPOINT_KIND = "watch-checkpoint";
|
|
36
|
+
/** Default poll interval. The issue's cadence, and slow enough for GitLab. */
|
|
37
|
+
export declare const DEFAULT_INTERVAL_MS = 30000;
|
|
38
|
+
/** Default ceiling on one Scan mission before the loop stops waiting on it. */
|
|
39
|
+
export declare const DEFAULT_SCAN_TIMEOUT_MS: number;
|
|
40
|
+
/** The branch a watch follows unless the task says otherwise. */
|
|
41
|
+
export declare const DEFAULT_REF = "main";
|
|
42
|
+
/**
|
|
43
|
+
* Poll state, kept in the World so a restarted agent resumes.
|
|
44
|
+
*
|
|
45
|
+
* `last` returning 0 means "nothing recorded, or the record could not be read".
|
|
46
|
+
* Both are the same instruction to the loop — scan the current head once — so
|
|
47
|
+
* there is nothing here for a caller to distinguish or get wrong.
|
|
48
|
+
*/
|
|
49
|
+
export interface WatchCheckpoints {
|
|
50
|
+
last(): Promise<number>;
|
|
51
|
+
record(p: Pipeline): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** Originates and awaits one Scan mission. */
|
|
54
|
+
export interface ScanLauncher {
|
|
55
|
+
/** Create and run the Scan mission for `p`. Returns its mission id. */
|
|
56
|
+
launch(p: Pipeline): Promise<string>;
|
|
57
|
+
/** Block until that mission is terminal. Returns its status, for the console. */
|
|
58
|
+
wait(missionId: string): Promise<string>;
|
|
59
|
+
}
|
|
60
|
+
/** One line of the console stream. Rendered as NDJSON by the dispatch driver. */
|
|
61
|
+
export type WatchEvent = {
|
|
62
|
+
type: "watch.start";
|
|
63
|
+
application: string;
|
|
64
|
+
ref: string;
|
|
65
|
+
lastPipelineId: number;
|
|
66
|
+
} | {
|
|
67
|
+
type: "watch.poll";
|
|
68
|
+
application: string;
|
|
69
|
+
ref: string;
|
|
70
|
+
pipelineId: number;
|
|
71
|
+
status: string;
|
|
72
|
+
fresh: boolean;
|
|
73
|
+
} | {
|
|
74
|
+
type: "watch.scan";
|
|
75
|
+
application: string;
|
|
76
|
+
pipelineId: number;
|
|
77
|
+
commit: string;
|
|
78
|
+
missionId: string;
|
|
79
|
+
} | {
|
|
80
|
+
type: "watch.scan.done";
|
|
81
|
+
application: string;
|
|
82
|
+
pipelineId: number;
|
|
83
|
+
missionId: string;
|
|
84
|
+
status: string;
|
|
85
|
+
} | {
|
|
86
|
+
type: "watch.error";
|
|
87
|
+
application: string;
|
|
88
|
+
stage: "checkpoint" | "poll" | "scan" | "record";
|
|
89
|
+
message: string;
|
|
90
|
+
};
|
|
91
|
+
/** What one watch run reports back when it is asked to stop. */
|
|
92
|
+
export interface WatchSummary {
|
|
93
|
+
polls: number;
|
|
94
|
+
scans: number;
|
|
95
|
+
errors: number;
|
|
96
|
+
lastPipelineId: number;
|
|
97
|
+
}
|
|
98
|
+
export interface WatchLoopOptions {
|
|
99
|
+
/** The Application this watch follows, e.g. `customer-portal`. */
|
|
100
|
+
application: string;
|
|
101
|
+
/** The branch whose pipelines trigger a Scan. */
|
|
102
|
+
ref?: string;
|
|
103
|
+
gitlab: GitLabClient;
|
|
104
|
+
checkpoints: WatchCheckpoints;
|
|
105
|
+
scans: ScanLauncher;
|
|
106
|
+
/** Poll cadence. Defaults to {@link DEFAULT_INTERVAL_MS}. */
|
|
107
|
+
intervalMs?: number;
|
|
108
|
+
/** Stop after this many polls. Unset runs until aborted — the real posture. */
|
|
109
|
+
maxPolls?: number;
|
|
110
|
+
/** Cooperative stop, so a caller can end the loop between polls. */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Console stream. Every poll emits one line so the ops wall shows life. */
|
|
113
|
+
onEvent?: (e: WatchEvent) => void;
|
|
114
|
+
/** Test seams. */
|
|
115
|
+
sleep?: (ms: number) => Promise<void>;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run the watch loop.
|
|
119
|
+
*
|
|
120
|
+
* Nothing in here throws for an expected failure. A GitLab outage, a refused
|
|
121
|
+
* Scan, a checkpoint that cannot be written — each is reported to the console
|
|
122
|
+
* and the loop keeps polling, because an always-on agent that exits on the
|
|
123
|
+
* first transient error is not always-on. The one thing it will not do is
|
|
124
|
+
* record a checkpoint for a Scan it failed to originate: that pipeline is
|
|
125
|
+
* retried on the next poll.
|
|
126
|
+
*/
|
|
127
|
+
export declare function runWatchLoop(opts: WatchLoopOptions): Promise<WatchSummary>;
|
|
128
|
+
/**
|
|
129
|
+
* The checkpoint fact, in one parseable line.
|
|
130
|
+
*
|
|
131
|
+
* A memory is free text by design, so the loop writes a line it can read back
|
|
132
|
+
* without depending on which properties the projector chose to keep. The
|
|
133
|
+
* application is part of the line, so one tenant's several watches never read
|
|
134
|
+
* each other's checkpoints.
|
|
135
|
+
*/
|
|
136
|
+
export declare function checkpointText(application: string, p: Pipeline): string;
|
|
137
|
+
/** The checkpoint a line carries, or `undefined` when it is not one. */
|
|
138
|
+
export declare function parseCheckpoint(text: string): {
|
|
139
|
+
application: string;
|
|
140
|
+
pipelineId: number;
|
|
141
|
+
commit: string;
|
|
142
|
+
} | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Checkpoints kept in the tenant World under the dispatch grant.
|
|
145
|
+
*
|
|
146
|
+
* The write is `Observe` with a memory shape, which the Taxonomy gate lands as
|
|
147
|
+
* an `Observation` node. The read is the graph query for those observations.
|
|
148
|
+
*
|
|
149
|
+
* DAEMON STATE, and why the loop is built to survive it: the SDK records that
|
|
150
|
+
* `QueryNodes` is declared but not wired at the daemon's registration site and
|
|
151
|
+
* answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
|
|
152
|
+
* wired, `last` reports 0 and the loop scans the current head once per restart
|
|
153
|
+
* rather than resuming — which is the same behaviour as a first run, and is why
|
|
154
|
+
* the loop never reads a backlog.
|
|
155
|
+
*/
|
|
156
|
+
export declare function worldCheckpoints(harness: TaskHarness, application: string): WatchCheckpoints;
|
|
157
|
+
/** The recall text for an application's checkpoints. Exported so a test names it once. */
|
|
158
|
+
export declare function checkpointQueryText(application: string): string;
|
|
159
|
+
/** What a Scan mission needs to know about the pipeline that triggered it. */
|
|
160
|
+
export interface ScanMissionInputs {
|
|
161
|
+
application: string;
|
|
162
|
+
pipeline: Pipeline;
|
|
163
|
+
/** The project's clone url, so the Scan mission knows what to check out. */
|
|
164
|
+
repositoryUrl: string;
|
|
165
|
+
/** The image the pipeline built, when the watch was told how to name it. */
|
|
166
|
+
imageRef?: string;
|
|
167
|
+
/** The registered target the runtime scans are allowed to reach. */
|
|
168
|
+
targetId?: string;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The Scan mission definition, as canonical proto JSON.
|
|
172
|
+
*
|
|
173
|
+
* A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
|
|
174
|
+
* Trivy on the image, source analysis on the commit, and the runtime tools
|
|
175
|
+
* against the target, fanned out and joined. Until it lands, this originates
|
|
176
|
+
* the one node that exists today: zerocool's source-analysis task
|
|
177
|
+
* (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
|
|
178
|
+
* needs is already carried in the node's task context, so gibson#1660 replaces
|
|
179
|
+
* the body of this one function and nothing else in the loop changes.
|
|
180
|
+
*
|
|
181
|
+
* The definition is built as protojson by hand rather than through the
|
|
182
|
+
* generated schema because the SDK's package exports only its index, and the
|
|
183
|
+
* mission schema is not re-exported from it.
|
|
184
|
+
*/
|
|
185
|
+
export declare function scanMissionDefinition(inputs: ScanMissionInputs): unknown;
|
|
186
|
+
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
187
|
+
export declare function scanMissionName(inputs: ScanMissionInputs): string;
|
|
188
|
+
/**
|
|
189
|
+
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
190
|
+
* protojson. Every value the loop passes is a string, so every entry is a
|
|
191
|
+
* `stringValue`.
|
|
192
|
+
*/
|
|
193
|
+
export declare function typedContext(ctx: Record<string, string>): Record<string, {
|
|
194
|
+
stringValue: string;
|
|
195
|
+
}>;
|
|
196
|
+
export interface HarnessScanLauncherOptions {
|
|
197
|
+
/** Everything but the pipeline, which the launcher fills in per trigger. */
|
|
198
|
+
inputs: Omit<ScanMissionInputs, "pipeline">;
|
|
199
|
+
/** The target the child mission binds to. Its scope for every observation. */
|
|
200
|
+
targetId: string;
|
|
201
|
+
/** Ceiling on one Scan. Defaults to {@link DEFAULT_SCAN_TIMEOUT_MS}. */
|
|
202
|
+
scanTimeoutMs?: number;
|
|
203
|
+
/** Override the definition, so gibson#1660 can supply the real one. */
|
|
204
|
+
definition?: (inputs: ScanMissionInputs) => unknown;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
208
|
+
*
|
|
209
|
+
* `CreateMission` here is the in-mission origination ADR-0063 permits: the
|
|
210
|
+
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
211
|
+
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
212
|
+
* parent, a tenant or an originator, so nothing here can widen them.
|
|
213
|
+
*/
|
|
214
|
+
export declare function harnessScanLauncher(harness: TaskHarness, opts: HarnessScanLauncherOptions): ScanLauncher;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { remember, taskKnowledge } from "@zeroroot-ai/sdk";
|
|
2
|
+
import { TRIGGER_STATUS } from "./gitlab.js";
|
|
3
|
+
/**
|
|
4
|
+
* The always-on watch loop (zerocool-plugins#88).
|
|
5
|
+
*
|
|
6
|
+
* Dispatched into a `watch-<application>` mission, the agent does not run a
|
|
7
|
+
* task and exit. It polls the application's GitLab project every 30 seconds,
|
|
8
|
+
* and on each finished pipeline on `main` it has not seen it originates a Scan
|
|
9
|
+
* mission, waits for it, and goes back to polling. The loop ends when the
|
|
10
|
+
* mission is cancelled or the sandbox is torn down, not on its own.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
|
|
13
|
+
* originate a mission only from inside one it was dispatched to. A GitLab job
|
|
14
|
+
* is not inside a mission, so it cannot originate a Scan mission whatever
|
|
15
|
+
* credential it holds. The person originates the long-lived watch mission once;
|
|
16
|
+
* every Scan mission after that is originated by the agent from inside it,
|
|
17
|
+
* through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
|
|
18
|
+
* gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
|
|
19
|
+
* harness rather than the request, so the child is attributed to this mission
|
|
20
|
+
* and a caller outside a live mission is refused outright.
|
|
21
|
+
*
|
|
22
|
+
* ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
|
|
23
|
+
* most recent successful pipeline on the branch — never for a backlog. That is
|
|
24
|
+
* what makes the checkpoint safe to lose: an unreadable checkpoint costs one
|
|
25
|
+
* redundant Scan of the current head, never a stampede over a project's
|
|
26
|
+
* history. A checkpoint is an optimisation against duplicate work, not a
|
|
27
|
+
* correctness gate, and the loop is written so that is true.
|
|
28
|
+
*
|
|
29
|
+
* THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
|
|
30
|
+
* anything written to its disk dies with it. The last pipeline seen goes into
|
|
31
|
+
* the World as a memory observation under the dispatch grant, which is the same
|
|
32
|
+
* write path every other thing the agent learns takes.
|
|
33
|
+
*/
|
|
34
|
+
/** The observation `kind` the checkpoint is written under. */
|
|
35
|
+
export const CHECKPOINT_KIND = "watch-checkpoint";
|
|
36
|
+
/** Default poll interval. The issue's cadence, and slow enough for GitLab. */
|
|
37
|
+
export const DEFAULT_INTERVAL_MS = 30_000;
|
|
38
|
+
/** Default ceiling on one Scan mission before the loop stops waiting on it. */
|
|
39
|
+
export const DEFAULT_SCAN_TIMEOUT_MS = 30 * 60_000;
|
|
40
|
+
/** The branch a watch follows unless the task says otherwise. */
|
|
41
|
+
export const DEFAULT_REF = "main";
|
|
42
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
43
|
+
/**
|
|
44
|
+
* Run the watch loop.
|
|
45
|
+
*
|
|
46
|
+
* Nothing in here throws for an expected failure. A GitLab outage, a refused
|
|
47
|
+
* Scan, a checkpoint that cannot be written — each is reported to the console
|
|
48
|
+
* and the loop keeps polling, because an always-on agent that exits on the
|
|
49
|
+
* first transient error is not always-on. The one thing it will not do is
|
|
50
|
+
* record a checkpoint for a Scan it failed to originate: that pipeline is
|
|
51
|
+
* retried on the next poll.
|
|
52
|
+
*/
|
|
53
|
+
export async function runWatchLoop(opts) {
|
|
54
|
+
const ref = opts.ref || DEFAULT_REF;
|
|
55
|
+
const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
56
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
57
|
+
const emit = (e) => opts.onEvent?.(e);
|
|
58
|
+
const summary = { polls: 0, scans: 0, errors: 0, lastPipelineId: 0 };
|
|
59
|
+
// A checkpoint that cannot be read is not fatal: the loop then treats the
|
|
60
|
+
// current head as fresh and scans it once. See the module note.
|
|
61
|
+
try {
|
|
62
|
+
summary.lastPipelineId = await opts.checkpoints.last();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
summary.errors++;
|
|
66
|
+
emit({ type: "watch.error", application: opts.application, stage: "checkpoint", message: message(e) });
|
|
67
|
+
}
|
|
68
|
+
emit({
|
|
69
|
+
type: "watch.start",
|
|
70
|
+
application: opts.application,
|
|
71
|
+
ref,
|
|
72
|
+
lastPipelineId: summary.lastPipelineId,
|
|
73
|
+
});
|
|
74
|
+
while (!opts.signal?.aborted && (opts.maxPolls === undefined || summary.polls < opts.maxPolls)) {
|
|
75
|
+
summary.polls++;
|
|
76
|
+
let pipeline;
|
|
77
|
+
try {
|
|
78
|
+
pipeline = await opts.gitlab.latestFinishedPipeline(ref);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
summary.errors++;
|
|
82
|
+
emit({ type: "watch.error", application: opts.application, stage: "poll", message: message(e) });
|
|
83
|
+
await pause();
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const fresh = !!pipeline && pipeline.id > summary.lastPipelineId;
|
|
87
|
+
emit({
|
|
88
|
+
type: "watch.poll",
|
|
89
|
+
application: opts.application,
|
|
90
|
+
ref,
|
|
91
|
+
pipelineId: pipeline?.id ?? 0,
|
|
92
|
+
status: pipeline?.status ?? TRIGGER_STATUS,
|
|
93
|
+
fresh,
|
|
94
|
+
});
|
|
95
|
+
if (!pipeline || !fresh) {
|
|
96
|
+
await pause();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// Originate, then record, then wait. Recording before the wait means a
|
|
100
|
+
// sandbox that dies mid-Scan does not re-originate the same Scan on
|
|
101
|
+
// restart; recording only after a successful launch means a refused launch
|
|
102
|
+
// is retried on the next poll instead of being silently skipped.
|
|
103
|
+
let missionId;
|
|
104
|
+
try {
|
|
105
|
+
missionId = await opts.scans.launch(pipeline);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
summary.errors++;
|
|
109
|
+
emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
|
|
110
|
+
await pause();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
summary.scans++;
|
|
114
|
+
emit({
|
|
115
|
+
type: "watch.scan",
|
|
116
|
+
application: opts.application,
|
|
117
|
+
pipelineId: pipeline.id,
|
|
118
|
+
commit: pipeline.sha,
|
|
119
|
+
missionId,
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
await opts.checkpoints.record(pipeline);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
summary.errors++;
|
|
126
|
+
emit({ type: "watch.error", application: opts.application, stage: "record", message: message(e) });
|
|
127
|
+
}
|
|
128
|
+
summary.lastPipelineId = pipeline.id;
|
|
129
|
+
try {
|
|
130
|
+
const status = await opts.scans.wait(missionId);
|
|
131
|
+
emit({
|
|
132
|
+
type: "watch.scan.done",
|
|
133
|
+
application: opts.application,
|
|
134
|
+
pipelineId: pipeline.id,
|
|
135
|
+
missionId,
|
|
136
|
+
status,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
summary.errors++;
|
|
141
|
+
emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
|
|
142
|
+
}
|
|
143
|
+
await pause();
|
|
144
|
+
}
|
|
145
|
+
return summary;
|
|
146
|
+
async function pause() {
|
|
147
|
+
if (opts.signal?.aborted)
|
|
148
|
+
return;
|
|
149
|
+
if (opts.maxPolls !== undefined && summary.polls >= opts.maxPolls)
|
|
150
|
+
return;
|
|
151
|
+
await sleep(interval);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function message(e) {
|
|
155
|
+
return e instanceof Error ? e.message : String(e);
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// The World-backed checkpoint
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* The checkpoint fact, in one parseable line.
|
|
162
|
+
*
|
|
163
|
+
* A memory is free text by design, so the loop writes a line it can read back
|
|
164
|
+
* without depending on which properties the projector chose to keep. The
|
|
165
|
+
* application is part of the line, so one tenant's several watches never read
|
|
166
|
+
* each other's checkpoints.
|
|
167
|
+
*/
|
|
168
|
+
export function checkpointText(application, p) {
|
|
169
|
+
return `${CHECKPOINT_KIND} application=${application} pipeline=${p.id} commit=${p.sha}`;
|
|
170
|
+
}
|
|
171
|
+
/** The checkpoint a line carries, or `undefined` when it is not one. */
|
|
172
|
+
export function parseCheckpoint(text) {
|
|
173
|
+
if (!text.startsWith(CHECKPOINT_KIND))
|
|
174
|
+
return undefined;
|
|
175
|
+
const application = /\bapplication=(\S+)/.exec(text)?.[1] ?? "";
|
|
176
|
+
const pipelineId = Number(/\bpipeline=(\d+)/.exec(text)?.[1] ?? Number.NaN);
|
|
177
|
+
const commit = /\bcommit=(\S+)/.exec(text)?.[1] ?? "";
|
|
178
|
+
if (!application || !Number.isInteger(pipelineId) || pipelineId <= 0)
|
|
179
|
+
return undefined;
|
|
180
|
+
return { application, pipelineId, commit };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Checkpoints kept in the tenant World under the dispatch grant.
|
|
184
|
+
*
|
|
185
|
+
* The write is `Observe` with a memory shape, which the Taxonomy gate lands as
|
|
186
|
+
* an `Observation` node. The read is the graph query for those observations.
|
|
187
|
+
*
|
|
188
|
+
* DAEMON STATE, and why the loop is built to survive it: the SDK records that
|
|
189
|
+
* `QueryNodes` is declared but not wired at the daemon's registration site and
|
|
190
|
+
* answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
|
|
191
|
+
* wired, `last` reports 0 and the loop scans the current head once per restart
|
|
192
|
+
* rather than resuming — which is the same behaviour as a first run, and is why
|
|
193
|
+
* the loop never reads a backlog.
|
|
194
|
+
*/
|
|
195
|
+
export function worldCheckpoints(harness, application) {
|
|
196
|
+
const knowledge = taskKnowledge(harness);
|
|
197
|
+
return {
|
|
198
|
+
async last() {
|
|
199
|
+
const hits = await knowledge.query({
|
|
200
|
+
text: checkpointQueryText(application),
|
|
201
|
+
nodeTypes: ["Observation"],
|
|
202
|
+
topK: 50,
|
|
203
|
+
});
|
|
204
|
+
let max = 0;
|
|
205
|
+
for (const hit of hits) {
|
|
206
|
+
const parsed = parseCheckpoint(String(hit.content ?? ""));
|
|
207
|
+
if (parsed && parsed.application === application && parsed.pipelineId > max) {
|
|
208
|
+
max = parsed.pipelineId;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return max;
|
|
212
|
+
},
|
|
213
|
+
record: (p) => remember(harness, {
|
|
214
|
+
text: checkpointText(application, p),
|
|
215
|
+
kind: CHECKPOINT_KIND,
|
|
216
|
+
tags: ["watch", `application:${application}`, `pipeline:${p.id}`],
|
|
217
|
+
sourceRef: p.webUrl,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/** The recall text for an application's checkpoints. Exported so a test names it once. */
|
|
222
|
+
export function checkpointQueryText(application) {
|
|
223
|
+
return `${CHECKPOINT_KIND} application=${application}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The Scan mission definition, as canonical proto JSON.
|
|
227
|
+
*
|
|
228
|
+
* A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
|
|
229
|
+
* Trivy on the image, source analysis on the commit, and the runtime tools
|
|
230
|
+
* against the target, fanned out and joined. Until it lands, this originates
|
|
231
|
+
* the one node that exists today: zerocool's source-analysis task
|
|
232
|
+
* (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
|
|
233
|
+
* needs is already carried in the node's task context, so gibson#1660 replaces
|
|
234
|
+
* the body of this one function and nothing else in the loop changes.
|
|
235
|
+
*
|
|
236
|
+
* The definition is built as protojson by hand rather than through the
|
|
237
|
+
* generated schema because the SDK's package exports only its index, and the
|
|
238
|
+
* mission schema is not re-exported from it.
|
|
239
|
+
*/
|
|
240
|
+
export function scanMissionDefinition(inputs) {
|
|
241
|
+
const { application, pipeline } = inputs;
|
|
242
|
+
const context = {
|
|
243
|
+
"zerocool.task": "source-analysis",
|
|
244
|
+
application,
|
|
245
|
+
"pipeline.id": String(pipeline.id),
|
|
246
|
+
"pipeline.url": pipeline.webUrl,
|
|
247
|
+
"repository.url": inputs.repositoryUrl,
|
|
248
|
+
"repository.ref": pipeline.ref,
|
|
249
|
+
"repository.commit": pipeline.sha,
|
|
250
|
+
...(inputs.imageRef ? { "image.ref": inputs.imageRef } : {}),
|
|
251
|
+
...(inputs.targetId ? { "target.id": inputs.targetId } : {}),
|
|
252
|
+
};
|
|
253
|
+
return {
|
|
254
|
+
name: scanMissionName(inputs),
|
|
255
|
+
description: `Scan ${application} at ${pipeline.sha.slice(0, 12)}, originated by the always-on agent ` +
|
|
256
|
+
`for pipeline ${pipeline.id}.`,
|
|
257
|
+
version: "1.0.0",
|
|
258
|
+
nodes: {
|
|
259
|
+
scan: {
|
|
260
|
+
id: "scan",
|
|
261
|
+
type: "NODE_TYPE_AGENT",
|
|
262
|
+
name: "scan",
|
|
263
|
+
description: "Source analysis of the commit the pipeline built.",
|
|
264
|
+
agentConfig: {
|
|
265
|
+
agentName: "zerocool",
|
|
266
|
+
task: {
|
|
267
|
+
id: "scan",
|
|
268
|
+
goal: `Scan the ${application} repository at commit ${pipeline.sha} and submit a Finding for ` +
|
|
269
|
+
"every real weakness you can support with evidence.",
|
|
270
|
+
context: typedContext(context),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
entryPoints: ["scan"],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** The child mission's name, which is also how a person finds it in the dashboard. */
|
|
279
|
+
export function scanMissionName(inputs) {
|
|
280
|
+
return `scan ${inputs.application} pipeline ${inputs.pipeline.id}`;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Render a string map as `map<string, gibson.common.v1.TypedValue>` in
|
|
284
|
+
* protojson. Every value the loop passes is a string, so every entry is a
|
|
285
|
+
* `stringValue`.
|
|
286
|
+
*/
|
|
287
|
+
export function typedContext(ctx) {
|
|
288
|
+
const out = {};
|
|
289
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
290
|
+
if (v !== "")
|
|
291
|
+
out[k] = { stringValue: v };
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
|
|
297
|
+
*
|
|
298
|
+
* `CreateMission` here is the in-mission origination ADR-0063 permits: the
|
|
299
|
+
* daemon resolves the parent from this harness, so the Scan mission is a child
|
|
300
|
+
* of the watch mission and inherits its tenant. Nothing in the request names a
|
|
301
|
+
* parent, a tenant or an originator, so nothing here can widen them.
|
|
302
|
+
*/
|
|
303
|
+
export function harnessScanLauncher(harness, opts) {
|
|
304
|
+
const build = opts.definition ?? scanMissionDefinition;
|
|
305
|
+
const timeoutMs = BigInt(opts.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS);
|
|
306
|
+
return {
|
|
307
|
+
async launch(pipeline) {
|
|
308
|
+
const inputs = { ...opts.inputs, pipeline, targetId: opts.targetId };
|
|
309
|
+
const created = await harness.client.createMission({
|
|
310
|
+
context: harness.context,
|
|
311
|
+
missionDefinitionJson: new TextEncoder().encode(JSON.stringify(build(inputs))),
|
|
312
|
+
targetId: opts.targetId,
|
|
313
|
+
name: scanMissionName(inputs),
|
|
314
|
+
});
|
|
315
|
+
if (created.error) {
|
|
316
|
+
throw new Error(`CreateMission refused: ${created.error.message}`);
|
|
317
|
+
}
|
|
318
|
+
const missionId = created.mission?.id ?? "";
|
|
319
|
+
if (!missionId) {
|
|
320
|
+
throw new Error("CreateMission returned no mission id");
|
|
321
|
+
}
|
|
322
|
+
const run = await harness.client.runMission({
|
|
323
|
+
context: harness.context,
|
|
324
|
+
missionId,
|
|
325
|
+
wait: false,
|
|
326
|
+
timeoutMs: 0n,
|
|
327
|
+
});
|
|
328
|
+
if (run.error) {
|
|
329
|
+
throw new Error(`RunMission refused for ${missionId}: ${run.error.message}`);
|
|
330
|
+
}
|
|
331
|
+
return missionId;
|
|
332
|
+
},
|
|
333
|
+
async wait(missionId) {
|
|
334
|
+
const res = await harness.client.waitForMission({ context: harness.context, missionId, timeoutMs });
|
|
335
|
+
if (res.error) {
|
|
336
|
+
throw new Error(`WaitForMission failed for ${missionId}: ${res.error.message}`);
|
|
337
|
+
}
|
|
338
|
+
return String(res.result?.status ?? "");
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroroot-ai/zerocool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"main": "dist/index.js",
|
|
15
15
|
"types": "dist/index.d.ts",
|
|
16
16
|
"files": [
|
|
17
|
-
"dist"
|
|
17
|
+
"dist",
|
|
18
|
+
"semgrep"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"build": "tsc -p tsconfig.json",
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
"test": "tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\""
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@zeroroot-ai/sdk": "^0.
|
|
26
|
+
"@zeroroot-ai/sdk": "^0.9.0"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"@opencode-ai/plugin": "*"
|