@intentius/chant 0.124.25 → 0.124.27
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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/activity-registry.d.ts +6 -3
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.js +7 -5
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +14 -7
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +136 -6
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +9 -2
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +225 -245
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +31 -6
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/gate-decision.ts +2 -0
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/activity-registry.ts +7 -5
- package/src/op/builders.ts +21 -1
- package/src/op/discover-root.test.ts +39 -0
- package/src/op/discover.ts +15 -8
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.ts +180 -7
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +9 -2
- package/src/op/types.ts +32 -0
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
package/src/op/local-output.ts
CHANGED
|
@@ -28,6 +28,10 @@ function formatDuration(ms: number): string {
|
|
|
28
28
|
* stays clean for piping (the `--json` renderer owns stdout).
|
|
29
29
|
*/
|
|
30
30
|
export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed = false): void {
|
|
31
|
+
// #3947: a run that resumed at a gate says where it picked up.
|
|
32
|
+
if (result.resumed) {
|
|
33
|
+
write(`[resume] at gate "${result.resumed.gate}", from run ${result.resumed.run}'s checkpoint ${result.resumed.checkpoint}`);
|
|
34
|
+
}
|
|
31
35
|
let currentPhase: string | undefined;
|
|
32
36
|
for (const record of result.records) {
|
|
33
37
|
if (record.phase !== currentPhase) {
|
|
@@ -36,7 +40,9 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
36
40
|
}
|
|
37
41
|
const mark = record.status === "ok" ? "✓" : record.status === "fail" ? "✗" : "•";
|
|
38
42
|
const call = `${record.fn}(${formatArgs(record.args ?? {})})`;
|
|
39
|
-
if (record.status === "skipped") {
|
|
43
|
+
if (record.status === "skipped" && record.resumed) {
|
|
44
|
+
write(` ${mark} ${call} from run ${record.resumed.run}`);
|
|
45
|
+
} else if (record.status === "skipped") {
|
|
40
46
|
write(` ${mark} ${call} skipped`);
|
|
41
47
|
} else {
|
|
42
48
|
write(` ${mark} ${call} ${formatDuration(record.durationMs)}`);
|
|
@@ -47,6 +53,10 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
47
53
|
if (record.approval) {
|
|
48
54
|
write(` [approved] ${record.approval.resolvedBy} at ${record.approval.timestamp}` +
|
|
49
55
|
(record.approval.url ? ` (${record.approval.url})` : ""));
|
|
56
|
+
// #3949: an approval carried from one of the gate's aliases names where it came from.
|
|
57
|
+
for (const c of record.approval.carriedFrom ?? []) {
|
|
58
|
+
write(` [carried] ${c.resolvedBy}'s approval of ${c.op}/${c.gate} at ${c.timestamp}`);
|
|
59
|
+
}
|
|
50
60
|
}
|
|
51
61
|
if (record.refusal) {
|
|
52
62
|
write(` [refused] ${record.refusal}`);
|
|
@@ -72,6 +82,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
72
82
|
write(`[work] nothing claimed: ${work.refusal}`);
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
for (const note of result.resumeNotes ?? []) write(`[resume] ${note}`);
|
|
86
|
+
|
|
75
87
|
const total = `${(result.totalMs / 1000).toFixed(1)}s`;
|
|
76
88
|
if (result.status === "ok") {
|
|
77
89
|
write(`Op "${result.op}" completed in ${total}`);
|
|
@@ -104,6 +116,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed
|
|
|
104
116
|
write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest, sealed)}`);
|
|
105
117
|
if (gate.url) write(` approve at: ${gate.url}`);
|
|
106
118
|
write(` expires : ${gate.expiresAt}`);
|
|
119
|
+
// #3947: the approval also lets the next run start here.
|
|
120
|
+
if (gate.checkpointDigest) write(` resume : once approved, the next run starts at this gate (checkpoint ${gate.checkpointDigest})`);
|
|
107
121
|
// #2310: this run's own append reached only the local chant/lifecycle
|
|
108
122
|
// branch, not the remote. The pending fact is still correct — the gate is
|
|
109
123
|
// still right to stand — but an operator working from a clone of the
|
|
@@ -139,7 +153,9 @@ export function renderJson(result: OpRunResult, write: Writer = stdout, sealed =
|
|
|
139
153
|
const push = result.gatePushed === false
|
|
140
154
|
? { pushed: false, pushWarning: result.gatePushWarning ?? "the pending fact was recorded locally only" }
|
|
141
155
|
: {};
|
|
142
|
-
|
|
156
|
+
// #3947: why a gate that resumes did not, beside the record like `push`.
|
|
157
|
+
const notes = result.resumeNotes ? { resumeNotes: result.resumeNotes } : {};
|
|
158
|
+
write(JSON.stringify({ ...result.record, ...approve, ...push, ...notes }));
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
export type { OpRunResult, StepRecord };
|
package/src/op/op-ir.test.ts
CHANGED
|
@@ -257,6 +257,31 @@ describe("op.json IR", () => {
|
|
|
257
257
|
expect(serializeOpIR(reconstructed)).toBe(text);
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
// #3947: a gate that resumes says so in op.json, for a runtime that reads it.
|
|
261
|
+
it("carries a gate's resume through op.json and back", () => {
|
|
262
|
+
const config: OpConfig = {
|
|
263
|
+
name: "live-apply",
|
|
264
|
+
overview: "o",
|
|
265
|
+
phases: [phase("Gate", [{ kind: "gate", gate: "approve-live-apply", resume: true }])],
|
|
266
|
+
};
|
|
267
|
+
const text = serializeOpIR(config);
|
|
268
|
+
expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", resume: true });
|
|
269
|
+
expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", resume: true });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// #3949: a gate's aliases travel in op.json, so a runtime that reads it counts the same approvals.
|
|
273
|
+
it("carries a gate's aliases through op.json and back", () => {
|
|
274
|
+
const aliases = [{ op: "upgrade-tier-1", gate: "approve" }, { gate: "approve-old" }];
|
|
275
|
+
const config: OpConfig = {
|
|
276
|
+
name: "upgrade",
|
|
277
|
+
overview: "o",
|
|
278
|
+
phases: [phase("Gate", [{ kind: "gate", gate: "approve-tier-1", plan: "jcs1-sha256:" + "a".repeat(64), aliases }])],
|
|
279
|
+
};
|
|
280
|
+
const text = serializeOpIR(config);
|
|
281
|
+
expect((JSON.parse(text) as OpIR).phases[0].steps[0]).toMatchObject({ kind: "gate", aliases });
|
|
282
|
+
expect(opConfigFromIR(JSON.parse(text) as OpIR).phases[0]!.steps[0]).toMatchObject({ kind: "gate", aliases });
|
|
283
|
+
});
|
|
284
|
+
|
|
260
285
|
it("round-trips a minimal Op (no gate, effect, onFailure or labels) too", () => {
|
|
261
286
|
const original: OpConfig = { name: "minimal", overview: "o", phases: [phase("Only", [shell("echo hi")])] };
|
|
262
287
|
const text = serializeOpIR(original);
|
package/src/op/op-ir.ts
CHANGED
|
@@ -151,6 +151,10 @@ export interface OpIRGateStep {
|
|
|
151
151
|
approval?: GateStep["approval"];
|
|
152
152
|
/** The decision point the gate asks (#3170), carried as authored. Absent on a gate that `chant approve` passes. */
|
|
153
153
|
point?: GateStep["point"];
|
|
154
|
+
/** The gate resumes once approved (#3947). Absent on a gate that starts its run again at the first phase. */
|
|
155
|
+
resume?: true;
|
|
156
|
+
/** The gate's old names (#3949), carried as authored. Absent on a gate that has none. */
|
|
157
|
+
aliases?: GateStep["aliases"];
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
export interface OpIREffectStep {
|
|
@@ -263,6 +267,8 @@ function irGateStep(step: GateStep): OpIRGateStep {
|
|
|
263
267
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
264
268
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
265
269
|
...(step.point !== undefined ? { point: step.point } : {}),
|
|
270
|
+
...(step.resume ? { resume: true as const } : {}),
|
|
271
|
+
...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
|
|
266
272
|
};
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -419,6 +425,8 @@ function opStepFromIR(step: OpIRStep): StepDefinition {
|
|
|
419
425
|
...(step.plan !== undefined ? { plan: step.plan } : {}),
|
|
420
426
|
...(step.approval !== undefined ? { approval: step.approval } : {}),
|
|
421
427
|
...(step.point !== undefined ? { point: step.point } : {}),
|
|
428
|
+
...(step.resume ? { resume: true } : {}),
|
|
429
|
+
...(step.aliases !== undefined && step.aliases.length > 0 ? { aliases: step.aliases } : {}),
|
|
422
430
|
};
|
|
423
431
|
}
|
|
424
432
|
return {
|
package/src/op/runtime.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface OpRunStepRecord {
|
|
|
59
59
|
point?: WaitingPoint;
|
|
60
60
|
/** The gate this step's command stopped at (#2779), for a step skipped because of it. */
|
|
61
61
|
gate?: { op: string; gate: string };
|
|
62
|
+
/** The run whose checkpoint supplied this step's output, for a step a resumed run skipped (#3947). */
|
|
63
|
+
resumed?: { run: string; gate: string };
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** One phase's steps and the verdict they add up to. */
|
|
@@ -151,6 +153,12 @@ export interface OpRunRecord {
|
|
|
151
153
|
point?: WaitingPoint & { since: string };
|
|
152
154
|
/** The steward whose turn the run was, when a steward ran it (#2749). */
|
|
153
155
|
steward?: string;
|
|
156
|
+
/**
|
|
157
|
+
* The gate this run resumed at (#3947), the run whose checkpoint it carried
|
|
158
|
+
* on from, and the checkpoint's digest. Absent on a run that started at the
|
|
159
|
+
* first phase.
|
|
160
|
+
*/
|
|
161
|
+
resumed?: { gate: string; run: string; checkpoint: string };
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
/** An {@link OpRunRecord} before the ledger stamps its version and mints an id. */
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -22,8 +22,9 @@ import { lexiconNames } from "../../lexicon-module";
|
|
|
22
22
|
import { loadChantConfig } from "../../config";
|
|
23
23
|
import { loadActivities, loadProfiles } from "../activity-registry";
|
|
24
24
|
import { runOpLocally, OpRunFailure, type OpRunResult } from "../local-executor";
|
|
25
|
+
import { loadActivityContracts } from "../activity-contract-registry";
|
|
26
|
+
import { resumeGatesOf } from "../gate-checkpoint";
|
|
25
27
|
import { stepHeartbeatMs } from "../step-console";
|
|
26
|
-
import { runComponents } from "../../components/cli-support";
|
|
27
28
|
import { discoverOps } from "../discover";
|
|
28
29
|
import { readRunLedger, runEnvOf, DEFAULT_RUN_ENV } from "../../lifecycle/run-ledger";
|
|
29
30
|
import type { OpConfig } from "../types";
|
|
@@ -139,6 +140,8 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
const [activities, profiles] = await Promise.all([loadActivities(lexicons), loadProfiles()]);
|
|
143
|
+
// #3947: a gate that resumes stores no output a contract marks secret.
|
|
144
|
+
const contracts = resumeGatesOf(op).length > 0 ? await loadActivityContracts(lexicons) : undefined;
|
|
142
145
|
|
|
143
146
|
// A caller that names the run (#3539: `chant acp` on fountain passes the
|
|
144
147
|
// conversation id) gets its id on the record; otherwise one is minted.
|
|
@@ -179,6 +182,7 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
179
182
|
stepOutput: (line) => process.stderr.write(`${line}\n`),
|
|
180
183
|
stepHeartbeatMs: stepHeartbeatMs(process.env, (message) => process.stderr.write(`warning: ${message}\n`)),
|
|
181
184
|
...(startOpts.work ? { work: startOpts.work } : {}),
|
|
185
|
+
...(contracts ? { contracts } : {}),
|
|
182
186
|
onWorkLeaseWarning: (message) =>
|
|
183
187
|
process.stderr.write(`warning: "${op.name}" could not renew its work lease, retrying at the next beat: ${message}\n`),
|
|
184
188
|
},
|
|
@@ -232,7 +236,10 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
|
|
|
232
236
|
);
|
|
233
237
|
},
|
|
234
238
|
|
|
235
|
-
runComponents(path, selector, options) {
|
|
239
|
+
async runComponents(path, selector, options) {
|
|
240
|
+
// Imported here, not at the top: the components driver is most of core's components tree, and this module is
|
|
241
|
+
// re-exported by `@intentius/chant/op`, which every `*.op.ts` imports (#3982).
|
|
242
|
+
const { runComponents } = await import("../../components/cli-support");
|
|
236
243
|
return runComponents(path, selector, options);
|
|
237
244
|
},
|
|
238
245
|
};
|
package/src/op/types.ts
CHANGED
|
@@ -298,6 +298,38 @@ export interface GateStepBase {
|
|
|
298
298
|
* `./gate-point.ts`.
|
|
299
299
|
*/
|
|
300
300
|
point?: string | GatePoint;
|
|
301
|
+
/**
|
|
302
|
+
* A run that stops at this gate can be resumed here once it is approved
|
|
303
|
+
* (#3947), rather than starting again at the first phase. The run stores
|
|
304
|
+
* the step outputs a later step references in a checkpoint on
|
|
305
|
+
* `chant/lifecycle`, and the next run after `chant approve` re-runs the
|
|
306
|
+
* step that produced the gate's `plan`, checks the checkpoint, and carries
|
|
307
|
+
* on from the gate. Any check that fails starts the run at the first phase,
|
|
308
|
+
* and the run says why. See `./gate-checkpoint.ts`.
|
|
309
|
+
*
|
|
310
|
+
* Only a gate at the top of a main phase resumes, and not one that asks a
|
|
311
|
+
* decision point or sits in an Op with a `workLease`. OPS016 refuses the
|
|
312
|
+
* rest at build.
|
|
313
|
+
*/
|
|
314
|
+
resume?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* The names this gate had before an Op was restructured (#3949), so the
|
|
317
|
+
* approvals already recorded under them still count. `op` defaults to this
|
|
318
|
+
* Op. An approval recorded under an alias counts toward this gate when it
|
|
319
|
+
* answers that name's standing pending fact, the fact hasn't expired, it
|
|
320
|
+
* is for the plan this run planned and the same environment, it hasn't
|
|
321
|
+
* been spent, it passes this gate's `identity.gates` rule, and a person
|
|
322
|
+
* made it. The run then writes it again under this gate's name, citing
|
|
323
|
+
* the line it came from (`carriedFrom`). Only a gate bound to a `plan`
|
|
324
|
+
* takes aliases, and OPS017 refuses an alias that is still a gate.
|
|
325
|
+
*/
|
|
326
|
+
aliases?: GateAlias[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A name a gate had before (#3949): a gate of `op`, or of the gate's own Op when `op` is absent. */
|
|
330
|
+
export interface GateAlias {
|
|
331
|
+
op?: string;
|
|
332
|
+
gate: string;
|
|
301
333
|
}
|
|
302
334
|
|
|
303
335
|
/**
|
|
@@ -11,7 +11,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
-
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
|
|
14
|
+
import { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealRefusal, gateSealVersion, sshKeyFingerprint, verifySshSignatureWith, type GateSigners, type SealedGateApproval } from "./gate-seal";
|
|
15
15
|
import { checkGateSeal } from "./seal";
|
|
16
16
|
import { emptyPolicy, parseAllowedSigners, type TrustPolicy } from "./policy";
|
|
17
17
|
import { hasSshKeygen } from "./test-repo";
|
|
@@ -96,7 +96,7 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
|
|
|
96
96
|
const policy = policyOf(`alice ${k.pub}\n`);
|
|
97
97
|
const seal = { signer: "alice", key: "SHA256:x", signature };
|
|
98
98
|
expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
|
|
99
|
-
for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }]) {
|
|
99
|
+
for (const edited of [{ planDigest: "jcs1-sha256:other" }, { timestamp: "2026-10-12T00:00:00.000Z" }, { relayedBy: "bot" }, { environment: "prod" }, { checkpointDigest: "jcs1-sha256:added" }]) {
|
|
100
100
|
const res = both(policy, { ...a, ...edited, seal });
|
|
101
101
|
expect(res.keygen).toBe(false);
|
|
102
102
|
expect(res.ours).toMatch(/does not verify for alice/);
|
|
@@ -137,6 +137,33 @@ describe.skipIf(!hasSshKeygen)("an SSHSIG checked with node:crypto", () => {
|
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
// #3947: seal version 2 signs the checkpoint digest as one more line, so the
|
|
141
|
+
// outputs a resumed run carries on with are covered, and a version 1 payload
|
|
142
|
+
// is unchanged.
|
|
143
|
+
describe("seal version 2 covers the checkpoint", () => {
|
|
144
|
+
test("the payload adds a checkpoint line only when the approval carries one", () => {
|
|
145
|
+
const a = approval({ relayedBy: "bot" });
|
|
146
|
+
expect(gateSealVersion(a)).toBe(1);
|
|
147
|
+
expect(gateSealPayload(a).toString()).toBe("migrations\nmigrations-wave-1\n\njcs1-sha256:abc\nalice\n2026-10-11T00:00:00.000Z\nrelayed-by bot");
|
|
148
|
+
const b = approval({ relayedBy: "bot", checkpointDigest: "jcs1-sha256:cp" });
|
|
149
|
+
expect(gateSealVersion(b)).toBe(2);
|
|
150
|
+
expect(gateSealPayload(b).toString()).toBe(`${gateSealPayload(a).toString()}\ncheckpoint jcs1-sha256:cp`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test.skipIf(!hasSshKeygen)("a version 2 seal verifies, and swapping or removing the checkpoint stops it", () => {
|
|
154
|
+
const k = key("alice-cp", ["-t", "ed25519"]);
|
|
155
|
+
const a = approval({ checkpointDigest: "jcs1-sha256:cp" });
|
|
156
|
+
const seal = { signer: "alice", key: "SHA256:x", signature: sign(k, gateSealPayload(a)), version: 2 };
|
|
157
|
+
const policy = policyOf(`alice ${k.pub}\n`);
|
|
158
|
+
expect(both(policy, { ...a, seal })).toEqual({ ours: null, keygen: true });
|
|
159
|
+
for (const edited of [{ checkpointDigest: "jcs1-sha256:other" }, { checkpointDigest: undefined }]) {
|
|
160
|
+
const res = both(policy, { ...a, ...edited, seal });
|
|
161
|
+
expect(res.keygen).toBe(false);
|
|
162
|
+
expect(res.ours).toMatch(/does not verify for alice/);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
140
167
|
describe("the refusals that need no signature", () => {
|
|
141
168
|
const signers = (over: Partial<GateSigners> = {}): GateSigners => ({ base: "a".repeat(40), signersPath: ".chant/allowed_signers", active: true, signers: [], problems: [], ...over });
|
|
142
169
|
|
|
@@ -37,9 +37,42 @@ export interface SealedGateApproval {
|
|
|
37
37
|
/** Who relayed the approval for `resolvedBy` (#3402), when someone did. */
|
|
38
38
|
relayedBy?: string;
|
|
39
39
|
timestamp: string;
|
|
40
|
+
/** The checkpoint the approval lets a run resume from (#3947), when it carries one. */
|
|
41
|
+
checkpointDigest?: string;
|
|
42
|
+
/** The line a carried approval came from (#3949): the names and time its seal was made under. */
|
|
43
|
+
carriedFrom?: { op: string; gate: string; timestamp: string; checkpointDigest?: string };
|
|
40
44
|
seal?: unknown;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The approval as it was sealed (#3949). A carried approval is recorded
|
|
49
|
+
* under a gate's new name and the time it was carried, and its seal was
|
|
50
|
+
* made over the line it came from, so its op, gate, time and checkpoint
|
|
51
|
+
* digest are read off `carriedFrom`. Any other approval is returned as it is.
|
|
52
|
+
*/
|
|
53
|
+
export function sealedFieldsOf(a: SealedGateApproval): SealedGateApproval {
|
|
54
|
+
const from = a.carriedFrom;
|
|
55
|
+
if (from === undefined) return a;
|
|
56
|
+
const { carriedFrom: _from, checkpointDigest: _checkpoint, ...rest } = a;
|
|
57
|
+
return {
|
|
58
|
+
...rest,
|
|
59
|
+
op: from.op,
|
|
60
|
+
gate: from.gate,
|
|
61
|
+
timestamp: from.timestamp,
|
|
62
|
+
...(from.checkpointDigest !== undefined ? { checkpointDigest: from.checkpointDigest } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The version of a seal over `a` (#3947): 2 when the approval carries a
|
|
68
|
+
* checkpoint digest, which the payload signs, and 1 otherwise. `chant approve
|
|
69
|
+
* --sign` records 2 on the seal; a seal records no version for 1.
|
|
70
|
+
*/
|
|
71
|
+
export function gateSealVersion(a: Pick<SealedGateApproval, "checkpointDigest" | "carriedFrom">): 1 | 2 {
|
|
72
|
+
const checkpoint = a.carriedFrom !== undefined ? a.carriedFrom.checkpointDigest : a.checkpointDigest;
|
|
73
|
+
return checkpoint !== undefined ? 2 : 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
43
76
|
/**
|
|
44
77
|
* The bytes a gate approval's seal signs (#3163): the op (or component), the
|
|
45
78
|
* gate, the environment, the plan digest, the approver and the time, joined
|
|
@@ -48,10 +81,21 @@ export interface SealedGateApproval {
|
|
|
48
81
|
* `relayed-by <principal>`, so the relay can't be added, changed or removed
|
|
49
82
|
* after sealing; an approval nobody relayed signs the six lines it always did,
|
|
50
83
|
* so seals made before #3402 still verify.
|
|
84
|
+
*
|
|
85
|
+
* Seal version 2 (#3947) adds the checkpoint a resumed run carries its step
|
|
86
|
+
* outputs from: an approval with a `checkpointDigest` signs one more line,
|
|
87
|
+
* `checkpoint <digest>`, last. The values the steps after the gate read are
|
|
88
|
+
* then covered by the seal too, and removing or swapping the digest stops it
|
|
89
|
+
* verifying. An approval with none signs what a version 1 seal signed.
|
|
90
|
+
*
|
|
91
|
+
* A carried approval (#3949) signs what the line it came from signed: see
|
|
92
|
+
* {@link sealedFieldsOf}.
|
|
51
93
|
*/
|
|
52
|
-
export function gateSealPayload(
|
|
94
|
+
export function gateSealPayload(approval: SealedGateApproval): Buffer {
|
|
95
|
+
const a = sealedFieldsOf(approval);
|
|
53
96
|
const relay = a.relayedBy !== undefined ? `\nrelayed-by ${a.relayedBy}` : "";
|
|
54
|
-
|
|
97
|
+
const checkpoint = gateSealVersion(a) === 2 ? `\ncheckpoint ${a.checkpointDigest}` : "";
|
|
98
|
+
return Buffer.from(`${a.op}\n${a.gate}\n${a.environment ?? ""}\n${a.planDigest ?? ""}\n${a.resolvedBy}\n${a.timestamp}${relay}${checkpoint}`, "utf-8");
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
// ── SSHSIG ───────────────────────────────────────────────────────────────
|
|
@@ -32,7 +32,7 @@ import type { Signer } from "./trust-files";
|
|
|
32
32
|
import { sshKeygen, verifySshSignature } from "./ssh-commit";
|
|
33
33
|
import { GATE_SEAL_NAMESPACE, gateSealPayload, namespaceAllowed, sshSignatureKey, verifySshSignatureWith, type SealedGateApproval } from "./gate-seal";
|
|
34
34
|
|
|
35
|
-
export { GATE_SEAL_NAMESPACE, gateSealPayload, type SealedGateApproval } from "./gate-seal";
|
|
35
|
+
export { GATE_SEAL_NAMESPACE, gateSealPayload, gateSealVersion, sealedFieldsOf, type SealedGateApproval } from "./gate-seal";
|
|
36
36
|
|
|
37
37
|
/** The ssh signature namespace of a verdict seal. */
|
|
38
38
|
export const REVIEW_SEAL_NAMESPACE = "chant-review";
|