@openthink/stamp 2.1.1 → 2.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.
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/server/prompts-cache-bootstrap.ts
5
+ var import_node_fs3 = require("fs");
6
+ var import_node_path2 = require("path");
7
+
8
+ // src/server/prompts-cache.ts
9
+ var import_node_fs2 = require("fs");
10
+ var import_node_child_process = require("child_process");
11
+ var import_node_url = require("url");
12
+ var import_node_path = require("path");
13
+
14
+ // src/server/promptFetch.ts
15
+ var import_node_fs = require("fs");
16
+ var import_node_crypto = require("crypto");
17
+ var MAX_PROMPT_BYTES = 1024 * 1024;
18
+
19
+ // src/server/prompts-cache.ts
20
+ var import_meta = {};
21
+ var LOCK_STALE_MS = 5 * 60 * 1e3;
22
+ function defaultKnownHostsPath() {
23
+ return (0, import_node_path.resolve)(
24
+ (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)),
25
+ "..",
26
+ "..",
27
+ "server",
28
+ "github-known-hosts"
29
+ );
30
+ }
31
+ var inflightRefreshes = /* @__PURE__ */ new Map();
32
+ async function cloneOrFetchPromptsCache(opts) {
33
+ validateOpts(opts);
34
+ const cacheRoot = (0, import_node_path.resolve)(opts.cacheRoot);
35
+ const existing = inflightRefreshes.get(cacheRoot);
36
+ if (existing) return existing;
37
+ const promise = (async () => {
38
+ const lockPath = `${cacheRoot}.refresh.lock`;
39
+ acquireLock(lockPath);
40
+ try {
41
+ return await refreshInternal({ ...opts, cacheRoot });
42
+ } finally {
43
+ releaseLock(lockPath);
44
+ }
45
+ })();
46
+ inflightRefreshes.set(cacheRoot, promise);
47
+ promise.finally(() => {
48
+ if (inflightRefreshes.get(cacheRoot) === promise) {
49
+ inflightRefreshes.delete(cacheRoot);
50
+ }
51
+ }).catch(() => {
52
+ });
53
+ return promise;
54
+ }
55
+ function validateOpts(opts) {
56
+ if (!opts || typeof opts !== "object") {
57
+ throw new Error("cloneOrFetchPromptsCache: opts must be an object");
58
+ }
59
+ if (!opts.url || typeof opts.url !== "string") {
60
+ throw new Error("cloneOrFetchPromptsCache: url is required");
61
+ }
62
+ if (!opts.ref || typeof opts.ref !== "string") {
63
+ throw new Error("cloneOrFetchPromptsCache: ref is required");
64
+ }
65
+ if (!opts.cacheRoot || typeof opts.cacheRoot !== "string") {
66
+ throw new Error("cloneOrFetchPromptsCache: cacheRoot is required");
67
+ }
68
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,200}$/.test(opts.ref)) {
69
+ throw new Error(
70
+ `cloneOrFetchPromptsCache: ref ${JSON.stringify(opts.ref)} contains characters not allowed in a git refspec`
71
+ );
72
+ }
73
+ }
74
+ function acquireLock(lockPath) {
75
+ (0, import_node_fs2.mkdirSync)((0, import_node_path.dirname)(lockPath), { recursive: true });
76
+ try {
77
+ const fd = (0, import_node_fs2.openSync)(lockPath, "wx");
78
+ (0, import_node_fs2.closeSync)(fd);
79
+ return;
80
+ } catch (err) {
81
+ const e = err;
82
+ if (e.code !== "EEXIST") {
83
+ throw new Error(
84
+ `prompts-cache: could not create lock file ${lockPath}: ${err.message}`
85
+ );
86
+ }
87
+ }
88
+ let lockStat;
89
+ try {
90
+ lockStat = (0, import_node_fs2.statSync)(lockPath);
91
+ } catch (err) {
92
+ try {
93
+ const fd = (0, import_node_fs2.openSync)(lockPath, "wx");
94
+ (0, import_node_fs2.closeSync)(fd);
95
+ return;
96
+ } catch (err2) {
97
+ throw new Error(
98
+ `prompts-cache: lock-file race on ${lockPath}: ${err2.message}`
99
+ );
100
+ }
101
+ }
102
+ const age = Date.now() - lockStat.mtimeMs;
103
+ if (age > LOCK_STALE_MS) {
104
+ (0, import_node_fs2.rmSync)(lockPath, { force: true });
105
+ const fd = (0, import_node_fs2.openSync)(lockPath, "wx");
106
+ (0, import_node_fs2.closeSync)(fd);
107
+ return;
108
+ }
109
+ throw new Error(
110
+ `prompts-cache: refresh already in progress (lock ${lockPath} held, ${Math.round(age / 1e3)}s old)`
111
+ );
112
+ }
113
+ function releaseLock(lockPath) {
114
+ try {
115
+ (0, import_node_fs2.rmSync)(lockPath, { force: true });
116
+ } catch {
117
+ }
118
+ }
119
+ async function refreshInternal(opts) {
120
+ const { url, ref, cacheRoot, deployKeyPath } = opts;
121
+ const env = buildGitEnv(deployKeyPath);
122
+ const tmpPath = `${cacheRoot}.tmp`;
123
+ if ((0, import_node_fs2.existsSync)(tmpPath)) {
124
+ (0, import_node_fs2.rmSync)(tmpPath, { recursive: true, force: true });
125
+ }
126
+ const cacheIsCheckout = (0, import_node_fs2.existsSync)(cacheRoot) && (0, import_node_fs2.existsSync)(`${cacheRoot}/.git`);
127
+ if (cacheIsCheckout) {
128
+ try {
129
+ runGit(cacheRoot, ["remote", "set-url", "origin", url], env);
130
+ runGit(cacheRoot, ["fetch", "--prune", "origin", ref], env);
131
+ runGit(cacheRoot, ["checkout", ref], env);
132
+ runGit(cacheRoot, ["reset", "--hard", `origin/${ref}`], env);
133
+ const commitSha2 = runGit(cacheRoot, ["rev-parse", "HEAD"], env).trim();
134
+ return { commitSha: commitSha2, refreshedAt: (/* @__PURE__ */ new Date()).toISOString() };
135
+ } catch (err) {
136
+ const reason = err instanceof Error ? err.message : String(err);
137
+ process.stderr.write(
138
+ `prompts-cache: in-place fetch failed (${reason}), falling back to atomic rebuild
139
+ `
140
+ );
141
+ }
142
+ }
143
+ (0, import_node_fs2.mkdirSync)((0, import_node_path.dirname)(cacheRoot), { recursive: true });
144
+ runGit((0, import_node_path.dirname)(cacheRoot), ["clone", "--quiet", "--branch", ref, url, tmpPath], env);
145
+ const commitSha = runGit(tmpPath, ["rev-parse", "HEAD"], env).trim();
146
+ if (!/^[0-9a-f]{40}$/.test(commitSha)) {
147
+ throw new Error(
148
+ `prompts-cache: rev-parse HEAD in ${tmpPath} returned non-SHA ${JSON.stringify(commitSha)}`
149
+ );
150
+ }
151
+ if ((0, import_node_fs2.existsSync)(cacheRoot)) {
152
+ const oldPath = `${cacheRoot}.old`;
153
+ if ((0, import_node_fs2.existsSync)(oldPath)) {
154
+ (0, import_node_fs2.rmSync)(oldPath, { recursive: true, force: true });
155
+ }
156
+ (0, import_node_fs2.renameSync)(cacheRoot, oldPath);
157
+ try {
158
+ (0, import_node_fs2.renameSync)(tmpPath, cacheRoot);
159
+ } catch (err) {
160
+ try {
161
+ (0, import_node_fs2.renameSync)(oldPath, cacheRoot);
162
+ } catch {
163
+ }
164
+ throw err;
165
+ }
166
+ (0, import_node_fs2.rmSync)(oldPath, { recursive: true, force: true });
167
+ } else {
168
+ (0, import_node_fs2.renameSync)(tmpPath, cacheRoot);
169
+ }
170
+ return { commitSha, refreshedAt: (/* @__PURE__ */ new Date()).toISOString() };
171
+ }
172
+ function buildGitEnv(deployKeyPath) {
173
+ const env = { ...process.env };
174
+ if (!deployKeyPath) return env;
175
+ if (!(0, import_node_fs2.existsSync)(deployKeyPath)) {
176
+ throw new Error(
177
+ `prompts-cache: deployKeyPath ${deployKeyPath} does not exist \u2014 operator must provision the private SSH key`
178
+ );
179
+ }
180
+ const knownHostsPath = process.env["GIT_SSH_KNOWN_HOSTS"] || defaultKnownHostsPath();
181
+ if (!(0, import_node_fs2.existsSync)(knownHostsPath)) {
182
+ throw new Error(
183
+ `prompts-cache: known-hosts file ${knownHostsPath} does not exist \u2014 image build is missing server/github-known-hosts`
184
+ );
185
+ }
186
+ env["GIT_SSH_COMMAND"] = [
187
+ "ssh",
188
+ "-i",
189
+ quoteForSshCommand(deployKeyPath),
190
+ "-o",
191
+ "StrictHostKeyChecking=yes",
192
+ "-o",
193
+ `UserKnownHostsFile=${quoteForSshCommand(knownHostsPath)}`,
194
+ "-o",
195
+ "IdentitiesOnly=yes"
196
+ ].join(" ");
197
+ return env;
198
+ }
199
+ function quoteForSshCommand(s) {
200
+ return `'${s.replace(/'/g, "'\\''")}'`;
201
+ }
202
+ function runGit(cwd, args, env) {
203
+ try {
204
+ return (0, import_node_child_process.execFileSync)("git", args, {
205
+ cwd,
206
+ env,
207
+ encoding: "utf8",
208
+ stdio: ["ignore", "pipe", "pipe"]
209
+ });
210
+ } catch (err) {
211
+ const e = err;
212
+ const stderr = typeof e.stderr === "string" ? e.stderr : e.stderr?.toString("utf8") ?? "";
213
+ throw new Error(
214
+ `git ${args.join(" ")} (cwd=${cwd}) failed: ${e.message ?? String(err)}${stderr ? `
215
+ stderr: ${stderr.trim()}` : ""}`
216
+ );
217
+ }
218
+ }
219
+
220
+ // src/server/prompts-cache-bootstrap.ts
221
+ var DEFAULT_CACHE_ROOT = "/srv/git/.prompts-cache";
222
+ var DEFAULT_REF = "main";
223
+ async function main() {
224
+ const repoUrl = process.env["STAMP_PROMPTS_REPO_URL"];
225
+ if (!repoUrl) {
226
+ return;
227
+ }
228
+ const ref = process.env["STAMP_PROMPTS_REPO_REF"] || DEFAULT_REF;
229
+ const cacheRoot = process.env["STAMP_PROMPTS_CACHE_ROOT"] || DEFAULT_CACHE_ROOT;
230
+ const deployKeyPath = process.env["STAMP_PROMPTS_DEPLOY_KEY_PATH"] || void 0;
231
+ process.stderr.write(
232
+ `prompts-cache: populating cache at ${cacheRoot} from ${repoUrl}@${ref}` + (deployKeyPath ? ` (deploy key: ${deployKeyPath})` : "") + "\n"
233
+ );
234
+ let result;
235
+ try {
236
+ result = await cloneOrFetchPromptsCache({
237
+ url: repoUrl,
238
+ ref,
239
+ cacheRoot,
240
+ deployKeyPath
241
+ });
242
+ } catch (err) {
243
+ const message = err instanceof Error ? err.message : String(err);
244
+ process.stderr.write(`error: prompts-cache populate failed: ${message}
245
+ `);
246
+ process.exit(1);
247
+ }
248
+ let inventory = "<inventory unavailable>";
249
+ try {
250
+ if ((0, import_node_fs3.existsSync)(cacheRoot) && (0, import_node_fs3.statSync)(cacheRoot).isDirectory()) {
251
+ const entries = (0, import_node_fs3.readdirSync)(cacheRoot).filter((name) => name.endsWith(".md")).filter((name) => {
252
+ try {
253
+ return (0, import_node_fs3.statSync)((0, import_node_path2.join)(cacheRoot, name)).isFile();
254
+ } catch {
255
+ return false;
256
+ }
257
+ }).sort();
258
+ inventory = entries.length > 0 ? entries.join(",") : "<none>";
259
+ }
260
+ } catch (err) {
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ process.stderr.write(`prompts-cache: inventory enumeration failed: ${message}
263
+ `);
264
+ }
265
+ process.stderr.write(
266
+ `prompts-cache: ready (cacheRoot=${cacheRoot} sha=${result.commitSha} files=${inventory})
267
+ `
268
+ );
269
+ }
270
+ main().catch((err) => {
271
+ const message = err instanceof Error ? err.message : String(err);
272
+ process.stderr.write(`error: prompts-cache bootstrap crashed: ${message}
273
+ `);
274
+ process.exit(1);
275
+ });
276
+ //# sourceMappingURL=prompts-cache-bootstrap.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/prompts-cache-bootstrap.ts","../../src/server/prompts-cache.ts","../../src/server/promptFetch.ts"],"sourcesContent":["/**\n * Boot-time entrypoint: populate the prompts cache by calling\n * `cloneOrFetchPromptsCache` exactly once before stamp-server starts\n * accepting traffic. Phase B of the external-prompts-via-webhook\n * initiative (AGT-375). Runs from `server/entrypoint.sh` as root, after\n * `stamp-bootstrap-review-key`, before the HTTP listener launches.\n *\n * Gating:\n * - `STAMP_PROMPTS_REPO_URL` unset → no-op, exit 0. Phase A path\n * (bundled prompts at `/etc/stamp/reviewers/`) remains in effect.\n * The entrypoint must call us unconditionally; we decide whether\n * there's work to do.\n * - `STAMP_PROMPTS_REPO_URL` set → resolve the cache root, invoke\n * `cloneOrFetchPromptsCache`, log the resulting SHA + file\n * inventory, exit 0 on success.\n *\n * Env-var contract:\n *\n * - `STAMP_PROMPTS_REPO_URL` (required when populating cache):\n * HTTPS or SSH git URL of the prompts repo.\n * - `STAMP_PROMPTS_REPO_REF` (optional, default `main`): branch or\n * tag to track.\n * - `STAMP_PROMPTS_CACHE_ROOT` (optional, default\n * `/srv/git/.prompts-cache`): absolute directory path. The parent\n * must exist and be writable; the cache dir itself is created by\n * `cloneOrFetchPromptsCache`. Override in tests.\n * - `STAMP_PROMPTS_DEPLOY_KEY_PATH` (optional): private SSH key for\n * SSH URLs. The entrypoint pre-checks existence with a clearer\n * error before invoking us (AC #2 of AGT-375); we re-pass the\n * value to the module so its own `buildGitEnv` check stays in the\n * loop as defense-in-depth. For HTTPS URLs the module silently\n * ignores this — see `prompts-cache.ts` module header.\n *\n * Output streams: progress + success lines go to stderr (matching\n * `entrypoint.sh`'s convention — its other log lines all redirect to\n * `>&2` so container logs interleave cleanly with sshd). Errors carry\n * the lowercase `error: ` prefix, also to stderr. Exit codes: 0 on\n * success or no-op; 1 on any populate failure (operator must fix\n * synchronously — no fallback to a partial cache).\n *\n * Why a dedicated bootstrap script vs `node -e \"...\"` from bash:\n * cleaner shell quoting (especially the deploy-key path), the\n * existing `stamp-bootstrap-review-key` shape already exists alongside,\n * and adding a one-off `node -e` would bury logic in `entrypoint.sh`\n * where it can't be type-checked.\n */\n\nimport { existsSync, readdirSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport {\n cloneOrFetchPromptsCache,\n type RefreshResult,\n} from \"./prompts-cache.js\";\n\n// ─── Constants ────────────────────────────────────────────────────────\n\n/**\n * Default cache root when `STAMP_PROMPTS_CACHE_ROOT` is unset. Matches\n * the path named in the project README:\n * `<vault>/projects/external-prompt-storage-via-webhook/README.md`.\n * The stamp-server image's persistent volume is mounted at `/srv/git`,\n * so the cache lives there for the same survival-across-redeploy\n * reason as `.ssh-host-keys` and `.stamp-state`.\n */\nconst DEFAULT_CACHE_ROOT = \"/srv/git/.prompts-cache\";\n\n/**\n * Default ref to track when `STAMP_PROMPTS_REPO_REF` is unset. Project\n * convention; documented in the operator-setup README section.\n */\nconst DEFAULT_REF = \"main\";\n\n// ─── Entry point ──────────────────────────────────────────────────────\n\nasync function main(): Promise<void> {\n const repoUrl = process.env[\"STAMP_PROMPTS_REPO_URL\"];\n\n if (!repoUrl) {\n // Phase A path. Stay silent — the inventory of bundled prompts at\n // /etc/stamp/reviewers/ is logged separately by entrypoint.sh, so\n // a second \"no external prompts repo configured\" line here would\n // be noise on every boot of every deployment that hasn't migrated.\n return;\n }\n\n const ref = process.env[\"STAMP_PROMPTS_REPO_REF\"] || DEFAULT_REF;\n const cacheRoot = process.env[\"STAMP_PROMPTS_CACHE_ROOT\"] || DEFAULT_CACHE_ROOT;\n const deployKeyPath = process.env[\"STAMP_PROMPTS_DEPLOY_KEY_PATH\"] || undefined;\n\n // Pre-flight log: surface what we're about to do before the git\n // network round-trip. Operators debugging a slow boot will see this\n // line first and know which env vars resolved to what.\n process.stderr.write(\n `prompts-cache: populating cache at ${cacheRoot} from ${repoUrl}@${ref}` +\n (deployKeyPath ? ` (deploy key: ${deployKeyPath})` : \"\") +\n \"\\n\",\n );\n\n let result: RefreshResult;\n try {\n result = await cloneOrFetchPromptsCache({\n url: repoUrl,\n ref,\n cacheRoot,\n deployKeyPath,\n });\n } catch (err) {\n // The module throws on any condition the operator must fix\n // synchronously — missing deploy key, unreachable remote,\n // unresolvable ref, etc. Surface verbatim with the `error: `\n // prefix and exit non-zero. entrypoint.sh's `set -e` then aborts\n // the boot before sshd or the HTTP listener launch.\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`error: prompts-cache populate failed: ${message}\\n`);\n process.exit(1);\n }\n\n // Inventory log: cache root path + commit SHA + ls of *.md files.\n // The reviewer-prompt inventory shipped by Phase A uses\n // `xargs -n1 basename | paste -sd ','` to render a comma-list on one\n // line; we match that shape so operator log-greps stay uniform across\n // both prompt sources. Failure to enumerate (race with a concurrent\n // refresh wiping the dir, or a perms drift) is best-effort: we still\n // log the SHA so the populate-succeeded signal isn't lost.\n let inventory = \"<inventory unavailable>\";\n try {\n if (existsSync(cacheRoot) && statSync(cacheRoot).isDirectory()) {\n const entries = readdirSync(cacheRoot)\n .filter((name) => name.endsWith(\".md\"))\n .filter((name) => {\n // Hide directory entries that happen to end in .md (unlikely\n // but defensive — readdirSync's withFileTypes path would also\n // work, kept inline for the same simple style as the\n // entrypoint.sh inventory line).\n try {\n return statSync(join(cacheRoot, name)).isFile();\n } catch {\n return false;\n }\n })\n .sort();\n inventory = entries.length > 0 ? entries.join(\",\") : \"<none>\";\n }\n } catch (err) {\n // Non-fatal: the populate succeeded, just couldn't enumerate.\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`prompts-cache: inventory enumeration failed: ${message}\\n`);\n }\n\n process.stderr.write(\n `prompts-cache: ready (cacheRoot=${cacheRoot} sha=${result.commitSha} files=${inventory})\\n`,\n );\n}\n\nmain().catch((err: unknown) => {\n // Safety net — any synchronous throw before the try/catch (e.g. a\n // bad URL shape rejected by validateOpts before the async work\n // starts) bubbles here. Mirror the same `error: ` shape.\n const message = err instanceof Error ? err.message : String(err);\n process.stderr.write(`error: prompts-cache bootstrap crashed: ${message}\\n`);\n process.exit(1);\n});\n","/**\n * Server-side prompts-cache module (AGT-372 — Phase B foundation).\n *\n * Maintains a local git clone of an external \"reviewer prompts\" repo and\n * resolves `(reviewer, org?, repo?)` → on-disk path. Sits underneath\n * `promptFetch.ts`: `cloneOrFetchPromptsCache` populates the cache directory,\n * `getPromptPath` answers per-request \"which file do I read for this reviewer\n * on this repo?\", and `defaultPromptCacheResolver` (Phase A, untouched in this\n * ticket) reads the bytes.\n *\n * Phase A's resolver mapped one (reviewer) → one path. Phase B layers a\n * per-repo override on top: `<cacheRoot>/<org>/<repo>/<reviewer>.md` if it\n * exists, else fall through to `<cacheRoot>/<reviewer>.md`. The lookup is the\n * ONLY filesystem I/O in `getPromptPath` (`existsSync`); the actual read still\n * happens in `fetchCanonicalPrompt` via `readFileSync`, where ENOENT surfaces\n * as `no_such_file`.\n *\n * --- Why a local clone, not a HiveDB-style direct-write ---\n *\n * Phase A had HiveDB writing prompt bytes directly into `STAMP_PROMPTS_DIR`.\n * Phase B replaces that channel with \"operator pushes to a github prompts\n * repo, github webhook fires, stamp-server pulls from origin.\" The cache is\n * a real git working tree (not a bare clone) so `getPromptPath` can answer\n * with a normal filesystem path, and `fetchCanonicalPrompt` keeps its\n * `readFileSync` shape unchanged.\n *\n * --- Atomic refresh contract ---\n *\n * Both \"clone the cache for the first time\" and \"rebuild from scratch after\n * a fetch failure\" go through the atomic pattern:\n *\n * 1. Remove any stale `<cacheRoot>.tmp` from a previous failed attempt.\n * 2. `git clone <url> <cacheRoot>.tmp` (with deploy key wired via\n * GIT_SSH_COMMAND if `deployKeyPath` is set).\n * 3. `git -C <cacheRoot>.tmp checkout <ref>` then `git -C <cacheRoot>.tmp\n * rev-parse HEAD` — proves the ref resolved AND the tree is checked\n * out, before we commit the swap.\n * 4. If `<cacheRoot>` already exists, rename it to `<cacheRoot>.old`,\n * then rename `.tmp` → `<cacheRoot>`, then `rm -rf <cacheRoot>.old`.\n * POSIX guarantees rename is atomic within the same filesystem; the\n * brief window where neither name exists is bounded by two syscalls.\n *\n * A failure at step 2 or 3 leaves `<cacheRoot>` untouched — the existing\n * (last-known-good) cache stays consistent. The `.tmp` debris is cleaned up\n * at the START of the NEXT call rather than on failure, so debugging a\n * broken fetch can inspect the partial state.\n *\n * The faster in-place path (`git fetch && git reset --hard FETCH_HEAD`\n * inside an already-populated `<cacheRoot>`) is also supported and is the\n * common case for webhook-driven refreshes — but if that path fails for\n * any reason, we fall back to the atomic rebuild rather than leaving a\n * half-fetched tree in place.\n *\n * --- Concurrency model ---\n *\n * Two layers:\n *\n * 1. In-process: a `Map<cacheRoot, Promise<RefreshResult>>` coalesces\n * concurrent calls from the same Node process. The second caller awaits\n * the in-flight promise — no parallel `git fetch`, no parallel rename.\n * 2. Cross-process: a sibling lock file at `<cacheRoot>.refresh.lock`\n * (NOT inside the clone — so the atomic rename doesn't move it) acquired\n * via `O_CREAT | O_EXCL`. Stale-lock stealing after 5 minutes (mtime)\n * handles SIGKILL'd processes that didn't release.\n *\n * Webhook bursts (github sometimes fires multiple deliveries within a few\n * hundred ms on a merge) collapse to one fetch; the second caller sees the\n * first's result.\n *\n * --- Deploy-key + known-hosts wiring ---\n *\n * SSH urls (`git@github.com:owner/repo.git`) need a private key on disk\n * (provisioned by the operator into the stamp-server volume — same posture\n * as `stamp-ensure-repo-key`) and a pinned known-hosts file so\n * `StrictHostKeyChecking=yes` doesn't prompt. The known-hosts file ships in\n * the image at `server/github-known-hosts`; we resolve it relative to this\n * module via `import.meta.url` so dev runs and packaged builds both find it.\n * Tests can override via the `GIT_SSH_KNOWN_HOSTS` env var to point at a\n * fixture.\n *\n * HTTPS urls bypass the key entirely — git's TLS does the host\n * verification. `deployKeyPath` is ignored in that case (but a non-existent\n * key path with an HTTPS url is NOT an error — operators may set both env\n * vars and toggle the url without re-deploying).\n */\n\nimport {\n closeSync,\n existsSync,\n mkdirSync,\n openSync,\n renameSync,\n rmSync,\n statSync,\n} from \"node:fs\";\nimport { execFileSync } from \"node:child_process\";\nimport { fileURLToPath } from \"node:url\";\nimport { dirname, resolve as pathResolve } from \"node:path\";\n\nimport { REVIEWER_NAME_RE } from \"./promptFetch.js\";\n\n// ─── Public types ─────────────────────────────────────────────────────\n\n/**\n * Options for `cloneOrFetchPromptsCache`.\n *\n * - `url` — github URL of the prompts repo. HTTPS or SSH.\n * - `ref` — branch or tag to track (e.g. `\"main\"`).\n * - `cacheRoot` — absolute directory path where the cache lives. The\n * parent dir must exist and be writable; the cache dir\n * itself is created/replaced by this function.\n * - `deployKeyPath` — optional path to a private SSH key for SSH URLs.\n * Ignored for HTTPS URLs. The corresponding pubkey\n * must already be registered as a deploy key on the\n * prompts repo.\n */\nexport interface CloneOrFetchOpts {\n url: string;\n ref: string;\n cacheRoot: string;\n deployKeyPath?: string;\n}\n\n/**\n * Returned by `cloneOrFetchPromptsCache`. `commitSha` is the SHA-1 hex of\n * `HEAD` after the refresh — the operator can compare this against the\n * prompts repo's current `<ref>` SHA to detect missed/delayed webhook\n * deliveries. `refreshedAt` is wall-clock ISO-8601 set at completion.\n */\nexport interface RefreshResult {\n commitSha: string;\n refreshedAt: string;\n}\n\n// ─── Constants / shape validation ─────────────────────────────────────\n\n/**\n * Github org/repo slug shape. Stricter than GitHub itself (which allows a\n * leading dot in obscure cases) but matches every name we'd realistically\n * see in a stamp-reviewers-style repo. Reuses the same character class as\n * `REVIEWER_NAME_RE` deliberately — these names get interpolated into a\n * filesystem path, and one canonical \"safe slug\" definition is easier to\n * reason about than two regexes that almost agree.\n */\nconst ORG_REPO_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$/;\n\n/**\n * Stale-lock threshold. Five minutes is comfortably longer than any healthy\n * clone (sub-second to a few seconds even on a slow link for the typical\n * stamp-reviewers repo) and short enough that a SIGKILL'd stamp-server\n * recovers without operator intervention by the next webhook.\n */\nconst LOCK_STALE_MS = 5 * 60 * 1000;\n\n// ─── Resolve the bundled github known-hosts file ──────────────────────\n\n/**\n * Path to `server/github-known-hosts` resolved relative to this module.\n * Used in `GIT_SSH_COMMAND` for SSH clones so `StrictHostKeyChecking=yes`\n * has something to verify against without falling back to the user's\n * `~/.ssh/known_hosts`. Tests override via `GIT_SSH_KNOWN_HOSTS`.\n *\n * Deferred to a function (rather than a module-level constant) so the\n * `import.meta.url` lookup doesn't run at module load. AGT-375's\n * boot-time bootstrap binary consumes this module from a CJS bundle\n * (tsup `format: \"cjs\"`), where `import.meta.url` is undefined and an\n * eager `fileURLToPath(undefined)` would crash the bundle's entry. The\n * lookup is only ever needed inside `buildGitEnv` for SSH URLs, so\n * deferring is both correct and free; ESM consumers see the identical\n * result the first call computes.\n */\nfunction defaultKnownHostsPath(): string {\n return pathResolve(\n dirname(fileURLToPath(import.meta.url)),\n \"..\",\n \"..\",\n \"server\",\n \"github-known-hosts\",\n );\n}\n\n// ─── In-process coalescing map ────────────────────────────────────────\n\n/**\n * Keyed by `cacheRoot` (the absolute path). Holds the in-flight refresh\n * promise so a second concurrent caller awaits the first's result instead\n * of starting a parallel git operation. Cleared when the promise settles\n * (success or failure) — a failed fetch shouldn't poison subsequent\n * attempts.\n *\n * Map (not Set or WeakMap) because the key is a string and we need to look\n * up by value; Map.delete on settle is the cleanup path.\n */\nconst inflightRefreshes = new Map<string, Promise<RefreshResult>>();\n\n// ─── Public API ───────────────────────────────────────────────────────\n\n/**\n * Clone (first run) or fetch+checkout (subsequent runs) the prompts repo\n * into `cacheRoot`. Idempotent: an already-populated cache for the same\n * `ref` resolves to `{ commitSha, refreshedAt }` after a fast no-op fetch.\n *\n * Coalesces concurrent callers via an in-process map AND a file-level lock\n * — see module header for the layered concurrency model. Atomic refresh:\n * mid-fetch failures leave `<cacheRoot>` intact.\n *\n * Throws (rather than returning a typed error) on any condition the operator\n * needs to fix synchronously: unreadable known-hosts file, unwritable parent\n * dir, missing deploy key, git not in PATH, git clone/fetch failure that the\n * atomic rebuild also failed to recover from. The caller (webhook route /\n * entrypoint / periodic poll) decides how to surface these.\n */\nexport async function cloneOrFetchPromptsCache(\n opts: CloneOrFetchOpts,\n): Promise<RefreshResult> {\n validateOpts(opts);\n const cacheRoot = pathResolve(opts.cacheRoot);\n\n // In-process coalescing: if another caller in this process is already\n // refreshing the same cacheRoot, return that promise. The lock file\n // below catches cross-process races; this catches the (much more common)\n // single-process webhook-burst case.\n const existing = inflightRefreshes.get(cacheRoot);\n if (existing) return existing;\n\n const promise = (async (): Promise<RefreshResult> => {\n const lockPath = `${cacheRoot}.refresh.lock`;\n acquireLock(lockPath);\n try {\n return await refreshInternal({ ...opts, cacheRoot });\n } finally {\n releaseLock(lockPath);\n }\n })();\n\n inflightRefreshes.set(cacheRoot, promise);\n // Clear the map slot when the promise settles, success or failure, so\n // a subsequent caller after the failure gets to retry rather than\n // re-awaiting a rejected promise.\n promise.finally(() => {\n if (inflightRefreshes.get(cacheRoot) === promise) {\n inflightRefreshes.delete(cacheRoot);\n }\n }).catch(() => {\n // Swallow: the rejection is already surfaced via the returned `promise`.\n // This .catch attaches a no-op handler to the `.finally`'d branch so\n // node doesn't log an unhandled rejection warning from the bookkeeping\n // chain. The real handler is whoever awaited the returned `promise`.\n });\n\n return promise;\n}\n\n/**\n * Resolve the on-disk path for a `(reviewer, org?, repo?)` triple.\n *\n * - If `org` AND `repo` are both supplied AND\n * `<cacheRoot>/<org>/<repo>/<reviewer>.md` exists on disk,\n * return that path (per-repo override).\n * - Otherwise return `<cacheRoot>/<reviewer>.md` (default fallback).\n *\n * The fallback path is returned EVEN IF IT ALSO DOESN'T EXIST — existence\n * of the default path is `fetchCanonicalPrompt`'s job to check (it surfaces\n * a clean `no_such_file` error). This function only decides which path to\n * try; the read decides whether the prompt is there.\n *\n * All three inputs are validated against the same regex Phase A uses for\n * reviewer names (re-exported as `REVIEWER_NAME_RE` from `promptFetch.ts`).\n * Org/repo names are validated against a slightly broader slug regex that\n * accepts the dot character (github allows `my.org`).\n *\n * Throws on invalid input — by the time we reach the resolver, the SSH\n * verb has already validated the inputs; a violation here is a caller bug,\n * not an attempted injection.\n */\nexport function getPromptPath(\n cacheRoot: string,\n reviewer: string,\n org?: string,\n repo?: string,\n): string {\n if (!cacheRoot || typeof cacheRoot !== \"string\") {\n throw new Error(\"getPromptPath: cacheRoot must be a non-empty string\");\n }\n if (!REVIEWER_NAME_RE.test(reviewer)) {\n throw new Error(\n `getPromptPath: invalid reviewer name '${reviewer}' (must match ${REVIEWER_NAME_RE.source})`,\n );\n }\n const normalized = cacheRoot.endsWith(\"/\") ? cacheRoot.slice(0, -1) : cacheRoot;\n\n // Per-repo override path. Only consult if BOTH org and repo are present\n // and both pass slug validation. If either is malformed, we fall through\n // to the default path rather than throwing — a malformed (org,repo) tuple\n // from upstream is more likely to be \"this verb call didn't carry repo\n // context\" than \"an attacker forged a slug.\" The default-path read will\n // still error cleanly if the prompt isn't there.\n if (org && repo && ORG_REPO_SLUG_RE.test(org) && ORG_REPO_SLUG_RE.test(repo)) {\n const overridePath = `${normalized}/${org}/${repo}/${reviewer}.md`;\n if (existsSync(overridePath)) {\n return overridePath;\n }\n }\n return `${normalized}/${reviewer}.md`;\n}\n\n// ─── Internals ────────────────────────────────────────────────────────\n\nfunction validateOpts(opts: CloneOrFetchOpts): void {\n if (!opts || typeof opts !== \"object\") {\n throw new Error(\"cloneOrFetchPromptsCache: opts must be an object\");\n }\n if (!opts.url || typeof opts.url !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: url is required\");\n }\n if (!opts.ref || typeof opts.ref !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: ref is required\");\n }\n if (!opts.cacheRoot || typeof opts.cacheRoot !== \"string\") {\n throw new Error(\"cloneOrFetchPromptsCache: cacheRoot is required\");\n }\n // Disallow shell metacharacters in `ref` — even though we use execFileSync\n // (no shell), a refspec like `;rm -rf /` would be passed to git verbatim\n // and git's own parser might do something unexpected on certain refspecs.\n // Branch/tag names allow a wide character set; this is the conservative\n // intersection.\n if (!/^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,200}$/.test(opts.ref)) {\n throw new Error(\n `cloneOrFetchPromptsCache: ref ${JSON.stringify(opts.ref)} contains characters not allowed in a git refspec`,\n );\n }\n}\n\n/**\n * Acquire `<cacheRoot>.refresh.lock` via `O_CREAT | O_EXCL`. If the file\n * exists and is stale (older than `LOCK_STALE_MS`), we steal it. Throws\n * with a clear message if a fresh lock is held — caller should treat that\n * as \"another process is already refreshing\" and retry / coalesce upstream.\n *\n * Lives at the PARENT of `cacheRoot` (sibling, not child) so the atomic\n * rename of `<cacheRoot>.tmp` → `<cacheRoot>` doesn't displace it.\n */\nfunction acquireLock(lockPath: string): void {\n // Make sure the parent directory exists before we try to create the lock\n // file. First-run on a fresh server has neither cacheRoot nor its\n // sibling lock present.\n mkdirSync(dirname(lockPath), { recursive: true });\n\n // Fast path — try exclusive create.\n try {\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n } catch (err) {\n const e = err as { code?: string };\n if (e.code !== \"EEXIST\") {\n throw new Error(\n `prompts-cache: could not create lock file ${lockPath}: ${(err as Error).message}`,\n );\n }\n }\n\n // EEXIST — check if it's stale.\n let lockStat;\n try {\n lockStat = statSync(lockPath);\n } catch (err) {\n // The file disappeared between openSync and statSync — extremely\n // unlikely race, retry the create once.\n try {\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n } catch (err2) {\n throw new Error(\n `prompts-cache: lock-file race on ${lockPath}: ${(err2 as Error).message}`,\n );\n }\n }\n\n const age = Date.now() - lockStat.mtimeMs;\n if (age > LOCK_STALE_MS) {\n // Steal the lock — previous holder is dead. `rmSync` then re-create.\n rmSync(lockPath, { force: true });\n const fd = openSync(lockPath, \"wx\");\n closeSync(fd);\n return;\n }\n\n throw new Error(\n `prompts-cache: refresh already in progress (lock ${lockPath} held, ${Math.round(age / 1000)}s old)`,\n );\n}\n\nfunction releaseLock(lockPath: string): void {\n try {\n rmSync(lockPath, { force: true });\n } catch {\n // Best-effort: if the lock is gone for some reason, we don't fail the\n // refresh over it. The next acquire will create afresh.\n }\n}\n\n/**\n * The actual refresh, called under the lock. Two paths:\n *\n * - cacheRoot exists & looks like a git checkout → try in-place fetch\n * (`git fetch` + `git reset --hard FETCH_HEAD`). Cheap, no rename.\n * - cacheRoot missing, or in-place path failed → atomic rebuild via\n * `<cacheRoot>.tmp` → renameSync.\n */\nasync function refreshInternal(\n opts: CloneOrFetchOpts & { cacheRoot: string },\n): Promise<RefreshResult> {\n const { url, ref, cacheRoot, deployKeyPath } = opts;\n const env = buildGitEnv(deployKeyPath);\n const tmpPath = `${cacheRoot}.tmp`;\n\n // Clean any debris from a prior failed attempt up front. We deliberately\n // do NOT clean on failure (see module header) so a broken state can be\n // inspected by the operator before the next refresh wipes it.\n if (existsSync(tmpPath)) {\n rmSync(tmpPath, { recursive: true, force: true });\n }\n\n const cacheIsCheckout = existsSync(cacheRoot) && existsSync(`${cacheRoot}/.git`);\n\n if (cacheIsCheckout) {\n // Force the in-place fetch to use the URL the caller passed, not\n // whatever the .git/config remembers from the original clone. If the\n // operator rotates `STAMP_PROMPTS_REPO_URL` (e.g. HTTPS → SSH after\n // adding a deploy key) the next refresh must honor the new url\n // synchronously rather than continuing to fetch the old one. Also\n // means: if the caller passes a bogus url, the in-place fetch fails\n // here and we fall through to the atomic-rebuild path (which will\n // also fail against the same bogus url, throwing — exactly the\n // behavior the \"mid-fetch failure\" tests expect).\n try {\n runGit(cacheRoot, [\"remote\", \"set-url\", \"origin\", url], env);\n runGit(cacheRoot, [\"fetch\", \"--prune\", \"origin\", ref], env);\n runGit(cacheRoot, [\"checkout\", ref], env);\n runGit(cacheRoot, [\"reset\", \"--hard\", `origin/${ref}`], env);\n const commitSha = runGit(cacheRoot, [\"rev-parse\", \"HEAD\"], env).trim();\n return { commitSha, refreshedAt: new Date().toISOString() };\n } catch (err) {\n // In-place fetch failed (corrupted tree, remote moved, ref renamed,\n // network blip mid-fetch). Don't trust the partial state — fall\n // through to the atomic rebuild. The existing cacheRoot stays in\n // place until step 4 below.\n const reason = err instanceof Error ? err.message : String(err);\n process.stderr.write(\n `prompts-cache: in-place fetch failed (${reason}), falling back to atomic rebuild\\n`,\n );\n }\n }\n\n // Atomic rebuild: clone to .tmp, verify, swap.\n mkdirSync(dirname(cacheRoot), { recursive: true });\n runGit(dirname(cacheRoot), [\"clone\", \"--quiet\", \"--branch\", ref, url, tmpPath], env);\n\n // Belt-and-suspenders: confirm the ref resolved on disk before we commit\n // to the swap. `git clone --branch` would have errored if `ref` didn't\n // exist, but rev-parse catches \"the clone succeeded but the working tree\n // is somehow empty\" — paranoid, cheap.\n const commitSha = runGit(tmpPath, [\"rev-parse\", \"HEAD\"], env).trim();\n if (!/^[0-9a-f]{40}$/.test(commitSha)) {\n throw new Error(\n `prompts-cache: rev-parse HEAD in ${tmpPath} returned non-SHA ${JSON.stringify(commitSha)}`,\n );\n }\n\n // Atomic swap. POSIX rename(2) is atomic within a filesystem; we verified\n // both paths share a parent dir, so they're on the same filesystem by\n // construction.\n if (existsSync(cacheRoot)) {\n const oldPath = `${cacheRoot}.old`;\n if (existsSync(oldPath)) {\n rmSync(oldPath, { recursive: true, force: true });\n }\n renameSync(cacheRoot, oldPath);\n try {\n renameSync(tmpPath, cacheRoot);\n } catch (err) {\n // Restore the old cacheRoot if the second rename somehow fails.\n // Should be impossible on a healthy filesystem — both renames are\n // within the same dir — but if it does happen, the old cache is\n // still recoverable.\n try {\n renameSync(oldPath, cacheRoot);\n } catch {\n // We've now lost the cache. Surface the original error.\n }\n throw err;\n }\n rmSync(oldPath, { recursive: true, force: true });\n } else {\n renameSync(tmpPath, cacheRoot);\n }\n\n return { commitSha, refreshedAt: new Date().toISOString() };\n}\n\n/**\n * Build the env block we pass to git: inherits the parent env, then\n * overlays `GIT_SSH_COMMAND` if we have a deploy key. The known-hosts file\n * resolution lives here so a missing file produces a clear error before\n * git is invoked.\n */\nfunction buildGitEnv(deployKeyPath: string | undefined): NodeJS.ProcessEnv {\n const env = { ...process.env };\n if (!deployKeyPath) return env;\n\n if (!existsSync(deployKeyPath)) {\n throw new Error(\n `prompts-cache: deployKeyPath ${deployKeyPath} does not exist — operator must provision the private SSH key`,\n );\n }\n\n const knownHostsPath = process.env[\"GIT_SSH_KNOWN_HOSTS\"] || defaultKnownHostsPath();\n if (!existsSync(knownHostsPath)) {\n throw new Error(\n `prompts-cache: known-hosts file ${knownHostsPath} does not exist — image build is missing server/github-known-hosts`,\n );\n }\n\n env[\"GIT_SSH_COMMAND\"] = [\n \"ssh\",\n \"-i\",\n quoteForSshCommand(deployKeyPath),\n \"-o\",\n \"StrictHostKeyChecking=yes\",\n \"-o\",\n `UserKnownHostsFile=${quoteForSshCommand(knownHostsPath)}`,\n \"-o\",\n \"IdentitiesOnly=yes\",\n ].join(\" \");\n return env;\n}\n\n/**\n * `GIT_SSH_COMMAND` is parsed by /bin/sh, so paths containing spaces or\n * shell metacharacters would break. The stamp-server image's paths\n * (`/srv/git/...`) never contain such characters, but the dev sandbox or\n * a test fixture might (e.g. `/var/folders/.../T/`). Single-quote and\n * escape any embedded single quotes — same posture as `shell-quote` but\n * inlined to avoid a dependency for one call site.\n */\nfunction quoteForSshCommand(s: string): string {\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Run `git` in `cwd` with the given args, return stdout. Throws on\n * non-zero exit. stderr is included in the thrown error's message so\n * the caller can log it. Uses `execFileSync` (no shell) — the args\n * array is the only injection surface, and we validate `ref` upstream.\n */\nfunction runGit(cwd: string, args: string[], env: NodeJS.ProcessEnv): string {\n try {\n return execFileSync(\"git\", args, {\n cwd,\n env,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n } catch (err) {\n const e = err as { stderr?: Buffer | string; message?: string };\n const stderr = typeof e.stderr === \"string\" ? e.stderr : e.stderr?.toString(\"utf8\") ?? \"\";\n throw new Error(\n `git ${args.join(\" \")} (cwd=${cwd}) failed: ${e.message ?? String(err)}${stderr ? `\\nstderr: ${stderr.trim()}` : \"\"}`,\n );\n }\n}\n\n","/**\n * Server-side canonical reviewer-prompt fetch — the load-bearing security\n * step of server-attested reviews (stamp 2.x).\n *\n * The trust property of server-attested reviews collapses unless the SERVER\n * (not the client) controls which prompt bytes get fed to the LLM. If the\n * client could supply the prompt the substitution attack returns: an\n * operator passes a permissive prompt to the LLM, but embeds the canonical\n * prompt's hash in the attestation, and the verifier sees a perfectly\n * consistent claim about a real LLM call that bears no relationship to what\n * the model actually read.\n *\n * This module is the choke point. AGT-370 moved the prompt source from\n * a server-side bare git clone to a server-side filesystem cache\n * populated out-of-band (HiveDB writes reviewer prompts directly into\n * `STAMP_PROMPTS_DIR`; see the parallel HiveDB reconfig ticket). The\n * fetch reads `${cacheRoot}/<reviewer>.md` synchronously via\n * `fs.readFileSync` — never via `git show`, never with a caller-\n * controlled fallback, never from anywhere outside the resolver-returned\n * path.\n *\n * The AGT-330 SSH-verb handler (and the future HTTP handler) calls this\n * exactly once per review request and pipes the returned bytes directly\n * into the Anthropic system message; the resulting `prompt_sha256` lives\n * inside the `ApprovalV4` body that the server then signs.\n *\n * --- Why no fallback parameter ---\n *\n * The module's surface deliberately offers no way to pass a substitute\n * prompt, an override path, an extra search root, or a \"use this if the\n * fetch fails\" fallback. Adding any such knob — even one gated behind a\n * dev-only flag — would re-open the substitution attack. The whole point\n * of moving the fetch server-side is that the (reviewer_name) tuple is\n * the ONLY input that determines what the server reads. Anything else,\n * by construction, is forbidden.\n *\n * If the fetch fails for any reason (no such file, filesystem error),\n * this module returns a typed error and the verb handler maps it to a\n * clean SSH response. Falling back to a different prompt is forbidden.\n *\n * --- Routing via injected resolver ---\n *\n * The default Phase-1 resolver maps `(reviewer)` → `${cacheRoot}/<reviewer>.md`.\n * Multi-tenant SaaS deployments inject a custom resolver that translates\n * `(reviewer)` into a tenant-aware path (e.g. `<state>/<tenant-id>/<reviewer>.md`);\n * the rest of this module — file read, error mapping, hashing — stays\n * identical. The resolver is a synchronous pure function returning a\n * string path: no I/O (no `existsSync`, no network) — keeping it pure\n * means the handler can pre-compute the path for logging before we hit\n * the fetch, and test injection becomes trivial. Existence checks\n * happen via the file read itself (an absent file surfaces as\n * `no_such_file`).\n *\n * --- Hash convention ---\n *\n * `FetchedPrompt.sha256` is BARE HEX (no `sha256:` prefix), matching\n * `ApprovalV4.prompt_sha256` in `src/lib/attestationV4.ts`. This is the\n * opposite convention from `src/lib/trustedKeysManifest.ts`, which uses\n * `sha256:<hex>` for KEY fingerprints — different field, different\n * convention, do not conflate. The caller folds this value directly into\n * the approval body before canonical serialization + signing.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\n\n// Circular-by-name import (prompts-cache.ts re-exports REVIEWER_NAME_RE\n// from this module): safe because `getPromptPath` is only invoked\n// inside the resolver closure at request time, never at module-load\n// time. ESM resolves the binding lazily — both modules' top-level\n// initialization completes before any closure body runs. See AGT-373\n// for the design rationale (single source of truth for path layout\n// lives in prompts-cache.ts).\nimport { getPromptPath } from \"./prompts-cache.js\";\n\n/**\n * Resolves a `(reviewer, org?, repo?)` tuple to the absolute path of\n * the prompt file on this server's filesystem.\n *\n * AGT-373 (Phase B) widened the signature from `(reviewer) => path` to\n * `(reviewer, org?, repo?) => path` so per-repo prompt overrides\n * (`<cacheRoot>/<org>/<repo>/<reviewer>.md`) can take precedence over\n * the cache-root-level default (`<cacheRoot>/<reviewer>.md`) when both\n * exist. `org` and `repo` are optional so existing Phase A callsites\n * that don't carry repo context still compile — a single-arg call\n * gets the fallback path.\n *\n * Synchronous — but NOT strictly pure under AGT-373: the default\n * resolver calls `existsSync` once to decide between the override and\n * the fallback path. Multi-tenant resolvers MAY remain pure if their\n * tenancy model doesn't need a stat. No thrown errors for \"not\n * found\" — an absent path surfaces naturally as `no_such_file` from\n * the read in `fetchCanonicalPrompt`.\n *\n * The default `defaultPromptCacheResolver` delegates to `getPromptPath`\n * from `prompts-cache.ts`, which validates the reviewer name against\n * `REVIEWER_NAME_RE` (the same regex `src/commands/reviewers.ts`\n * enforces) and the org/repo slugs against a slightly broader shape.\n * Multi-tenant resolvers do whatever path layout their tenancy model\n * requires; they MUST validate any input that gets interpolated into\n * a filesystem path.\n */\nexport type PromptResolver = (\n reviewer: string,\n org?: string,\n repo?: string,\n) => string;\n\n/**\n * The successful fetch result. `bytes` is the raw `.md` file content as\n * read from disk — no normalization, no trimming, no line-ending fixes.\n * Whatever is on disk is what the LLM sees and what the hash binds.\n * `sha256` is bare hex (see file-header doc).\n */\nexport interface FetchedPrompt {\n kind: \"ok\";\n bytes: Buffer;\n /** Hex sha256 of `bytes`. Bare — no `sha256:` prefix. Matches the\n * `ApprovalV4.prompt_sha256` convention in `src/lib/attestationV4.ts`. */\n sha256: string;\n}\n\n/**\n * Typed failure mode. Each `kind` maps to a stable client-facing error\n * category; the verb handler translates these to SSH responses.\n *\n * `detail` is server-side diagnostic surface — log it, do NOT surface it\n * verbatim to the caller (filesystem error messages can leak server\n * filesystem layout). The verb handler should respond with a generic\n * \"<kind>: not available\" message and rely on operator-visible logs for\n * the detail.\n *\n * Categories:\n * - `no_such_file` — resolver returned a path; the file does\n * not exist there. The reviewer isn't\n * provisioned in the prompt cache, or the\n * reviewer name is misspelled. AGT-370:\n * this is the dominant failure now that\n * prompts live in a filesystem cache\n * rather than a bare git repo.\n * - `invalid_input` — `reviewer_name` failed shape validation.\n * Caller bug or attempted injection.\n * - `io_error` — filesystem read failed for a reason\n * other than ENOENT (permission denied,\n * I/O error, file too large, etc.).\n * Operator-actionable.\n */\nexport interface PromptFetchError {\n kind: \"no_such_file\" | \"invalid_input\" | \"io_error\";\n detail: string;\n}\n\nexport type PromptFetchResult = FetchedPrompt | PromptFetchError;\n\n// ─── Limits ─────────────────────────────────────────────────────────\n\n/** Hard cap on a single fetched prompt. Reviewer prompts are normally a\n * few KB; a megabyte is already huge. 1 MB gives plenty of headroom for\n * rich prompts without leaving DoS surface open. */\nconst MAX_PROMPT_BYTES = 1024 * 1024;\n\n// ─── Input validation ───────────────────────────────────────────────\n\n/** Reviewer-name shape; mirrors `VALID_REVIEWER_NAME` in\n * `src/commands/reviewers.ts` so a name that round-trips through the\n * client `reviewers add` UI also round-trips through the server fetch.\n *\n * Exported (AGT-372) so the Phase B `prompts-cache` module can reuse the\n * same canonical source rather than redefining it. The regex is the one\n * thing both modules MUST agree on — a divergence would let a name\n * validate at one layer and reject at the other, opening a confused-deputy\n * path. Keep the export; do not inline a copy. */\nexport const REVIEWER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;\n\n// ─── Public surface ─────────────────────────────────────────────────\n\n/**\n * Build the default single-tenant resolver. `cacheRoot` is the\n * directory holding the reviewer prompt files (e.g. `/etc/stamp/reviewers`\n * for Phase A, or `/srv/git/.prompts-cache` once Phase B's\n * `STAMP_PROMPTS_REPO_URL` is set).\n *\n * `cacheRoot` is taken at resolver-construction time, not at each call,\n * so the verb handler can build the resolver once at startup from the\n * env-resolved cache root and inject it into every request.\n *\n * AGT-373 (Phase B) widened the returned resolver from\n * `(reviewer) => path` to `(reviewer, org?, repo?) => path` and\n * delegates the path-construction logic to `getPromptPath` from\n * `prompts-cache.ts`. The override-vs-default decision (`<cacheRoot>/<org>/<repo>/<reviewer>.md`\n * when that file exists, else `<cacheRoot>/<reviewer>.md`) lives in\n * `getPromptPath` so the resolver and the prompts-cache module agree\n * on layout by construction. Phase A callsites that omit org/repo get\n * the fallback path — they keep compiling, they keep working.\n *\n * AGT-370: replaces the old `defaultRepoResolver` (which mapped `(org,\n * repo)` to a bare-repo path on disk and then used `git show` to read\n * the prompt at `base_sha`). The bare-repo source forced stamp-server\n * to maintain a clone of every reviewed repo, blocking private/internal\n * repos whose code must never leave its git host. The filesystem-cache\n * source decouples the prompt provisioning channel from the review\n * channel — HiveDB (Phase A) / a github prompts repo via webhook\n * (Phase B, AGT-374) writes canonical prompts into the cache\n * out-of-band.\n */\nexport function defaultPromptCacheResolver(cacheRoot: string): PromptResolver {\n if (!cacheRoot || typeof cacheRoot !== \"string\") {\n throw new Error(\"defaultPromptCacheResolver: cacheRoot must be a non-empty string\");\n }\n // The closure captures `cacheRoot` and forwards every call to\n // `getPromptPath`, which owns the trailing-slash normalization, the\n // reviewer-name regex check, the override-vs-fallback decision, and\n // the org/repo slug validation. One source of truth for \"which file\n // do I read for this triple?\" lives in `prompts-cache.ts`; this\n // function only wires the resolver's cacheRoot in.\n return (reviewer: string, org?: string, repo?: string): string =>\n getPromptPath(cacheRoot, reviewer, org, repo);\n}\n\n/**\n * Fetch the canonical reviewer prompt for `reviewerName` from the\n * server's local filesystem cache via the injected `promptResolver`.\n * Returns a discriminated-union result: callers branch on `result.kind`\n * — `\"ok\"` carries the bytes + hash, any other value is a\n * `PromptFetchError`.\n *\n * The flow:\n * 1. Validate `reviewerName` shape.\n * 2. Resolve the prompt path via the injected resolver.\n * 3. `fs.readFileSync(path)` — captures the bytes; ENOENT maps to\n * `no_such_file`, any other error maps to `io_error`.\n * 4. Hash the bytes with SHA-256 (bare hex).\n * 5. Return `{ kind: \"ok\", bytes, sha256 }`.\n *\n * Buffered output is bounded by `MAX_PROMPT_BYTES`. Reviewer prompts in\n * the wild are kilobytes; the cap defends against a future runaway\n * provisioning script that drops a multi-megabyte file at the prompt\n * path.\n *\n * AGT-370 + AGT-373 note: `base_sha` is NOT a prompt-resolution input\n * (server is manifest-blind and base-sha-blind for prompts). It still\n * flows over the SSH wire to populate `ApprovalV4.base_sha`. `org` and\n * `repo` ARE now resolution inputs (AGT-373 Phase B) so the default\n * resolver can pick a `<cacheRoot>/<org>/<repo>/<reviewer>.md`\n * override when one exists. Both are optional — Phase A callsites\n * that omit them keep working, falling back to `<cacheRoot>/<reviewer>.md`.\n */\nexport async function fetchCanonicalPrompt(\n promptResolver: PromptResolver,\n reviewerName: string,\n org?: string,\n repo?: string,\n): Promise<PromptFetchResult> {\n if (!REVIEWER_NAME_RE.test(reviewerName)) {\n return {\n kind: \"invalid_input\",\n detail: `reviewerName must match ${REVIEWER_NAME_RE.source} (got ${JSON.stringify(reviewerName)})`,\n };\n }\n\n let promptPath: string;\n try {\n // Forward org/repo to the resolver. The default resolver\n // (defaultPromptCacheResolver → getPromptPath) consults the\n // override path `<cacheRoot>/<org>/<repo>/<reviewer>.md` if both\n // are present and the file exists, else falls back to\n // `<cacheRoot>/<reviewer>.md`. Custom (multi-tenant) resolvers\n // may ignore the extra args entirely — the wider signature is\n // covariant so older one-arg resolvers stay assignable.\n promptPath = promptResolver(reviewerName, org, repo);\n } catch (err) {\n return {\n kind: \"invalid_input\",\n detail: `resolver rejected (reviewer=${JSON.stringify(reviewerName)}): ${\n err instanceof Error ? err.message : String(err)\n }`,\n };\n }\n\n let bytes: Buffer;\n try {\n bytes = readFileSync(promptPath);\n } catch (err) {\n const e = err as { code?: string; message?: string };\n if (e.code === \"ENOENT\") {\n return {\n kind: \"no_such_file\",\n detail: `prompt cache miss: ${promptPath} does not exist (reviewer=${JSON.stringify(reviewerName)})`,\n };\n }\n return {\n kind: \"io_error\",\n detail: `readFileSync(${promptPath}) failed: ${e.message ?? String(err)}`,\n };\n }\n if (bytes.length > MAX_PROMPT_BYTES) {\n return {\n kind: \"io_error\",\n detail: `prompt file ${promptPath} is ${bytes.length} bytes — exceeds cap (${MAX_PROMPT_BYTES} bytes). Operator: shrink the prompt or raise the cap.`,\n };\n }\n\n const sha256 = createHash(\"sha256\").update(bytes).digest(\"hex\");\n return { kind: \"ok\", bytes, sha256 };\n}\n"],"mappings":"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","names":["import_node_fs","import_node_path","import_node_fs","pathResolve","commitSha"]}
@@ -8820,25 +8820,45 @@ function resolveReviewSigningKeyPath() {
8820
8820
  }
8821
8821
 
8822
8822
  // src/server/promptFetch.ts
8823
- var import_node_fs7 = require("fs");
8823
+ var import_node_fs8 = require("fs");
8824
8824
  var import_node_crypto4 = require("crypto");
8825
+
8826
+ // src/server/prompts-cache.ts
8827
+ var import_node_fs7 = require("fs");
8828
+ var import_node_child_process = require("child_process");
8829
+ var import_node_url = require("url");
8830
+ var import_node_path5 = require("path");
8831
+ var ORG_REPO_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,99}$/;
8832
+ var LOCK_STALE_MS = 5 * 60 * 1e3;
8833
+ function getPromptPath(cacheRoot, reviewer, org, repo) {
8834
+ if (!cacheRoot || typeof cacheRoot !== "string") {
8835
+ throw new Error("getPromptPath: cacheRoot must be a non-empty string");
8836
+ }
8837
+ if (!REVIEWER_NAME_RE.test(reviewer)) {
8838
+ throw new Error(
8839
+ `getPromptPath: invalid reviewer name '${reviewer}' (must match ${REVIEWER_NAME_RE.source})`
8840
+ );
8841
+ }
8842
+ const normalized = cacheRoot.endsWith("/") ? cacheRoot.slice(0, -1) : cacheRoot;
8843
+ if (org && repo && ORG_REPO_SLUG_RE.test(org) && ORG_REPO_SLUG_RE.test(repo)) {
8844
+ const overridePath = `${normalized}/${org}/${repo}/${reviewer}.md`;
8845
+ if ((0, import_node_fs7.existsSync)(overridePath)) {
8846
+ return overridePath;
8847
+ }
8848
+ }
8849
+ return `${normalized}/${reviewer}.md`;
8850
+ }
8851
+
8852
+ // src/server/promptFetch.ts
8825
8853
  var MAX_PROMPT_BYTES = 1024 * 1024;
8826
8854
  var REVIEWER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8827
8855
  function defaultPromptCacheResolver(cacheRoot) {
8828
8856
  if (!cacheRoot || typeof cacheRoot !== "string") {
8829
8857
  throw new Error("defaultPromptCacheResolver: cacheRoot must be a non-empty string");
8830
8858
  }
8831
- const normalized = cacheRoot.endsWith("/") ? cacheRoot.slice(0, -1) : cacheRoot;
8832
- return (reviewer) => {
8833
- if (!REVIEWER_NAME_RE.test(reviewer)) {
8834
- throw new Error(
8835
- `defaultPromptCacheResolver: invalid reviewer name '${reviewer}' (must match ${REVIEWER_NAME_RE.source})`
8836
- );
8837
- }
8838
- return `${normalized}/${reviewer}.md`;
8839
- };
8859
+ return (reviewer, org, repo) => getPromptPath(cacheRoot, reviewer, org, repo);
8840
8860
  }
8841
- async function fetchCanonicalPrompt(promptResolver, reviewerName) {
8861
+ async function fetchCanonicalPrompt(promptResolver, reviewerName, org, repo) {
8842
8862
  if (!REVIEWER_NAME_RE.test(reviewerName)) {
8843
8863
  return {
8844
8864
  kind: "invalid_input",
@@ -8847,7 +8867,7 @@ async function fetchCanonicalPrompt(promptResolver, reviewerName) {
8847
8867
  }
8848
8868
  let promptPath;
8849
8869
  try {
8850
- promptPath = promptResolver(reviewerName);
8870
+ promptPath = promptResolver(reviewerName, org, repo);
8851
8871
  } catch (err) {
8852
8872
  return {
8853
8873
  kind: "invalid_input",
@@ -8856,7 +8876,7 @@ async function fetchCanonicalPrompt(promptResolver, reviewerName) {
8856
8876
  }
8857
8877
  let bytes;
8858
8878
  try {
8859
- bytes = (0, import_node_fs7.readFileSync)(promptPath);
8879
+ bytes = (0, import_node_fs8.readFileSync)(promptPath);
8860
8880
  } catch (err) {
8861
8881
  const e = err;
8862
8882
  if (e.code === "ENOENT") {
@@ -8897,8 +8917,12 @@ function resolveReviewTimeoutMs() {
8897
8917
  if (!Number.isInteger(n) || n <= 0) return DEFAULT_REVIEW_TIMEOUT_MS;
8898
8918
  return n;
8899
8919
  }
8920
+ var PHASE_B_CACHE_ROOT = "/srv/git/.prompts-cache";
8900
8921
  function resolvePromptCacheRoot() {
8901
8922
  warnIfLegacyRepoRootSet();
8923
+ if (process.env["STAMP_PROMPTS_REPO_URL"]) {
8924
+ return PHASE_B_CACHE_ROOT;
8925
+ }
8902
8926
  return process.env["STAMP_PROMPTS_DIR"] || "/etc/stamp/reviewers";
8903
8927
  }
8904
8928
  var legacyRepoRootWarned = false;
@@ -8939,7 +8963,12 @@ var SigningKeyUnavailableError = class extends Error {
8939
8963
  async function runReviewPipeline(input) {
8940
8964
  const deps = input.deps ?? {};
8941
8965
  const resolver = deps.promptResolver ?? defaultPromptCacheResolver(resolvePromptCacheRoot());
8942
- const prompt = await fetchCanonicalPrompt(resolver, input.params.reviewer);
8966
+ const prompt = await fetchCanonicalPrompt(
8967
+ resolver,
8968
+ input.params.reviewer,
8969
+ input.params.org,
8970
+ input.params.repo
8971
+ );
8943
8972
  if (prompt.kind !== "ok") {
8944
8973
  throw new PromptFetchFailedError(prompt.kind, prompt.detail);
8945
8974
  }