@kici-dev/agent 0.1.26 → 0.2.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/bootstrap/ensure-init-runner.d.ts +23 -22
- package/dist/bootstrap/payload-source.d.ts +32 -0
- package/dist/bootstrap/probe-platform.d.ts +21 -0
- package/dist/bootstrap/restage-agent.d.ts +43 -0
- package/dist/bootstrap/run-restage.d.ts +12 -0
- package/dist/bootstrap/s3-payload-source.d.ts +35 -0
- package/dist/bootstrap/ssh-exec.d.ts +14 -0
- package/dist/bootstrap/stage-agent-payload.d.ts +41 -0
- package/dist/checkout/changed-files.d.ts +34 -0
- package/dist/config.d.ts +42 -14
- package/dist/container-ts-loader-hook.js +147710 -0
- package/dist/execution/artifacts/artifact-engine.d.ts +51 -0
- package/dist/execution/dep-installer.d.ts +3 -3
- package/dist/execution/init-runner.d.ts +4 -4
- package/dist/execution/job-runner.d.ts +41 -6
- package/dist/execution/log-streamer.d.ts +17 -2
- package/dist/execution/rule-evaluator.d.ts +1 -12
- package/dist/execution/sandbox/container-hardening.d.ts +80 -0
- package/dist/execution/sandbox/container-sandbox.d.ts +54 -0
- package/dist/execution/sandbox/container-ts-loader-hook.d.ts +26 -0
- package/dist/execution/sandbox/env-sanitizer.d.ts +12 -3
- package/dist/execution/sandbox/fork-runner.d.ts +14 -0
- package/dist/execution/sandbox/index.d.ts +2 -1
- package/dist/execution/sandbox/ipc-protocol.d.ts +82 -6
- package/dist/execution/sandbox/step-loop.d.ts +4 -0
- package/dist/execution/sandbox/types.d.ts +25 -4
- package/dist/execution/sandbox/workflow-runner.d.ts +111 -5
- package/dist/execution/streaming-zx-log.d.ts +38 -0
- package/dist/execution/tmp-gc.d.ts +22 -7
- package/dist/execution/workflow-loader.d.ts +32 -1
- package/dist/index.js +50 -45
- package/dist/provenance/statement-builder.d.ts +3 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1360 -193
- package/dist/workflow-runner-bundle.js +215393 -0
- package/dist/workflow-runner.js +937 -256
- package/dist/ws/orchestrator-client.d.ts +105 -1
- package/package.json +14 -12
- package/sbom.spdx.json +1092 -1723
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ArtifactsApi } from '@kici-dev/sdk';
|
|
2
|
+
import type { ArtifactRejectReason, ArtifactUploadOutcome, ArtifactDownloadOutcome } from '@kici-dev/engine';
|
|
3
|
+
import { type CacheRoots } from '../cache/cache-engine.js';
|
|
4
|
+
/** Outcome of a begin-upload grant request. */
|
|
5
|
+
export interface ArtifactBeginUploadResult {
|
|
6
|
+
outcome: ArtifactUploadOutcome;
|
|
7
|
+
/** Presigned PUT URL — present only on `granted`. */
|
|
8
|
+
uploadUrl?: string;
|
|
9
|
+
/** Storage key echoed back on complete — present only on `granted`. */
|
|
10
|
+
storageKey?: string;
|
|
11
|
+
/** Enforcement-gate rejection reason — present only on an enforcement `rejected`. */
|
|
12
|
+
reason?: ArtifactRejectReason;
|
|
13
|
+
/**
|
|
14
|
+
* Non-enforcement refusal detail — present only on `rejected` when no
|
|
15
|
+
* enforcement `reason` applies (a name that violates the artifact-name
|
|
16
|
+
* contract, orchestrator misconfiguration, or an internal error). A safe,
|
|
17
|
+
* fixed string from the orchestrator; never a raw exception.
|
|
18
|
+
*/
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Outcome of a download lookup. */
|
|
22
|
+
export interface ArtifactDownloadLookup {
|
|
23
|
+
outcome: ArtifactDownloadOutcome;
|
|
24
|
+
/** Presigned GET URL — present only on `found`. */
|
|
25
|
+
downloadUrl?: string;
|
|
26
|
+
/** Artifact size in bytes — present only on `found`. */
|
|
27
|
+
sizeBytes?: number;
|
|
28
|
+
/** SHA-256 (hex) of the tarball bytes — present only on `found`. */
|
|
29
|
+
sha256?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Internal-failure detail — present only on `not_found` when the outcome
|
|
32
|
+
* reflects an orchestrator failure rather than a genuinely missing artifact. A
|
|
33
|
+
* safe, fixed string from the orchestrator; never a raw exception.
|
|
34
|
+
*/
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Transport the artifacts engine uses to reach the orchestrator over IPC -> WS.
|
|
39
|
+
* Backed by the agent's request/response relay (mirrors {@link CacheTransport}).
|
|
40
|
+
*/
|
|
41
|
+
export interface ArtifactTransport {
|
|
42
|
+
/** Request a presigned PUT for `name` at `declaredSizeBytes`; enforced before minting. */
|
|
43
|
+
beginUpload(name: string, declaredSizeBytes: number): Promise<ArtifactBeginUploadResult>;
|
|
44
|
+
/** Confirm the upload finished — records the DB row. */
|
|
45
|
+
completeUpload(name: string, sizeBytes: number, sha256: string, storageKey: string): Promise<void>;
|
|
46
|
+
/** Resolve a named artifact of this run to a presigned GET + its size/sha256. */
|
|
47
|
+
download(name: string): Promise<ArtifactDownloadLookup>;
|
|
48
|
+
}
|
|
49
|
+
/** Build the imperative `ctx.artifacts` API bound to a workDir + transport. */
|
|
50
|
+
export declare function createArtifactsApi(workDir: string, transport: ArtifactTransport, roots?: CacheRoots): ArtifactsApi;
|
|
51
|
+
//# sourceMappingURL=artifact-engine.d.ts.map
|
|
@@ -41,9 +41,9 @@ export interface InstallDepsOptions {
|
|
|
41
41
|
* Install `.kici/` dependencies inline with the repo's package manager.
|
|
42
42
|
*
|
|
43
43
|
* Falls back to this when the dep cache is unavailable or a download fails.
|
|
44
|
-
* The install runs with an isolated cache/store directory (
|
|
45
|
-
* `
|
|
46
|
-
* is removed after installation.
|
|
44
|
+
* The install runs with an isolated cache/store directory (allocated under the
|
|
45
|
+
* global temp base, which honors `KICI_TMPDIR`) to prevent cache poisoning
|
|
46
|
+
* between build jobs; the directory is removed after installation.
|
|
47
47
|
*
|
|
48
48
|
* If `opts.npmRegistries` / `opts.installEnvSecrets` is provided, a job-scoped
|
|
49
49
|
* `.kici/.npmrc` overlay is synthesized for the install, restored in `finally`,
|
|
@@ -5,8 +5,8 @@ import { type MatrixValues } from '@kici-dev/engine';
|
|
|
5
5
|
* Only fields that were flagged as dynamic and successfully resolved are set.
|
|
6
6
|
*/
|
|
7
7
|
export interface InitResult {
|
|
8
|
-
/** Resolved bound-
|
|
9
|
-
|
|
8
|
+
/** Resolved bound-context names, in merge order (one per `contexts` element). */
|
|
9
|
+
contextNames?: string[];
|
|
10
10
|
env?: Record<string, string>;
|
|
11
11
|
concurrencyGroup?: string;
|
|
12
12
|
/**
|
|
@@ -16,7 +16,7 @@ export interface InitResult {
|
|
|
16
16
|
matrixValues?: MatrixValues[];
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Evaluate dynamic fields (
|
|
19
|
+
* Evaluate dynamic fields (context, env, concurrencyGroup) on a job.
|
|
20
20
|
*
|
|
21
21
|
* Only fields with their corresponding flag set to true AND whose property
|
|
22
22
|
* on the job is a function will be evaluated. All evaluations happen in a
|
|
@@ -33,7 +33,7 @@ export interface InitResult {
|
|
|
33
33
|
* @param timeoutMs - Timeout per dynamic function call (default 60_000ms)
|
|
34
34
|
*/
|
|
35
35
|
export declare function evaluateDynamicFields(workflow: Workflow, jobName: string, event: Record<string, unknown>, flags: {
|
|
36
|
-
|
|
36
|
+
dynamicContext: boolean;
|
|
37
37
|
dynamicEnv: boolean;
|
|
38
38
|
dynamicConcurrencyGroup: boolean;
|
|
39
39
|
dynamicMatrix?: boolean;
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import type { AgentToOrchestratorMessage, JobDispatch } from '@kici-dev/engine';
|
|
2
2
|
import type { AppConfig } from '../config.js';
|
|
3
3
|
import { buildNeedsContext } from '@kici-dev/sdk';
|
|
4
|
-
import type { CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc } from './sandbox/index.js';
|
|
4
|
+
import type { CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, ArtifactRequestIpc, ArtifactResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc } from './sandbox/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Dependencies injected into JobRunner.
|
|
7
7
|
*/
|
|
8
8
|
export interface JobRunnerDeps {
|
|
9
|
-
/** Send function for WS messages (buffered) */
|
|
9
|
+
/** Send function for WS messages (buffered; replayed on reconnect) */
|
|
10
10
|
send: (msg: AgentToOrchestratorMessage) => void;
|
|
11
|
-
/** Send direct function (bypasses buffer, for protocol messages) */
|
|
12
|
-
sendDirect: (msg: AgentToOrchestratorMessage) => void;
|
|
13
11
|
/** Agent config */
|
|
14
12
|
config: AppConfig;
|
|
15
13
|
/** Request a pre-signed S3 upload URL from the orchestrator via WS request-response. */
|
|
@@ -99,6 +97,14 @@ export interface JobRunnerDeps {
|
|
|
99
97
|
* compatibility (callers that don't support provenance).
|
|
100
98
|
*/
|
|
101
99
|
relayProvenance?: (jobId: string, request: ProvenanceRequestIpc) => Promise<ProvenanceResponseIpc>;
|
|
100
|
+
/**
|
|
101
|
+
* Relay a user-facing artifact request to the orchestrator and await the
|
|
102
|
+
* response. Translates the sandbox `artifacts.request` IPC into the matching
|
|
103
|
+
* `artifacts.upload.request` / `.complete` / `artifacts.download.request` WS
|
|
104
|
+
* message and returns the orchestrator's response mapped back onto the IPC
|
|
105
|
+
* response shape. Optional for backward compatibility.
|
|
106
|
+
*/
|
|
107
|
+
requestUserArtifact?: (jobId: string, request: ArtifactRequestIpc) => Promise<ArtifactResponseIpc>;
|
|
102
108
|
/**
|
|
103
109
|
* Relay a step-level approval request to the orchestrator and await the
|
|
104
110
|
* resolution. Translates the sandbox `approval.request` IPC into a
|
|
@@ -113,6 +119,17 @@ interface ActiveJob {
|
|
|
113
119
|
completionPromise: Promise<void>;
|
|
114
120
|
runId: string;
|
|
115
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Derive the self-contained runner bundle path from the resolved runner path.
|
|
124
|
+
*
|
|
125
|
+
* The container backend mounts the runner as a single file into the customer
|
|
126
|
+
* job container, so it must run `workflow-runner-bundle.js` (zx + `@kici-dev/*`
|
|
127
|
+
* inlined) rather than the external `workflow-runner.js`, which cannot resolve
|
|
128
|
+
* its bare imports without the agent's node_modules / pnpm workspace. The
|
|
129
|
+
* bundle is a flat sibling emitted alongside the runner by build-service.mjs.
|
|
130
|
+
* bwrap / firecracker keep the external runner (they bind the workspace).
|
|
131
|
+
*/
|
|
132
|
+
export declare function resolveRunnerBundlePath(runnerPath: string): string;
|
|
116
133
|
/**
|
|
117
134
|
* Build the result-aware `ctx.needs` for a dynamic eval from its frozen upstream
|
|
118
135
|
* snapshot. Returns undefined for an event-only generator (no snapshot).
|
|
@@ -122,6 +139,23 @@ export declare function buildEvalNeedsContext(config: {
|
|
|
122
139
|
declaredNeeds?: readonly unknown[];
|
|
123
140
|
upstreamSnapshot?: import('@kici-dev/engine').UpstreamSnapshot;
|
|
124
141
|
}): ReturnType<typeof buildNeedsContext> | undefined;
|
|
142
|
+
/**
|
|
143
|
+
* Resolve a job's workDir and its cleanup.
|
|
144
|
+
*
|
|
145
|
+
* - Default: a fresh `mkdtemp` the agent clones into and removes after the job.
|
|
146
|
+
* - **In-place profile** (`inPlace` config + a `file://` source): the source's
|
|
147
|
+
* real repo path used directly as the workDir, with **no clone** and **no
|
|
148
|
+
* removal**. This is the routed `deploy:stg` profile — the operator runs their
|
|
149
|
+
* own already-built working tree (module-relative `MONOREPO_ROOT`,
|
|
150
|
+
* `node_modules`, `dist` all present). Gated to `file://` so a
|
|
151
|
+
* Platform-connected agent (https sources) can never be pushed onto a tree,
|
|
152
|
+
* and read only from agent config (never a dispatch/wire value).
|
|
153
|
+
*/
|
|
154
|
+
export declare function resolveJobWorkDir(inPlace: boolean, repoUrl: string | undefined): Promise<{
|
|
155
|
+
workDir: string;
|
|
156
|
+
cleanup: () => Promise<void>;
|
|
157
|
+
inPlace: boolean;
|
|
158
|
+
}>;
|
|
125
159
|
/**
|
|
126
160
|
* Top-level job execution orchestrator for the agent.
|
|
127
161
|
*
|
|
@@ -138,7 +172,6 @@ export declare function buildEvalNeedsContext(config: {
|
|
|
138
172
|
*/
|
|
139
173
|
export declare class JobRunner {
|
|
140
174
|
private readonly send;
|
|
141
|
-
private readonly sendDirect;
|
|
142
175
|
private readonly config;
|
|
143
176
|
private readonly requestUploadUrl;
|
|
144
177
|
private readonly sendUploadComplete;
|
|
@@ -151,6 +184,7 @@ export declare class JobRunner {
|
|
|
151
184
|
private readonly _sendApiRequest?;
|
|
152
185
|
private readonly _requestUserCache?;
|
|
153
186
|
private readonly _relayProvenance?;
|
|
187
|
+
private readonly _requestUserArtifact?;
|
|
154
188
|
private readonly _sendStepApproval?;
|
|
155
189
|
/** Tracks running jobs for concurrency and cancellation */
|
|
156
190
|
readonly activeJobs: Map<string, ActiveJob>;
|
|
@@ -205,7 +239,8 @@ export declare class JobRunner {
|
|
|
205
239
|
/**
|
|
206
240
|
* Drive `sandbox.executeJob` with IPC callbacks wired to the WS pipeline.
|
|
207
241
|
*
|
|
208
|
-
* Lazily
|
|
242
|
+
* Lazily populates `logStreamers` (owned by the caller so a throw still
|
|
243
|
+
* leaves the buffered output flushable), forwards step + log + event-emit +
|
|
209
244
|
* concurrency-report + api-request messages, and emits the
|
|
210
245
|
* `agent.execution.start` / `agent.execution.end` lifecycle events.
|
|
211
246
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { LogStream, type AgentLogChunk } from '@kici-dev/engine';
|
|
2
2
|
interface LogStreamerOptions {
|
|
3
3
|
/** Callback to send log.chunk messages */
|
|
4
4
|
send: (msg: AgentLogChunk) => void;
|
|
@@ -47,6 +47,8 @@ export declare class LogStreamer {
|
|
|
47
47
|
private flushTimer;
|
|
48
48
|
private totalBytes;
|
|
49
49
|
private truncated;
|
|
50
|
+
/** Which stream the currently-buffered lines came from. */
|
|
51
|
+
private bufferStream;
|
|
50
52
|
/** Number of lines dropped due to backpressure (drop mode). */
|
|
51
53
|
private droppedCount;
|
|
52
54
|
/** Whether we are currently in a backpressured state (pause mode). */
|
|
@@ -73,8 +75,21 @@ export declare class LogStreamer {
|
|
|
73
75
|
/**
|
|
74
76
|
* Add a line to the buffer. Triggers flush if threshold reached,
|
|
75
77
|
* otherwise schedules a timer-based flush.
|
|
78
|
+
*
|
|
79
|
+
* A chunk carries a single stream, so a kind flip closes the pending chunk
|
|
80
|
+
* before the new line is buffered. Chunks are delivered in order, so the
|
|
81
|
+
* stdout/stderr interleaving is preserved across that boundary.
|
|
82
|
+
*
|
|
83
|
+
* `bufferStream` is only advanced once the buffer is actually empty. Under
|
|
84
|
+
* pause-mode backpressure `flush()` deliberately leaves the buffer intact
|
|
85
|
+
* until the socket drains, so the flip cannot close the pending chunk;
|
|
86
|
+
* advancing the tag there would relabel the already-buffered lines as the
|
|
87
|
+
* newly-arrived stream. The buffer keeps the tag of its first lines instead,
|
|
88
|
+
* which means a chunk assembled while backpressured reports one stream for
|
|
89
|
+
* lines that came from both — stderr in such a chunk is under-reported as
|
|
90
|
+
* stdout. Separating them needs a queue of pending per-stream chunks.
|
|
76
91
|
*/
|
|
77
|
-
addLine(line: string): void;
|
|
92
|
+
addLine(line: string, stream?: LogStream): void;
|
|
78
93
|
/**
|
|
79
94
|
* Flush buffered lines as a log.chunk message.
|
|
80
95
|
* No-op if buffer is empty.
|
|
@@ -1,13 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
/**
|
|
3
|
-
* Create RuleContext for agent-side rule evaluation.
|
|
4
|
-
*
|
|
5
|
-
* @param event - Event payload from the dispatch message
|
|
6
|
-
* @param changedFiles - List of files changed in this event
|
|
7
|
-
* @param env - Merged environment variables
|
|
8
|
-
* @param dispatchInputs - Operator dispatch inputs (`ctx.dispatchInputs`)
|
|
9
|
-
* @param fanout - Fan-out position (`ctx.fanout`); undefined on a non-fan-out job
|
|
10
|
-
*/
|
|
11
|
-
export declare function createRuleContext(event: Record<string, unknown>, changedFiles?: string[], env?: Record<string, string | undefined>, dispatchInputs?: Readonly<Record<string, string | number | boolean | null>>, fanout?: FanoutPosition): RuleContext;
|
|
12
|
-
export { evaluateRules, type RuleEvaluationResult } from '@kici-dev/sdk';
|
|
1
|
+
export { createRuleContext, ChangedFilesUnavailableError, evaluateRules, type RuleEvaluationResult, } from '@kici-dev/sdk';
|
|
13
2
|
//# sourceMappingURL=rule-evaluator.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container sandbox hardening posture builder.
|
|
3
|
+
*
|
|
4
|
+
* A pure function that produces the dockerode `HostConfig` fragment (plus an
|
|
5
|
+
* optional top-level `User`) applied to every per-job container sandbox. It
|
|
6
|
+
* brings the container backend to parity with the bare-metal bwrap sandbox
|
|
7
|
+
* (`fork-runner.ts`) — which runs rootless, with a read-only system tree,
|
|
8
|
+
* private dev/proc/tmp, and namespace isolation — and exceeds it with cgroup
|
|
9
|
+
* resource caps that bwrap (a namespace tool, not a cgroup tool) cannot set.
|
|
10
|
+
*
|
|
11
|
+
* The default posture ("hardened"):
|
|
12
|
+
* - `CapDrop: ['ALL']` — drop every Linux capability (no default add-back).
|
|
13
|
+
* - `SecurityOpt: ['no-new-privileges']` — a step can never gain privileges via
|
|
14
|
+
* setuid binaries, matching bwrap's `--new-session` lifecycle posture.
|
|
15
|
+
* - `PidsLimit` / `Memory` / `NanoCpus` — cgroup caps bounding fork-bomb, memory
|
|
16
|
+
* and CPU DoS against the host.
|
|
17
|
+
* - `Tmpfs: { '/tmp': '' }` — a private, non-persistent /tmp (mirrors bwrap's
|
|
18
|
+
* `--tmpfs /tmp`).
|
|
19
|
+
* - `User` — honored as configured on the image; an explicit override sets it,
|
|
20
|
+
* but a root image is never silently rewritten (parity is best-effort here:
|
|
21
|
+
* bwrap runs as the unprivileged invoker uid by construction).
|
|
22
|
+
* - `ReadonlyRootfs` — OPT-IN only (many images write outside /workspace: npm
|
|
23
|
+
* cache, /home, tool state), enabled via the resolved `readonlyRootfs` input.
|
|
24
|
+
* - `NetworkMode` — `none` for the isolated network posture (bwrap's
|
|
25
|
+
* `--unshare-net`); the default (bridge) otherwise.
|
|
26
|
+
*
|
|
27
|
+
* The `grant` input is the dispatch-resolved per-job escape hatch (allow-listed
|
|
28
|
+
* orchestrator-side). It is honored strictly additively on top of the hardened
|
|
29
|
+
* baseline: requested capabilities become `CapAdd` entries while `CapDrop:
|
|
30
|
+
* ['ALL']` still applies, and a grant may switch the network to `host` or set
|
|
31
|
+
* an explicit user / read-only rootfs. The builder never reads an allow-list —
|
|
32
|
+
* it applies whatever grant dispatch already resolved (single enforcement
|
|
33
|
+
* point). When `hardened` is false (the documented-temporary
|
|
34
|
+
* `KICI_SANDBOX_HARDENED` rollback affordance), the builder emits an empty
|
|
35
|
+
* posture, reproducing the unhardened container behavior.
|
|
36
|
+
*/
|
|
37
|
+
import type Docker from 'dockerode';
|
|
38
|
+
import type { ResolvedSandboxGrant, SandboxNetworkMode } from '@kici-dev/engine';
|
|
39
|
+
export type { ResolvedSandboxGrant, SandboxNetworkMode };
|
|
40
|
+
/** Inputs to the hardening builder, already resolved from agent config + dispatch. */
|
|
41
|
+
export interface SandboxHardeningOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Master switch. When false (the `KICI_SANDBOX_HARDENED=false` rollback
|
|
44
|
+
* affordance) the builder emits an empty posture — the legacy unhardened
|
|
45
|
+
* container behavior. Defaults are ON in the shipping config.
|
|
46
|
+
*/
|
|
47
|
+
hardened: boolean;
|
|
48
|
+
/** Opt-in read-only rootfs (config `KICI_SANDBOX_READONLY_ROOTFS`). */
|
|
49
|
+
readonlyRootfs: boolean;
|
|
50
|
+
/** Explicit user override (config `KICI_SANDBOX_USER`); honors the image user when unset. */
|
|
51
|
+
user?: string;
|
|
52
|
+
/** Max PIDs in the container cgroup. */
|
|
53
|
+
pidsLimit: number;
|
|
54
|
+
/** Memory cap in bytes for the container cgroup. */
|
|
55
|
+
memoryBytes: number;
|
|
56
|
+
/** CPU cap in nano-CPUs (1 CPU = 1_000_000_000). */
|
|
57
|
+
nanoCpus: number;
|
|
58
|
+
/** Config-derived network posture (`isolated` → `none`, else `default`). */
|
|
59
|
+
networkMode: SandboxNetworkMode;
|
|
60
|
+
/** Optional dispatch-resolved escape hatch (Sub-wish B populates this at dispatch). */
|
|
61
|
+
grant?: ResolvedSandboxGrant;
|
|
62
|
+
}
|
|
63
|
+
/** The builder output: a HostConfig fragment merged into createContainer, plus an optional top-level User. */
|
|
64
|
+
export interface ContainerHardening {
|
|
65
|
+
hostConfig: Partial<Docker.HostConfig>;
|
|
66
|
+
user?: string;
|
|
67
|
+
}
|
|
68
|
+
/** Default cgroup caps — operator-visible constants, overridable via scaler `resources`. */
|
|
69
|
+
export declare const DEFAULT_PIDS_LIMIT = 512;
|
|
70
|
+
export declare const DEFAULT_MEMORY_BYTES: number;
|
|
71
|
+
export declare const DEFAULT_NANO_CPUS: number;
|
|
72
|
+
/**
|
|
73
|
+
* Build the hardened HostConfig fragment for a job sandbox container.
|
|
74
|
+
*
|
|
75
|
+
* Pure and side-effect-free: given resolved inputs it returns the fields the
|
|
76
|
+
* container-sandbox merges into `docker.createContainer`. When `hardened` is
|
|
77
|
+
* false it returns an empty posture (the rollback affordance).
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildContainerHardening(opts: SandboxHardeningOptions): ContainerHardening;
|
|
80
|
+
//# sourceMappingURL=container-hardening.d.ts.map
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import Docker from 'dockerode';
|
|
19
19
|
import type { ExecutionSandbox, SandboxSetupOptions, JobExecutionOptions, JobExecutionResult } from './types.js';
|
|
20
|
+
import { type SandboxHardeningOptions } from './container-hardening.js';
|
|
20
21
|
interface ContainerSandboxOptions {
|
|
21
22
|
/** Dockerode instance (from orchestrator/scaler or created locally). */
|
|
22
23
|
docker: Docker;
|
|
@@ -26,21 +27,39 @@ interface ContainerSandboxOptions {
|
|
|
26
27
|
runnerPath: string;
|
|
27
28
|
/** Mount target inside container (default: /opt/kici/workflow-runner.js). */
|
|
28
29
|
runnerMountPath?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Path to the pure-JS container loader-hook bundle on the HOST (bind-mounted
|
|
32
|
+
* read-only). Defaults to `container-ts-loader-hook.js` next to `runnerPath`.
|
|
33
|
+
*/
|
|
34
|
+
hookPath?: string;
|
|
29
35
|
/** Pre-sanitized environment variables for the container. */
|
|
30
36
|
env: Record<string, string>;
|
|
31
37
|
/** Whether to keep failed containers for debugging. */
|
|
32
38
|
keepFailed?: boolean;
|
|
33
39
|
/** Job ID for container labeling and orphan cleanup. */
|
|
34
40
|
jobId?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Resolved hardening posture for the job container (cap-drop, no-new-privileges,
|
|
43
|
+
* cgroup caps, tmpfs, user, network). When omitted, no hardening is applied —
|
|
44
|
+
* the production caller (job-runner) always supplies this from agent config so
|
|
45
|
+
* the secure-by-default posture is in force; leaving it optional keeps the
|
|
46
|
+
* constructor testable and lets non-production callers opt out explicitly.
|
|
47
|
+
*/
|
|
48
|
+
hardening?: SandboxHardeningOptions;
|
|
35
49
|
}
|
|
36
50
|
export declare class ContainerSandbox implements ExecutionSandbox {
|
|
37
51
|
private readonly docker;
|
|
38
52
|
private readonly image;
|
|
39
53
|
private readonly runnerPath;
|
|
40
54
|
private readonly runnerMountPath;
|
|
55
|
+
/** Host path to the pure-JS container loader-hook bundle (bind-mounted :ro). */
|
|
56
|
+
private readonly hookHostPath;
|
|
41
57
|
private readonly env;
|
|
42
58
|
private readonly keepFailed;
|
|
43
59
|
private readonly jobId;
|
|
60
|
+
private readonly hardening?;
|
|
61
|
+
/** Resolved container user (image-user override / grant), applied to createContainer + each exec. */
|
|
62
|
+
private resolvedUser?;
|
|
44
63
|
/** The running container instance (set during setup). */
|
|
45
64
|
private container;
|
|
46
65
|
/** The active exec stream (set during executeJob, used for abort). */
|
|
@@ -50,7 +69,42 @@ export declare class ContainerSandbox implements ExecutionSandbox {
|
|
|
50
69
|
/** Container name for logging/debugging. */
|
|
51
70
|
private containerName;
|
|
52
71
|
constructor(options: ContainerSandboxOptions);
|
|
72
|
+
/**
|
|
73
|
+
* Ensure the job's container image is present locally, pulling it on demand
|
|
74
|
+
* when it is not.
|
|
75
|
+
*
|
|
76
|
+
* dockerode's `createContainer` — unlike `docker run` / `podman run` — never
|
|
77
|
+
* auto-pulls a missing image; it fails with `(HTTP code 404) ... No such
|
|
78
|
+
* image`. A bare-metal executor that aggressively prunes unused images under
|
|
79
|
+
* disk pressure can leave a container job with nothing to run, so the agent
|
|
80
|
+
* pulls the image itself. Already-present images (the common case, and how
|
|
81
|
+
* private images pre-pulled with registry auth stay working) skip the pull.
|
|
82
|
+
*/
|
|
83
|
+
private ensureImagePresent;
|
|
53
84
|
setup(options: SandboxSetupOptions): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Build the container's read-only bind list.
|
|
87
|
+
*
|
|
88
|
+
* The workspace is NOT bound here — it is a container-owned anonymous volume
|
|
89
|
+
* (`Volumes: { '/workspace': {} }` on the container config), so the container
|
|
90
|
+
* user can write it on every runtime with `CapDrop: ['ALL']` intact. This
|
|
91
|
+
* method binds the workflow runner (read-only) plus two parity affordances
|
|
92
|
+
* that mirror the bare-metal bwrap sandbox — both strictly additive and gated,
|
|
93
|
+
* so the default posture for a production (https-source, bridge-network) job is
|
|
94
|
+
* unchanged:
|
|
95
|
+
*
|
|
96
|
+
* - **`file://` clone-source dir(s)** (`options.extraReadOnlyBinds`): the
|
|
97
|
+
* workflow runner clones the repo from inside the container, so a local
|
|
98
|
+
* `file://` source dir must be exposed read-only or `git clone` fails.
|
|
99
|
+
* Empty for https/ssh remotes — mirrors fork-runner's `extraReadOnlyBinds`.
|
|
100
|
+
* - **Host name-resolution files under host networking**: when the effective
|
|
101
|
+
* network posture is `host` (`KICI_SANDBOX_NETWORK=host`, or a per-job host
|
|
102
|
+
* grant), bind the host's `/etc/hosts` (+ `/etc/nsswitch.conf`) read-only so
|
|
103
|
+
* an `/etc/hosts`-only name the host resolves — e.g. a private registry —
|
|
104
|
+
* resolves inside the container too. Mirrors fork-runner's
|
|
105
|
+
* `--ro-bind /etc/hosts` for the bwrap host-network mode.
|
|
106
|
+
*/
|
|
107
|
+
private buildBinds;
|
|
54
108
|
executeJob(options: JobExecutionOptions): Promise<JobExecutionResult>;
|
|
55
109
|
/**
|
|
56
110
|
* Phase 1 of executeJob: create the docker exec, start it in hijack mode,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type ResolveContext = {
|
|
2
|
+
parentURL?: string;
|
|
3
|
+
conditions: string[];
|
|
4
|
+
importAttributes: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
type ResolveResult = {
|
|
7
|
+
url: string;
|
|
8
|
+
shortCircuit?: boolean;
|
|
9
|
+
format?: string | null;
|
|
10
|
+
};
|
|
11
|
+
type NextResolve = (specifier: string, context?: ResolveContext) => ResolveResult | Promise<ResolveResult>;
|
|
12
|
+
type LoadContext = {
|
|
13
|
+
format?: string | null;
|
|
14
|
+
importAttributes: Record<string, string>;
|
|
15
|
+
conditions: string[];
|
|
16
|
+
};
|
|
17
|
+
type LoadResult = {
|
|
18
|
+
format: string;
|
|
19
|
+
source?: string | ArrayBuffer | Uint8Array;
|
|
20
|
+
shortCircuit?: boolean;
|
|
21
|
+
};
|
|
22
|
+
type NextLoad = (url: string, context?: LoadContext) => LoadResult | Promise<LoadResult>;
|
|
23
|
+
export declare function resolve(specifier: string, context: ResolveContext, nextResolve: NextResolve): ResolveResult | Promise<ResolveResult>;
|
|
24
|
+
export declare function load(url: string, context: LoadContext, nextLoad: NextLoad): Promise<LoadResult>;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=container-ts-loader-hook.d.ts.map
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* 1. Allowed system vars from process.env
|
|
10
10
|
* 2. Sandbox defaults (FORCE_COLOR=1, etc.)
|
|
11
11
|
* 3. KICI_* system vars (orchestrator-generated, from userEnv)
|
|
12
|
-
* 4. Org-level environment vars (from orchestrator via
|
|
13
|
-
* 5. Source-level environment overrides (merged into
|
|
12
|
+
* 4. Org-level environment vars (from orchestrator via contextVars)
|
|
13
|
+
* 5. Source-level environment overrides (merged into contextVars by orchestrator)
|
|
14
14
|
* 6. Job env (from lock file env field, evaluated by orchestrator)
|
|
15
15
|
* 7. setEnv() calls (runtime -- applied at step execution, not here)
|
|
16
16
|
*/
|
|
@@ -32,12 +32,21 @@
|
|
|
32
32
|
* PLATFORM_TOKEN, WEBHOOK_SECRET, etc.) are never included because they
|
|
33
33
|
* are not in the allowlist.
|
|
34
34
|
*
|
|
35
|
+
* The `trustedEnv` option (default false) selects the trusted fleet-agent
|
|
36
|
+
* profile: Layer 1 passes the ambient host env through instead of restricting
|
|
37
|
+
* to `ALLOWED_SYSTEM_VARS`, minus the agent's own KiCI identity/operational
|
|
38
|
+
* secrets (the whole `KICI_*` namespace + a small non-`KICI_` infra denylist).
|
|
39
|
+
* It is read ONLY from agent config (`KICI_TRUSTED_ENV`), never from a dispatch
|
|
40
|
+
* payload, so a workflow cannot request it. With it OFF the output is
|
|
41
|
+
* byte-identical to the allowlist-only behavior.
|
|
42
|
+
*
|
|
35
43
|
* @param userEnv - Environment variables from workflow config and orchestrator
|
|
36
44
|
* @param options - Optional extended options for environment layers
|
|
37
45
|
* @returns A new Record with only safe environment variables
|
|
38
46
|
*/
|
|
39
47
|
export declare function buildSanitizedEnv(userEnv: Record<string, string>, options?: {
|
|
40
|
-
|
|
48
|
+
contextVars?: Record<string, string>;
|
|
41
49
|
jobEnv?: Record<string, string>;
|
|
50
|
+
trustedEnv?: boolean;
|
|
42
51
|
}): Record<string, string>;
|
|
43
52
|
//# sourceMappingURL=env-sanitizer.d.ts.map
|
|
@@ -83,6 +83,20 @@ export declare function buildRequest(dispatch: JobDispatch, workDir: string): Jo
|
|
|
83
83
|
* is simpler and more secure than selective blocking.
|
|
84
84
|
*/
|
|
85
85
|
export declare function buildBwrapArgs(workDir: string, nodeExecPath: string, networkIsolation?: boolean, runnerPath?: string, extraReadOnlyBinds?: string[]): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Derive the read-only bind path(s) for a `file://` clone source. The workflow
|
|
88
|
+
* runner clones the repo from inside the sandbox, so a local `file://` URL's
|
|
89
|
+
* source directory must be exposed read-only or `git clone` fails with
|
|
90
|
+
* `does not appear to be a git repository`. Returns `[]` for non-`file://`
|
|
91
|
+
* remotes (https/ssh need no host bind) and for a malformed `file://` URL (the
|
|
92
|
+
* clone then surfaces the real error rather than being masked here).
|
|
93
|
+
*
|
|
94
|
+
* Shared by both sandboxes that clone a local source: the bare-metal (bwrap)
|
|
95
|
+
* backend threads the result into `buildBwrapArgs`'s `extraReadOnlyBinds`, and
|
|
96
|
+
* the container backend binds each `<dir>:<dir>:ro` into the job container —
|
|
97
|
+
* the same clone-source affordance across both isolation models.
|
|
98
|
+
*/
|
|
99
|
+
export declare function fileCloneSourceBinds(repoUrl: string | undefined): string[];
|
|
86
100
|
/**
|
|
87
101
|
* Spawn the workflow runner as a child process with IPC channel.
|
|
88
102
|
*
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* import { BareMetalSandbox, ContainerSandbox, buildSanitizedEnv } from './sandbox/index.js';
|
|
6
6
|
*/
|
|
7
7
|
export type { ExecutionSandbox, SandboxSetupOptions, JobExecutionOptions, JobExecutionResult, SandboxStepResult, } from './types.js';
|
|
8
|
-
export type { RunnerToAgentMessage, AgentToRunnerMessage, EventEmitRequest, EventEmitResponse, CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc, JobExecutionRequest, } from './ipc-protocol.js';
|
|
8
|
+
export type { RunnerToAgentMessage, AgentToRunnerMessage, EventEmitRequest, EventEmitResponse, CacheRequestIpc, CacheResponseIpc, ProvenanceRequestIpc, ProvenanceResponseIpc, ArtifactRequestIpc, ArtifactResponseIpc, StepApprovalRequestIpc, StepApprovalResolvedIpc, JobExecutionRequest, } from './ipc-protocol.js';
|
|
9
9
|
export { buildSanitizedEnv } from './env-sanitizer.js';
|
|
10
10
|
export { ALLOWED_SYSTEM_VARS, KICI_AGENT_ENV_PREFIX, AGENT_REQUIRED_KICI_VARS, } from '@kici-dev/engine';
|
|
11
11
|
export { BareMetalSandbox } from './bare-metal-sandbox.js';
|
|
12
12
|
export { FirecrackerSandbox } from './firecracker-sandbox.js';
|
|
13
13
|
export { ContainerSandbox } from './container-sandbox.js';
|
|
14
|
+
export { fileCloneSourceBinds } from './fork-runner.js';
|
|
14
15
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CheckMode, CheckStepOutcome } from '@kici-dev/engine';
|
|
1
|
+
import type { CheckMode, CheckStepOutcome, LogStream } from '@kici-dev/engine';
|
|
2
2
|
import type { SandboxStepResult } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Structured clone auth. Wire-compatible with `gitAuthSchema` on the
|
|
@@ -78,6 +78,8 @@ interface LogLineMessage {
|
|
|
78
78
|
type: 'log.line';
|
|
79
79
|
stepIndex: number;
|
|
80
80
|
line: string;
|
|
81
|
+
/** Which subprocess stream the line came from. Absent means stdout. */
|
|
82
|
+
stream?: LogStream;
|
|
81
83
|
}
|
|
82
84
|
/**
|
|
83
85
|
* Discriminator for {@link StepSecretMountMessage} -- distinguishes a bare
|
|
@@ -248,7 +250,32 @@ export interface ProvenanceRequestIpc {
|
|
|
248
250
|
/** Ephemeral public key JWK the envelope was signed with. `defer` only. */
|
|
249
251
|
publicKey?: unknown;
|
|
250
252
|
}
|
|
251
|
-
|
|
253
|
+
/** Which artifact operation to relay. */
|
|
254
|
+
export type ArtifactRequestOp = 'beginUpload' | 'completeUpload' | 'download';
|
|
255
|
+
/**
|
|
256
|
+
* Request a user-facing artifact operation (runner -> agent). The agent relays
|
|
257
|
+
* it over the WS as an `artifacts.upload.request` / `.complete` /
|
|
258
|
+
* `artifacts.download.request` and pipes the response back as an
|
|
259
|
+
* {@link ArtifactResponseIpc}. Mirrors the {@link CacheRequestIpc} relay pattern.
|
|
260
|
+
*/
|
|
261
|
+
export interface ArtifactRequestIpc {
|
|
262
|
+
type: 'artifacts.request';
|
|
263
|
+
/** UUID for correlating the response. */
|
|
264
|
+
requestId: string;
|
|
265
|
+
/** Which artifact operation to perform. */
|
|
266
|
+
op: ArtifactRequestOp;
|
|
267
|
+
/** Artifact name (all ops). */
|
|
268
|
+
name: string;
|
|
269
|
+
/** Packed tarball size in bytes — drives the enforcement gates. `beginUpload` only. */
|
|
270
|
+
declaredSizeBytes?: number;
|
|
271
|
+
/** Confirmed tarball size in bytes. `completeUpload` only. */
|
|
272
|
+
sizeBytes?: number;
|
|
273
|
+
/** SHA-256 of the tarball bytes. `completeUpload` only. */
|
|
274
|
+
sha256?: string;
|
|
275
|
+
/** Storage key echoed from the grant response. `completeUpload` only. */
|
|
276
|
+
storageKey?: string;
|
|
277
|
+
}
|
|
278
|
+
export type RunnerToAgentMessage = ReadyMessage | StepStartMessage | StepCompleteMessage | LogLineMessage | StepSecretMountMessage | JobCompleteMessage | EventEmitRequest | ConcurrencyReportMessage | AgentApiRequestIpc | CacheRequestIpc | ProvenanceRequestIpc | ArtifactRequestIpc | StepApprovalRequestIpc;
|
|
252
279
|
/** Instruct the workflow runner to execute a job. */
|
|
253
280
|
interface ExecuteMessage {
|
|
254
281
|
type: 'execute';
|
|
@@ -353,7 +380,50 @@ export interface ProvenanceResponseIpc {
|
|
|
353
380
|
/** Error description (present when the relay or orchestrator failed). */
|
|
354
381
|
error?: string;
|
|
355
382
|
}
|
|
356
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Response to an {@link ArtifactRequestIpc} (agent -> runner). Relayed from the
|
|
385
|
+
* orchestrator's `artifacts.upload.response` / `artifacts.download.response` WS
|
|
386
|
+
* message. Carries the union of the upload-grant (`uploadOutcome` / `uploadUrl`
|
|
387
|
+
* / `storageKey` / `reason`) and download-lookup (`downloadOutcome` /
|
|
388
|
+
* `downloadUrl` / `sizeBytes` / `sha256`) fields; `completeUpload` resolves with
|
|
389
|
+
* an empty (no-field) response. `error` is set when the relay itself failed;
|
|
390
|
+
* `rejectionDetail` carries an orchestrator-side non-enforcement explanation
|
|
391
|
+
* that the workflow-facing render surfaces without throwing a relay error.
|
|
392
|
+
*/
|
|
393
|
+
export interface ArtifactResponseIpc {
|
|
394
|
+
type: 'artifacts.response';
|
|
395
|
+
/** Matches the original request's requestId. */
|
|
396
|
+
requestId: string;
|
|
397
|
+
/** beginUpload: `granted` (presigned PUT minted) or `rejected` (named reason). */
|
|
398
|
+
uploadOutcome?: 'granted' | 'rejected';
|
|
399
|
+
/** beginUpload: presigned PUT URL (present only on `granted`). */
|
|
400
|
+
uploadUrl?: string;
|
|
401
|
+
/** beginUpload: storage key to echo back on complete (present only on `granted`). */
|
|
402
|
+
storageKey?: string;
|
|
403
|
+
/** beginUpload: enforcement-gate refusal reason (present only on `rejected`). */
|
|
404
|
+
reason?: 'duplicate_name' | 'size_cap' | 'run_cap' | 'org_quota';
|
|
405
|
+
/**
|
|
406
|
+
* Orchestrator non-enforcement refusal detail — set when a `rejected` upload
|
|
407
|
+
* or a `not_found` download reflects a name that violates the artifact-name
|
|
408
|
+
* contract, or an orchestrator problem (artifacts not configured, unresolvable
|
|
409
|
+
* run, internal error), rather than an enforcement gate or a genuinely missing
|
|
410
|
+
* artifact. Distinct from `error` below, which is a relay/transport failure
|
|
411
|
+
* the sandbox turns into a thrown error: this field flows into the normal
|
|
412
|
+
* rejection render instead.
|
|
413
|
+
*/
|
|
414
|
+
rejectionDetail?: string;
|
|
415
|
+
/** download: `found` (presigned GET minted) or `not_found`. */
|
|
416
|
+
downloadOutcome?: 'found' | 'not_found';
|
|
417
|
+
/** download: presigned GET URL (present only on `found`). */
|
|
418
|
+
downloadUrl?: string;
|
|
419
|
+
/** download: artifact size in bytes (present only on `found`). */
|
|
420
|
+
sizeBytes?: number;
|
|
421
|
+
/** download: SHA-256 of the tarball bytes (present only on `found`). */
|
|
422
|
+
sha256?: string;
|
|
423
|
+
/** Error description (present when the relay or orchestrator failed). */
|
|
424
|
+
error?: string;
|
|
425
|
+
}
|
|
426
|
+
export type AgentToRunnerMessage = ExecuteMessage | AbortMessage | EventEmitResponse | ConcurrencyAckMessage | AgentApiResponseIpc | CacheResponseIpc | ProvenanceResponseIpc | ArtifactResponseIpc | StepApprovalResolvedIpc;
|
|
357
427
|
/**
|
|
358
428
|
* All data the workflow runner needs to execute a job inside the sandbox.
|
|
359
429
|
*
|
|
@@ -508,10 +578,10 @@ export interface JobExecutionRequest {
|
|
|
508
578
|
orchestratorPrivateKey?: string;
|
|
509
579
|
/** Base64-encoded X25519 public key for the run (for encrypting secret outputs). */
|
|
510
580
|
runPublicKey?: string;
|
|
511
|
-
/**
|
|
512
|
-
|
|
581
|
+
/** Resolved context name (resolved by orchestrator). */
|
|
582
|
+
context?: string;
|
|
513
583
|
/** Environment variables from orchestrator (org-level + source overrides, layers 4-5). */
|
|
514
|
-
|
|
584
|
+
contextVars?: Record<string, string>;
|
|
515
585
|
/** Job env from lock file env field (layer 6, evaluated by orchestrator). */
|
|
516
586
|
jobEnv?: Record<string, string>;
|
|
517
587
|
/** Whether this is a global workflow (dual-clone: workflow repo + source repo). */
|
|
@@ -528,6 +598,12 @@ export interface JobExecutionRequest {
|
|
|
528
598
|
hasConcurrencyGroup?: boolean;
|
|
529
599
|
/** Concurrency group evaluation timeout in milliseconds (default: 30000). */
|
|
530
600
|
concurrencyEvaluationTimeoutMs?: number;
|
|
601
|
+
/**
|
|
602
|
+
* Orchestrator-resolved concurrency-slot wait timeout (ms), pushed on
|
|
603
|
+
* `job.dispatch` from the fleet-wide `cluster_settings.concurrency_wait_timeout_ms`.
|
|
604
|
+
* Absent for older orchestrators — the runner falls back to its own default.
|
|
605
|
+
*/
|
|
606
|
+
concurrencyWaitTimeoutMs?: number;
|
|
531
607
|
/** Git branch for concurrency group context. */
|
|
532
608
|
branch?: string;
|
|
533
609
|
/** Plain outputs from upstream jobs (keyed by job name, then by step name). For ctx.jobOutputs(). */
|