@intentius/chant 0.124.29 → 0.124.31

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.
Files changed (145) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +4 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -5
  5. package/dist/cli/handlers/pr.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +64 -0
  8. package/dist/cli/parse-args.d.ts.map +1 -1
  9. package/dist/cli/parse-args.js +99 -0
  10. package/dist/cli/registry.d.ts +35 -1
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/pr-pipeline.d.ts +24 -0
  13. package/dist/components/pr-pipeline.d.ts.map +1 -1
  14. package/dist/components/pr-pipeline.js +16 -0
  15. package/dist/components/pr-run.d.ts +20 -2
  16. package/dist/components/pr-run.d.ts.map +1 -1
  17. package/dist/components/pr-run.js +26 -13
  18. package/dist/components/wave-plan.d.ts +11 -0
  19. package/dist/components/wave-plan.d.ts.map +1 -1
  20. package/dist/components/wave-plan.js +26 -0
  21. package/dist/lifecycle/release-ledger.d.ts +8 -3
  22. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  23. package/dist/lifecycle/release-ledger.js +2 -0
  24. package/dist/lifecycle/run-ledger.d.ts +1 -1
  25. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  26. package/dist/lifecycle/run-ledger.js +9 -0
  27. package/dist/op/activities/activity-contracts.d.ts +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  29. package/dist/op/activities/activity-contracts.js +1 -0
  30. package/dist/op/activities/shell.d.ts +6 -0
  31. package/dist/op/activities/shell.d.ts.map +1 -1
  32. package/dist/op/activities/shell.js +1 -1
  33. package/dist/op/local-executor.d.ts +35 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-executor.js +31 -6
  36. package/dist/op/redact.d.ts +34 -0
  37. package/dist/op/redact.d.ts.map +1 -0
  38. package/dist/op/redact.js +94 -0
  39. package/dist/op/runtime.d.ts +27 -0
  40. package/dist/op/runtime.d.ts.map +1 -1
  41. package/dist/op/runtime.js +20 -0
  42. package/dist/op/step-console.d.ts +2 -0
  43. package/dist/op/step-console.d.ts.map +1 -1
  44. package/dist/op/step-console.js +5 -2
  45. package/dist/pr-forge.d.ts +40 -0
  46. package/dist/pr-forge.d.ts.map +1 -1
  47. package/dist/pr-forge.js +56 -6
  48. package/dist/pr-loop.d.ts +10 -0
  49. package/dist/pr-loop.d.ts.map +1 -1
  50. package/dist/pr-loop.js +7 -0
  51. package/dist/pr-report-url.d.ts +8 -0
  52. package/dist/pr-report-url.d.ts.map +1 -1
  53. package/dist/pr-report-url.js +47 -1
  54. package/dist/run-event-stream.d.ts +2 -2
  55. package/dist/run-event-stream.d.ts.map +1 -1
  56. package/dist/run-event-stream.js +2 -2
  57. package/dist/run-event.d.ts +15 -0
  58. package/dist/run-event.d.ts.map +1 -1
  59. package/dist/run-event.js +2 -0
  60. package/dist/wave-runtime.mjs +171 -171
  61. package/dist/workspace/ci-green-forge.d.ts +2 -0
  62. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  63. package/dist/workspace/ci-green.d.ts +2 -0
  64. package/dist/workspace/ci-green.d.ts.map +1 -1
  65. package/dist/workspace/ci-green.js +4 -2
  66. package/dist/workspace/ci-run-index.d.ts +96 -0
  67. package/dist/workspace/ci-run-index.d.ts.map +1 -0
  68. package/dist/workspace/ci-run-index.js +162 -0
  69. package/dist/workspace/ci-run-index.schema.json +50 -0
  70. package/dist/workspace/ci-run.d.ts +299 -0
  71. package/dist/workspace/ci-run.d.ts.map +1 -0
  72. package/dist/workspace/ci-run.js +691 -0
  73. package/dist/workspace/ci-runner-key.d.ts +120 -0
  74. package/dist/workspace/ci-runner-key.d.ts.map +1 -0
  75. package/dist/workspace/ci-runner-key.js +217 -0
  76. package/dist/workspace/ci-secrets.d.ts +65 -0
  77. package/dist/workspace/ci-secrets.d.ts.map +1 -0
  78. package/dist/workspace/ci-secrets.js +211 -0
  79. package/dist/workspace/ci-serve.d.ts +227 -0
  80. package/dist/workspace/ci-serve.d.ts.map +1 -0
  81. package/dist/workspace/ci-serve.js +468 -0
  82. package/dist/workspace/pr-report.schema.json +5 -0
  83. package/dist/workspace/reason-codes.d.ts +6 -2
  84. package/dist/workspace/reason-codes.d.ts.map +1 -1
  85. package/dist/workspace/reason-codes.js +7 -2
  86. package/dist/workspace/run-event.schema.json +14 -3
  87. package/dist/workspace/status-stewards.d.ts +2 -0
  88. package/dist/workspace/status-stewards.d.ts.map +1 -1
  89. package/dist/workspace/status-stewards.js +1 -0
  90. package/dist/workspace/status.schema.json +12 -1
  91. package/dist/workspace/trust/trust-write.d.ts +109 -0
  92. package/dist/workspace/trust/trust-write.d.ts.map +1 -0
  93. package/dist/workspace/trust/trust-write.js +320 -0
  94. package/dist/workspace/trust-write.schema.json +265 -0
  95. package/package.json +1 -1
  96. package/src/cli/handlers/build.test.ts +15 -0
  97. package/src/cli/handlers/build.ts +4 -1
  98. package/src/cli/handlers/fan-out.test.ts +82 -0
  99. package/src/cli/handlers/fan-out.ts +34 -5
  100. package/src/cli/handlers/pr.ts +5 -0
  101. package/src/cli/main.test.ts +1 -0
  102. package/src/cli/main.ts +64 -0
  103. package/src/cli/parse-args.ts +71 -0
  104. package/src/cli/registry.ts +35 -1
  105. package/src/components/pr-pipeline.ts +32 -0
  106. package/src/components/pr-run.test.ts +127 -0
  107. package/src/components/pr-run.ts +32 -12
  108. package/src/components/wave-plan.ts +30 -0
  109. package/src/lifecycle/release-ledger.ts +9 -3
  110. package/src/lifecycle/run-ledger.ts +7 -1
  111. package/src/op/activities/activity-contracts.ts +1 -0
  112. package/src/op/activities/shell.ts +7 -1
  113. package/src/op/local-executor.ts +58 -7
  114. package/src/op/redact.ts +93 -0
  115. package/src/op/runtime.ts +42 -0
  116. package/src/op/step-console.ts +7 -2
  117. package/src/pr-forge.test.ts +54 -0
  118. package/src/pr-forge.ts +92 -6
  119. package/src/pr-loop.test.ts +13 -1
  120. package/src/pr-loop.ts +16 -0
  121. package/src/pr-report-url.ts +53 -1
  122. package/src/run-event-stream.ts +4 -3
  123. package/src/run-event.ts +17 -0
  124. package/src/workspace/ci-green-forge.ts +2 -0
  125. package/src/workspace/ci-green.ts +6 -2
  126. package/src/workspace/ci-run-index.schema.json +50 -0
  127. package/src/workspace/ci-run-index.test.ts +353 -0
  128. package/src/workspace/ci-run-index.ts +192 -0
  129. package/src/workspace/ci-run.test.ts +716 -0
  130. package/src/workspace/ci-run.ts +833 -0
  131. package/src/workspace/ci-runner-key.test.ts +166 -0
  132. package/src/workspace/ci-runner-key.ts +250 -0
  133. package/src/workspace/ci-secrets.test.ts +106 -0
  134. package/src/workspace/ci-secrets.ts +213 -0
  135. package/src/workspace/ci-serve.test.ts +367 -0
  136. package/src/workspace/ci-serve.ts +574 -0
  137. package/src/workspace/pr-report.schema.json +5 -0
  138. package/src/workspace/reason-codes.test.ts +2 -0
  139. package/src/workspace/reason-codes.ts +7 -2
  140. package/src/workspace/run-event.schema.json +14 -3
  141. package/src/workspace/status-stewards.ts +3 -0
  142. package/src/workspace/status.schema.json +12 -1
  143. package/src/workspace/trust/trust-write.test.ts +179 -0
  144. package/src/workspace/trust/trust-write.ts +377 -0
  145. package/src/workspace/trust-write.schema.json +265 -0
@@ -0,0 +1,166 @@
1
+ /**
2
+ * #4099: the runner key on a `chant ci serve` or `chant ci run` host. The
3
+ * key is made in the state directory with mode 0600, `--setup-key --trust`
4
+ * proposes its runner entry on a branch through `trust runner add` (#3510)
5
+ * and never writes the target branch, and `ci serve` refuses to start when
6
+ * `.chant/trust.json` at the target's head does not list its key, unless
7
+ * `--unsigned`.
8
+ *
9
+ * Every test works in its own temp repository with a bare origin and its own
10
+ * state directory. The jobs' `chant` is a stub script, and no job runs: the
11
+ * refusals come before the first poll.
12
+ */
13
+
14
+ import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
+ import { generateKeyPairSync } from "node:crypto";
16
+ import { join } from "node:path";
17
+ import { afterEach, describe, expect, test, vi } from "vitest";
18
+ import { cleanScratch, declaration, git, repoFixture, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
+ import { parseArgs } from "../cli/parse-args";
20
+ import type { CommandContext } from "../cli/registry";
21
+ import { ciServeRunnerKey, ensureHostRunnerKey, hostRunnerKey, RUNNER_KEY_FILE } from "./ci-runner-key";
22
+ import { runCiServe } from "./ci-serve";
23
+ import { runCiRun } from "./ci-run";
24
+ import { ciTagSigner } from "./ci-green";
25
+ import { trustWrite } from "./trust/trust-write";
26
+
27
+ const home = process.cwd();
28
+ afterEach(() => {
29
+ process.chdir(home);
30
+ vi.restoreAllMocks();
31
+ cleanScratch();
32
+ });
33
+
34
+ /** The repository every test starts from, built once: a checkout on main with a bare origin, with commits made by a fixed identity and never signed. */
35
+ const workspaceTemplate = repoFixture(() => {
36
+ const top = scratchDir("chant-runner-key-template-");
37
+ const origin = join(top, "origin");
38
+ const root = join(top, "root");
39
+ mkdirSync(origin);
40
+ mkdirSync(root);
41
+ git(origin, "init", "-q", "--bare");
42
+ git(root, "init", "-q");
43
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
44
+ for (const [k, v] of [["user.name", "t"], ["user.email", "t@example.com"], ["commit.gpgsign", "false"]]) git(root, "config", k!, v!);
45
+ writeFiles(root, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }]), "app/a.txt": "a\n" });
46
+ git(root, "remote", "add", "origin", origin);
47
+ git(root, "add", "-A");
48
+ git(root, "commit", "-q", "-m", "init");
49
+ git(root, "push", "-q", "origin", "main");
50
+ return top;
51
+ }, "chant-runner-key-");
52
+
53
+ /** A copy of the template, its origin pointed at the copy of the bare one. */
54
+ function workspace(): { root: string; origin: string; main: string } {
55
+ const top = workspaceTemplate();
56
+ const root = join(top, "root");
57
+ const origin = join(top, "origin");
58
+ git(root, "remote", "set-url", "origin", origin);
59
+ return { root, origin, main: git(root, "rev-parse", "HEAD") };
60
+ }
61
+
62
+ /** Run a ci command line in `cwd`, with what it printed. */
63
+ async function cli(cwd: string, argv: string[], run: (ctx: CommandContext) => Promise<number> = runCiServe) {
64
+ process.chdir(cwd);
65
+ const out: string[] = [];
66
+ const err: string[] = [];
67
+ vi.spyOn(console, "log").mockImplementation((s: string) => void out.push(String(s)));
68
+ vi.spyOn(console, "error").mockImplementation((s: string) => void err.push(String(s)));
69
+ const code = await run({ args: parseArgs(argv) } as unknown as CommandContext);
70
+ return { code, out: out.join("\n"), err: err.join("\n") };
71
+ }
72
+
73
+ /** A stand-in for `chant` the jobs would run; no test here reaches a job. */
74
+ function stubChant(): string {
75
+ const file = join(scratchDir("chant-runner-key-bin-"), "chant");
76
+ writeFileSync(file, "#!/bin/sh\nexit 0\n");
77
+ chmodSync(file, 0o755);
78
+ return file;
79
+ }
80
+
81
+ const SERVE = (state: string) => ["ci", "serve", "--forge", "forgejo", "--repo", "demo/estate", "--env", "local", "--once", "--state", state, "--chant", stubChant(), "--image", "node:22-bookworm"];
82
+
83
+ describe("the host's runner key (#4099)", () => {
84
+ test("--setup-key makes an Ed25519 key with mode 0600, prints its public half, and reuses it", async () => {
85
+ const { root } = workspace();
86
+ const state = join(scratchDir("chant-runner-key-state-"), "ci-serve");
87
+ const first = await cli(root, ["ci", "serve", "--setup-key", "--state", state]);
88
+ expect(first.code).toBe(0);
89
+ const path = join(state, RUNNER_KEY_FILE);
90
+ expect(statSync(path).mode & 0o777).toBe(0o600);
91
+ expect(statSync(state).mode & 0o777).toBe(0o700);
92
+ expect(readFileSync(path, "utf-8")).toContain("BEGIN PRIVATE KEY");
93
+ expect(first.out).toMatch(/^ssh-ed25519 [A-Za-z0-9+/]+=*$/);
94
+ expect(first.err).toContain("made now, mode 0600");
95
+ // It is not listed yet, so the exact command is printed.
96
+ expect(first.err).toContain(`chant workspace trust runner add chant-runner:`);
97
+ expect(first.err).toContain(`--key "${first.out}"`);
98
+
99
+ // ci run --setup-key finds the same key and makes no other.
100
+ const again = await cli(root, ["ci", "run", "--setup-key", "--state", state], runCiRun);
101
+ expect(again.code).toBe(0);
102
+ expect(again.out).toBe(first.out);
103
+ expect(again.err).toContain("already here");
104
+
105
+ // A key other users can read is refused.
106
+ chmodSync(path, 0o644);
107
+ expect(() => hostRunnerKey(state)).toThrow(/can be read by other users/);
108
+ chmodSync(path, 0o600);
109
+ expect(ensureHostRunnerKey(state).created).toBe(false);
110
+ });
111
+
112
+ test("--setup-key --trust pushes the runner entry on a branch of its own and leaves main as it was", async () => {
113
+ const { root, origin, main } = workspace();
114
+ const state = scratchDir("chant-runner-key-state-");
115
+ const r = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--principal", "chant-runner:demo", "--by", "github:alex", "--state", state]);
116
+ expect(r.code, r.err).toBe(0);
117
+ expect(r.err).toContain("committed the runner entry for chant-runner:demo on chant/trust-runner/chant-runner-demo");
118
+ const branch = "chant/trust-runner/chant-runner-demo";
119
+
120
+ // main is untouched, here and on the remote; the branch holds the entry and the role.
121
+ expect(git(origin, "rev-parse", "refs/heads/main")).toBe(main);
122
+ expect(git(root, "rev-parse", "HEAD")).toBe(main);
123
+ expect(git(root, "status", "--short")).toBe("");
124
+ const trust = JSON.parse(git(origin, "show", `${branch}:.chant/trust.json`)) as { runners: { principal: string; key: string }[]; roles: { runner: string[] } };
125
+ expect(trust.runners).toEqual([{ principal: "chant-runner:demo", class: "runner", key: r.out }]);
126
+ expect(trust.roles.runner).toEqual(["chant-runner:demo"]);
127
+ expect(git(origin, "rev-parse", `${branch}^`)).toBe(main);
128
+
129
+ // Until it merges, ci serve refuses the key; once it has, the key signs as the principal.
130
+ const key = hostRunnerKey(state)!;
131
+ expect(() => ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toThrow(/not trusted at main/);
132
+ git(root, "push", "-q", "origin", `refs/remotes/origin/${branch}:refs/heads/main`);
133
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main" })).toMatchObject({ pem: key.pem, source: join(state, RUNNER_KEY_FILE) });
134
+ expect(ciTagSigner(root, git(root, "rev-parse", "refs/remotes/origin/main"), key.pem).principal).toBe("chant-runner:demo");
135
+
136
+ // Run again, it says the key is listed and proposes nothing.
137
+ const listed = await cli(root, ["ci", "serve", "--setup-key", "--trust", "--state", state]);
138
+ expect(listed.err).toContain("lists it for chant-runner:demo, with the runner role");
139
+ });
140
+
141
+ test("ci serve refuses to start with a key trust.json does not list at the target's head, and --unsigned starts without one", async () => {
142
+ const { root } = workspace();
143
+ const state = scratchDir("chant-runner-key-state-");
144
+
145
+ const none = await cli(root, SERVE(state));
146
+ expect(none.code).toBe(1);
147
+ expect(none.err).toContain("there is no runner key");
148
+
149
+ ensureHostRunnerKey(state);
150
+ const unlisted = await cli(root, SERVE(state));
151
+ expect(unlisted.code).toBe(1);
152
+ expect(unlisted.err).toMatch(/not trusted at main [0-9a-f]{12}/);
153
+ expect(unlisted.err).toContain("chant ci serve --setup-key --trust");
154
+ expect(unlisted.err).toContain("chant workspace trust runner add");
155
+
156
+ // A key listed only in the working tree, not at the head, is still refused.
157
+ const other = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString();
158
+ writeFileSync(join(state, "other.pem"), other, { mode: 0o600 });
159
+ expect(trustWrite({ cwd: root, action: "runner add", name: "ci-bot", key: join(state, "other.pem") })).toMatchObject({ changed: true });
160
+ const flagged = await cli(root, [...SERVE(state), "--key", join(state, "other.pem")]);
161
+ expect(flagged.code).toBe(1);
162
+ expect(flagged.err).toContain("lists no runner with this key");
163
+
164
+ expect(ciServeRunnerKey({ top: root, stateDir: state, branch: "main", unsigned: true })).toEqual({ fromFlag: false, source: "none (--unsigned)" });
165
+ });
166
+ });
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The runner key on a `chant ci serve` or `chant ci run` host (#4099, epic
3
+ * #4101).
4
+ *
5
+ * The host keeps its runner key in the serve's state directory, by default
6
+ * `<git common dir>/chant/ci-serve/runner-key.pem`: an Ed25519 private key in
7
+ * PEM, mode 0600, made by `--setup-key` when there is none. Its public half
8
+ * is the `ssh-ed25519` line `.chant/trust.json` lists under `runners`, and
9
+ * `--setup-key --trust` proposes that entry through `chant workspace trust
10
+ * runner add` (#3510) on a branch of its own, pushed for review. It never
11
+ * commits to the target branch: changing the trust config is a protected
12
+ * write, and the change is reviewed and merged like any other.
13
+ *
14
+ * `ci serve` refuses to start when its key is not listed, with the runner
15
+ * role, in `.chant/trust.json` at the target branch's head, since every run
16
+ * it signed would then be ignored by `ci tick --source runs`. `--unsigned`
17
+ * starts it without a key.
18
+ */
19
+
20
+ import { execFileSync } from "node:child_process";
21
+ import { generateKeyPairSync } from "node:crypto";
22
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
23
+ import { hostname, tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { ciTagSigner } from "./ci-green";
26
+ import { CiRunError } from "./ci-run";
27
+ import { loadRunnerKey } from "./trust/dsse";
28
+ import { trustWrite } from "./trust/trust-write";
29
+
30
+ /** The runner key's file in the state directory. */
31
+ export const RUNNER_KEY_FILE = "runner-key.pem";
32
+
33
+ /** The principal `--setup-key --trust` lists the key for when `--principal` names none. */
34
+ export function defaultRunnerPrincipal(): string {
35
+ return `chant-runner:${hostname().split(".")[0]!.toLowerCase().replace(/[^a-z0-9-]/g, "-") || "host"}`;
36
+ }
37
+
38
+ export interface HostRunnerKey {
39
+ path: string;
40
+ pem: string;
41
+ /** `ssh-ed25519 <base64>`. */
42
+ publicKey: string;
43
+ /** Whether this call made it. */
44
+ created: boolean;
45
+ }
46
+
47
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
48
+
49
+ function git(cwd: string, args: string[]): string {
50
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
51
+ }
52
+
53
+ function tryGit(cwd: string, args: string[]): string | undefined {
54
+ try {
55
+ return git(cwd, args);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /** Read the key at `path`, refusing one others can read or one that is not an Ed25519 private key in PEM. */
62
+ function readHostKey(path: string): HostRunnerKey {
63
+ const mode = statSync(path).mode & 0o777;
64
+ if (mode & 0o077) throw new CiRunError(`${path} can be read by other users (mode ${mode.toString(8)}); chmod 600 it, since it signs for this runner`);
65
+ const pem = readFileSync(path, "utf-8");
66
+ try {
67
+ return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: false };
68
+ } catch (err) {
69
+ throw new CiRunError(`${path} is not an Ed25519 private key in PEM: ${message(err)}`);
70
+ }
71
+ }
72
+
73
+ /** The host's runner key in `stateDir`, or undefined when there is none. */
74
+ export function hostRunnerKey(stateDir: string): HostRunnerKey | undefined {
75
+ const path = join(stateDir, RUNNER_KEY_FILE);
76
+ return existsSync(path) ? readHostKey(path) : undefined;
77
+ }
78
+
79
+ /** The host's runner key in `stateDir`, made when there is none: Ed25519, PEM, mode 0600 in a directory of mode 0700. */
80
+ export function ensureHostRunnerKey(stateDir: string): HostRunnerKey {
81
+ const found = hostRunnerKey(stateDir);
82
+ if (found) return found;
83
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
84
+ const path = join(stateDir, RUNNER_KEY_FILE);
85
+ const { privateKey } = generateKeyPairSync("ed25519");
86
+ const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
87
+ // wx: never replace a key another process made in the meantime.
88
+ writeFileSync(path, pem, { mode: 0o600, flag: "wx" });
89
+ chmodSync(path, 0o600);
90
+ return { path, pem, publicKey: loadRunnerKey(pem).publicKey, created: true };
91
+ }
92
+
93
+ /**
94
+ * The target branch's head, as the readers of runners take it: the remote's
95
+ * branch after a fetch (best effort), else the local branch. Undefined when
96
+ * neither exists.
97
+ */
98
+ export function targetHead(top: string, branch: string, remote = "origin"): { commit: string; ref: string } | undefined {
99
+ if (tryGit(top, ["remote", "get-url", remote]) !== undefined) tryGit(top, ["fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);
100
+ for (const ref of [`refs/remotes/${remote}/${branch}`, `refs/heads/${branch}`]) {
101
+ const commit = tryGit(top, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
102
+ if (commit) return { commit, ref };
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /** The exact command that lists `publicKey` for `principal`. */
108
+ export function trustCommand(principal: string, publicKey: string): string {
109
+ return `chant workspace trust runner add ${principal} --key "${publicKey}"`;
110
+ }
111
+
112
+ /** Whether `.chant/trust.json` at `commit` lists the key as a runner holding the runner role: its principal, or why not. */
113
+ export function runnerKeyListed(top: string, commit: string, pem: string): { ok: true; principal: string } | { ok: false; reason: string } {
114
+ try {
115
+ return { ok: true, principal: ciTagSigner(top, commit, pem, "the runs it signs").principal };
116
+ } catch (err) {
117
+ return { ok: false, reason: message(err) };
118
+ }
119
+ }
120
+
121
+ export interface RunnerTrustProposal {
122
+ /** The branch holding the commit, or null when the key was already listed at the target's head. */
123
+ branch: string | null;
124
+ commit: string | null;
125
+ /** Whether the branch was pushed to the remote. */
126
+ pushed: boolean;
127
+ /** The trust config's digest on the branch. */
128
+ digest: string | null;
129
+ }
130
+
131
+ /**
132
+ * Propose the runner entry for review (#4099): in a temporary worktree at
133
+ * the target's head, run `trust runner add` (#3510), commit the change on
134
+ * `chant/trust-runner/<principal>`, and push that branch to `remote` when
135
+ * the clone has it. The target branch is never written. The commit is made
136
+ * with the host's git identity and signing settings; when the policy at base
137
+ * has signers, the change needs a signature by a policy writer before it
138
+ * merges.
139
+ */
140
+ export function proposeRunnerTrust(req: { top: string; branch: string; principal: string; publicKey: string; by?: string; remote?: string }): RunnerTrustProposal {
141
+ const remote = req.remote ?? "origin";
142
+ const head = targetHead(req.top, req.branch, remote);
143
+ if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on ${remote}, so there is no trust config to add the runner to`);
144
+ const branch = `chant/trust-runner/${req.principal.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "")}`;
145
+ if (tryGit(req.top, ["rev-parse", "--verify", "-q", `refs/heads/${branch}`])) {
146
+ throw new CiRunError(`the branch ${branch} exists already; review or delete it, then run --setup-key --trust again`);
147
+ }
148
+ const dir = mkdtempSync(join(tmpdir(), "chant-trust-runner-"));
149
+ try {
150
+ git(req.top, ["worktree", "add", "-q", "-b", branch, dir, head.commit]);
151
+ const doc = trustWrite({ cwd: dir, action: "runner add", name: req.principal, key: req.publicKey, ...(req.by ? { by: req.by } : {}) });
152
+ if ("error" in doc) throw new CiRunError(`trust runner add refused (${doc.error.code}): ${doc.error.message}`);
153
+ if (!doc.changed) {
154
+ git(req.top, ["worktree", "remove", "--force", dir]);
155
+ git(req.top, ["branch", "-q", "-D", branch]);
156
+ return { branch: null, commit: null, pushed: false, digest: doc.digest };
157
+ }
158
+ git(dir, ["add", "--", ".chant/trust.json"]);
159
+ git(dir, ["commit", "-q", "-m", `trust the ${req.principal} runner key\n\nchant ci --setup-key --trust: list this host's runner key in .chant/trust.json (#4099).`]);
160
+ const commit = git(dir, ["rev-parse", "HEAD"]);
161
+ let pushed = false;
162
+ if (tryGit(req.top, ["remote", "get-url", remote]) !== undefined) {
163
+ try {
164
+ git(dir, ["push", "-q", remote, `refs/heads/${branch}:refs/heads/${branch}`]);
165
+ pushed = true;
166
+ } catch (err) {
167
+ throw new CiRunError(`committed ${branch} here, and pushing it to ${remote} failed: ${message(err).split("\n")[0]}`);
168
+ }
169
+ }
170
+ return { branch, commit, pushed, digest: doc.digest };
171
+ } finally {
172
+ tryGit(req.top, ["worktree", "remove", "--force", dir]);
173
+ rmSync(dir, { recursive: true, force: true });
174
+ }
175
+ }
176
+
177
+ export interface RunnerKeySetup {
178
+ key: HostRunnerKey;
179
+ /** The target's head the trust config was read at, or null when there is no such branch. */
180
+ head: string | null;
181
+ /** The principal the key is listed for at the head, or null. */
182
+ listedAs: string | null;
183
+ principal: string;
184
+ command: string;
185
+ proposal?: RunnerTrustProposal;
186
+ }
187
+
188
+ /** `--setup-key [--trust]`: make or read the key, see whether the target's head lists it, and with `trust` propose the entry. */
189
+ export function setupRunnerKey(req: { top: string; stateDir: string; branch: string; principal?: string; trust?: boolean; by?: string }): RunnerKeySetup {
190
+ const key = ensureHostRunnerKey(req.stateDir);
191
+ const head = targetHead(req.top, req.branch);
192
+ const listed = head ? runnerKeyListed(req.top, head.commit, key.pem) : undefined;
193
+ const principal = req.principal ?? defaultRunnerPrincipal();
194
+ const out: RunnerKeySetup = { key, head: head?.commit ?? null, listedAs: listed?.ok ? listed.principal : null, principal, command: trustCommand(principal, key.publicKey) };
195
+ if (req.trust && !out.listedAs) out.proposal = proposeRunnerTrust({ top: req.top, branch: req.branch, principal, publicKey: key.publicKey, ...(req.by ? { by: req.by } : {}) });
196
+ return out;
197
+ }
198
+
199
+ /** What `--setup-key` prints on stderr; the public key alone goes to stdout. */
200
+ export function formatRunnerKeySetup(s: RunnerKeySetup, branch: string): string {
201
+ const lines = [`runner key ${s.key.path} (${s.key.created ? "made now, mode 0600" : "already here"})`];
202
+ if (s.listedAs) {
203
+ lines.push(`.chant/trust.json at ${branch} ${s.head!.slice(0, 12)} lists it for ${s.listedAs}, with the runner role`);
204
+ } else if (s.proposal?.branch) {
205
+ lines.push(
206
+ `committed the runner entry for ${s.principal} on ${s.proposal.branch} (${s.proposal.commit!.slice(0, 12)})${s.proposal.pushed ? " and pushed it" : ""}; open a pull request into ${branch}, and the key counts once it merges`,
207
+ );
208
+ lines.push("changing .chant/trust.json is a protected write: when the trust policy has signers, an admin signs the commit before it merges");
209
+ } else if (s.proposal) {
210
+ lines.push(`the runner entry for ${s.principal} is already at ${branch}'s head`);
211
+ } else {
212
+ lines.push(`.chant/trust.json at ${branch}${s.head ? ` ${s.head.slice(0, 12)}` : ""} does not list it yet. In a checkout of ${branch}, run`, ` ${s.command}`, "commit the change through review, or run this again with --trust to push it on a branch");
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+
217
+ /**
218
+ * The key `ci serve` signs with, checked before its first poll (#4099): the
219
+ * `--key` file, else `CHANT_RUNNER_KEY`, else the host's key in the state
220
+ * directory. Throws, with the command that lists it, when there is none or
221
+ * `.chant/trust.json` at the target's head does not list it with the runner
222
+ * role. With `unsigned` there is no key and nothing is checked.
223
+ */
224
+ export function ciServeRunnerKey(req: { top: string; stateDir: string; branch: string; keyFile?: string; envKey?: string; unsigned?: boolean }): { pem?: string; fromFlag: boolean; source: string } {
225
+ if (req.unsigned) return { fromFlag: false, source: "none (--unsigned)" };
226
+ const host = req.keyFile === undefined && !req.envKey ? hostRunnerKey(req.stateDir) : undefined;
227
+ const pem = req.keyFile !== undefined ? readFileSync(req.keyFile, "utf-8") : req.envKey || host?.pem;
228
+ const source = req.keyFile !== undefined ? req.keyFile : req.envKey ? "CHANT_RUNNER_KEY" : (host?.path ?? join(req.stateDir, RUNNER_KEY_FILE));
229
+ if (pem === undefined) {
230
+ throw new CiRunError(`there is no runner key: no --key, no CHANT_RUNNER_KEY and no ${source}. Make one with chant ci serve --setup-key, list it with --trust, or start with --unsigned`);
231
+ }
232
+ let publicKey: string;
233
+ try {
234
+ publicKey = loadRunnerKey(pem).publicKey;
235
+ } catch (err) {
236
+ throw new CiRunError(`the runner key from ${source} is not an Ed25519 private key in PEM: ${message(err)}`);
237
+ }
238
+ const head = targetHead(req.top, req.branch);
239
+ if (!head) throw new CiRunError(`there is no branch ${req.branch} here or on origin, so the runner key can't be checked against its .chant/trust.json; fetch it, or start with --unsigned`);
240
+ const listed = runnerKeyListed(req.top, head.commit, pem);
241
+ if (!listed.ok) {
242
+ throw new CiRunError(
243
+ `the runner key from ${source} is not trusted at ${branchAt(req.branch, head.commit)}: ${listed.reason}\n` +
244
+ ` List it on a branch for review with chant ci serve --setup-key --trust, or run ${trustCommand(defaultRunnerPrincipal(), publicKey)} in a checkout of ${req.branch} and merge the change. Start with --unsigned to serve without signing.`,
245
+ );
246
+ }
247
+ return { pem, fromFlag: req.keyFile !== undefined, source };
248
+ }
249
+
250
+ const branchAt = (branch: string, commit: string) => `${branch} ${commit.slice(0, 12)}`;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * #4096: the chant runner's secrets file, its allowlist, and the redactor
3
+ * built from it. Each file is written to its own scratch directory.
4
+ */
5
+
6
+ import { chmodSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, scratchDir } from "./__fixtures__/contract-repo";
10
+ import { ciRedactor, ciSecretsFor, CiSecretsError, parseCiSecrets, parseDotenv, readCiSecrets } from "./ci-secrets";
11
+ import { ciSecretsOf } from "./ci-run";
12
+ import { redactorOf, redactText } from "../op/redact";
13
+ import { parseArgs } from "../cli/parse-args";
14
+
15
+ afterAll(cleanScratch);
16
+
17
+ function fileWithMode(text: string, mode: number): string {
18
+ const file = join(scratchDir("chant-ci-secrets-"), "secrets.env");
19
+ writeFileSync(file, text);
20
+ chmodSync(file, mode);
21
+ return file;
22
+ }
23
+
24
+ describe("the secrets file (#4096)", () => {
25
+ test.skipIf(process.platform === "win32")("a file its group or other users can read or change is refused, as ssh refuses a loose key", () => {
26
+ for (const mode of [0o644, 0o640, 0o604, 0o620, 0o660]) {
27
+ const file = fileWithMode("A_TOKEN=abcdef\n", mode);
28
+ expect(() => readCiSecrets(file)).toThrow(CiSecretsError);
29
+ expect(() => readCiSecrets(file)).toThrow(new RegExp(`has mode 0${mode.toString(8)}.*chmod 600`));
30
+ }
31
+ for (const mode of [0o600, 0o400]) expect(readCiSecrets(fileWithMode("A_TOKEN=abcdef\n", mode)).values).toEqual({ A_TOKEN: "abcdef" });
32
+ // CHANT_CI_SECRETS names the file when --secrets does not, and the same check applies.
33
+ const loose = fileWithMode("A_TOKEN=abcdef\n", 0o644);
34
+ expect(() => ciSecretsOf(undefined, { CHANT_CI_SECRETS: loose })).toThrow(/chmod 600/);
35
+ expect(ciSecretsOf(undefined, { CHANT_CI_SECRETS: fileWithMode("A_TOKEN=abcdef\n", 0o600) })?.values).toEqual({ A_TOKEN: "abcdef" });
36
+ expect(ciSecretsOf(undefined, {})).toBeUndefined();
37
+ expect(() => readCiSecrets(join(scratchDir("chant-ci-secrets-"), "missing.env"))).toThrow(/can't be read: ENOENT/);
38
+ });
39
+
40
+ test("dotenv: comments, export, quotes and escapes, and errors that name the line and not the value", () => {
41
+ const text = [
42
+ "# cloud",
43
+ "export AWS_ACCESS_KEY_ID=AKIAEXAMPLE",
44
+ "AWS_SECRET_ACCESS_KEY = 'single # kept'",
45
+ 'PEM="line one\\nline two"',
46
+ "PLAIN=value # a comment",
47
+ "",
48
+ 'MULTI="a',
49
+ 'b"',
50
+ ].join("\n");
51
+ expect(parseDotenv(text, "s.env")).toEqual({
52
+ AWS_ACCESS_KEY_ID: "AKIAEXAMPLE",
53
+ AWS_SECRET_ACCESS_KEY: "single # kept",
54
+ PEM: "line one\nline two",
55
+ PLAIN: "value",
56
+ MULTI: "a\nb",
57
+ });
58
+ expect(() => parseDotenv("A=1\nnot a line hunter2\n", "s.env")).toThrow(/^s\.env:2: expected NAME=value$/);
59
+ expect(() => parseDotenv('A="unclosed hunter2', "s.env")).toThrow(/s\.env:1: the value of A has no closing "/);
60
+ expect(() => parseDotenv("A=1\nA=2\n", "s.env")).toThrow(/A is set twice/);
61
+ expect(() => parseCiSecrets("CHANT_RUNNER_KEY=x\n", "s.env")).toThrow(/runner key is never passed to a job/);
62
+ });
63
+
64
+ test("JSON: a flat map, or secrets with an allowlist for each job", () => {
65
+ expect(parseCiSecrets('{ "A_TOKEN": "abcd" }', "s.json")).toEqual({ values: { A_TOKEN: "abcd" }, jobs: {} });
66
+ const s = { file: "s.json", ...parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa", B: "bbbb" }, jobs: { plan: ["A"] } }), "s.json") };
67
+ expect(ciSecretsFor(s, "plan")).toEqual({ A: "aaaa" });
68
+ expect(ciSecretsFor(s, "apply")).toEqual({ A: "aaaa", B: "bbbb" });
69
+ expect(ciSecretsFor(undefined, "plan")).toEqual({});
70
+ expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
71
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
72
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan and apply/);
73
+ expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
74
+ // A parse error does not quote the text, which may hold a secret.
75
+ expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
76
+ });
77
+
78
+ test("ci run and ci serve take --secrets", () => {
79
+ expect(parseArgs(["ci", "run", "plan", "--secrets", "/etc/chant/ci.env"])).toMatchObject({ ciSecrets: "/etc/chant/ci.env" });
80
+ expect(parseArgs(["ci", "serve", "--secrets", "s.json"])).toMatchObject({ ciSecrets: "s.json" });
81
+ expect(() => parseArgs(["ci", "run", "plan", "--secrets"])).toThrow(/--secrets needs/);
82
+ });
83
+ });
84
+
85
+ describe("redaction (#4096)", () => {
86
+ test("every secret, and each job variable whose name looks secret, is ***; a value under 4 characters is kept", () => {
87
+ const secrets = { file: "s", values: { DB: "hunter22", PIN: "abc" }, jobs: {} };
88
+ const redact = ciRedactor({ GITHUB_TOKEN: "ghp_abcdef", AWS_SECRET_ACCESS_KEY: "aws-secret", MY_PASSWORD: "pw-1234", HOME: "/home/runner", GITHUB_REPOSITORY: "acme/estate" }, secrets)!;
89
+ expect(redact("hunter22 ghp_abcdef aws-secret pw-1234 abc /home/runner acme/estate")).toBe("*** *** *** *** abc /home/runner acme/estate");
90
+ expect(ciRedactor({ HOME: "/h" })).toBeUndefined();
91
+ expect(redactorOf(["abc", ""])).toBeUndefined();
92
+ });
93
+
94
+ test("a JSON document or line stays JSON, a value that spans lines is redacted line by line, and the longest value wins", () => {
95
+ const redact = redactorOf(["true", 'qu"ote', "-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----", "abcd", "abcdefgh"])!;
96
+ const line = `${JSON.stringify({ ok: true, msg: "it is true", q: 'say qu"ote' })}\n`;
97
+ const out = redactText(line, redact);
98
+ expect(JSON.parse(out)).toEqual({ ok: true, msg: "it is ***", q: "say ***" });
99
+ expect(out.endsWith("\n")).toBe(true);
100
+ expect(redactText("[x] cmd | AAAABBBB", redact)).toBe("[x] cmd | ***");
101
+ expect(redact("abcdefgh abcd")).toBe("*** ***");
102
+ expect(redactText("nothing here", redact)).toBe("nothing here");
103
+ // JSON Lines: each line on its own.
104
+ expect(redactText('{"a":"abcd"}\nplain abcd\n', redact)).toBe('{"a":"***"}\nplain ***\n');
105
+ });
106
+ });