@awebai/oats 0.24.13 → 0.25.1

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 (58) hide show
  1. package/bin/oats.mjs +994 -2837
  2. package/docs/capabilities.md +136 -323
  3. package/docs/configuration.md +68 -533
  4. package/docs/conventions.md +51 -24
  5. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +19 -14
  6. package/docs/design/2026-09-16-portable-migration-evidence.md +2 -0
  7. package/docs/design/2026-09-16-portable-onboarding.md +4 -2
  8. package/docs/design/2026-09-20-redesign-program-board.md +1 -1
  9. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +2 -0
  10. package/docs/design/2026-09-23-simplified-workspace-model.md +711 -0
  11. package/docs/design/2026-09-23-workspace-module-contracts.md +460 -0
  12. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +65 -0
  13. package/docs/design/README.md +20 -8
  14. package/docs/design/operations-contract.md +1 -0
  15. package/docs/design/package-engine-contract.md +1 -1
  16. package/docs/design/package-runtime-api.md +1 -1
  17. package/docs/desktop-cli-api.md +356 -5
  18. package/docs/desktop-succession.md +12 -6
  19. package/docs/desktop.md +9 -4
  20. package/docs/execution-targets.md +16 -4
  21. package/docs/first-team.md +107 -224
  22. package/docs/implementation.md +41 -11
  23. package/docs/integrations.md +50 -47
  24. package/docs/knowledge-capability-authoring.md +10 -4
  25. package/docs/knowledge-migration.md +21 -12
  26. package/docs/knowledge-reference/package-craft.md +11 -3
  27. package/docs/knowledge.md +60 -18
  28. package/docs/layers.md +3 -3
  29. package/docs/migration-from-oas.md +20 -9
  30. package/docs/oats-local.schema.json +50 -0
  31. package/docs/oats-membership.schema.json +23 -0
  32. package/docs/oats-workspace.schema.json +133 -48
  33. package/docs/official-marketplace.md +9 -6
  34. package/docs/packages.md +229 -440
  35. package/docs/rebuild-to-v2.md +347 -0
  36. package/docs/release-notes/v0.25.0.md +99 -0
  37. package/docs/release-notes/v0.25.1.md +94 -0
  38. package/docs/schedules.md +12 -6
  39. package/docs/soul.schema.json +41 -68
  40. package/docs/souls-and-instances.md +175 -108
  41. package/docs/workspace-adoption.md +70 -345
  42. package/docs/workspaces.md +436 -119
  43. package/lib/core.mjs +462 -61
  44. package/lib/instance-resolution.mjs +387 -0
  45. package/lib/materialize.mjs +580 -0
  46. package/lib/operator-dispatch.mjs +117 -0
  47. package/lib/packages.mjs +558 -1269
  48. package/lib/remote.mjs +718 -0
  49. package/lib/resolve.mjs +638 -0
  50. package/lib/schedule.mjs +90 -16
  51. package/lib/workspace.mjs +654 -0
  52. package/package.json +1 -1
  53. package/lib/portable-migration-artifacts.mjs +0 -135
  54. package/lib/portable-migration-evidence.mjs +0 -305
  55. package/lib/portable-migration-store.mjs +0 -199
  56. package/lib/portable-migration.mjs +0 -104
  57. package/lib/portable-onboarding-acceptance.mjs +0 -66
  58. package/lib/setup-expert-source.mjs +0 -100
package/lib/remote.mjs ADDED
@@ -0,0 +1,718 @@
1
+ /** lib/remote.mjs — observe Git remotes in the operator's own access context
2
+ * (workspace model v2, module contract §1).
3
+ *
4
+ * APPROACH (one approach, used for every remote kind — local bare repos and
5
+ * https/ssh remotes alike):
6
+ * 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
7
+ * never a fetch when the caller already gave a full OID.
8
+ * 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
9
+ * commit's objects locally. `ensureCommit` does a shallow
10
+ * `git fetch --depth 1 --no-tags <url> <oid>` into a content-addressed BARE
11
+ * cache repo under `<cacheRoot>/<sha256(key)>/` and then pins the commit with
12
+ * `refs/oats/commits/<oid>` so `gc` cannot prune it. The pin doubles as the
13
+ * "already fetched" marker: a pinned commit is never fetched again.
14
+ * 3. Reads are then local plumbing: `git ls-tree -r -t -l -z` for listing and
15
+ * `git cat-file blob` for bytes. `git archive --remote` is NOT used: GitHub and
16
+ * most https hosts refuse it, and per-entry plumbing lets us inspect every
17
+ * mode (symlink / submodule / oversize) BEFORE anything touches the disk.
18
+ *
19
+ * DEVIATION FROM THE CONTRACT (named on purpose, not silently resolved): the
20
+ * contract says "shallow git fetch --depth 1 --filter=blob:none". A blob-less
21
+ * partial fetch would make every later `cat-file` a lazy per-blob network
22
+ * round-trip through the promisor machinery, and topping a filtered commit up
23
+ * to a full one afterwards requires `--refetch` semantics that vary by server.
24
+ * We fetch depth-1 WITHOUT a blob filter: one round-trip per commit, blobs
25
+ * present, correct on every server that allows fetching an advertised OID.
26
+ *
27
+ * The cache is invisible plumbing: it may be wiped at any time (a wiped cache
28
+ * simply re-fetches), and nothing outside this module references it.
29
+ *
30
+ * Nothing here ever prompts: GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=/usr/bin/false,
31
+ * ssh ALWAYS in BatchMode — `-o BatchMode=yes` is appended to the operator's own
32
+ * GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call.
33
+ *
34
+ * KEY vs URL (post-0.25.0 fix M2): the canonical KEY (`<host>/<path>`, lowercase
35
+ * host, no scheme, no `.git`) is the identity everywhere — the same repo written
36
+ * as `git@host:org/repo.git`, `ssh://git@host/org/repo`, `https://host/org/repo`
37
+ * or `git:host/org/repo` is ONE key and ONE cache repo. The FETCH URL honours the
38
+ * form written: an ssh form (`git@…`, `ssh://…`) is fetched over ssh exactly as
39
+ * written, so the operator's ssh access (keys, agent, config) is what is used;
40
+ * an `https://` form is fetched over https; the bare `git:host/path` scheme
41
+ * defaults to https and fetches over ssh (`git@host:path.git`) when
42
+ * `remoteOptions.transport === "ssh"`. Nothing compares URLs; everything compares
43
+ * keys.
44
+ *
45
+ * COMMIT vs TAG OID (post-0.25.0 fix M4): an `at` naming an annotated tag's own
46
+ * OID is accepted, but the commit recorded (observeRemote result, error details,
47
+ * cache pin) is ALWAYS the peeled commit (`rev-parse <oid>^{commit}`).
48
+ *
49
+ * TIMEOUT vs OVERFLOW (post-0.25.0 fix L4): `error.timedOut` is set only for the
50
+ * timeout kill; a `maxBuffer` overflow (`ERR_CHILD_PROCESS_STDIO_MAXBUFFER`) is
51
+ * `error.overflowed`. A listing failure git does not explain is never a raw
52
+ * Node error escaping this module: it is E_REMOTE_UNREADABLE { reason: "unknown" }.
53
+ *
54
+ * TREE SAFETY: every entry name git reports is validated BEFORE anything touches
55
+ * the disk. A component that is empty, `.`, `..`, `.git` (any case) or contains
56
+ * `\` / NUL is E_REMOTE_TREE_UNSAFE { why: "path" } — a crafted tree object can
57
+ * carry such names (the transport does not fsck them), and `join()` would
58
+ * happily normalize `../../x` out of the staging directory. Entries that would
59
+ * collide on a case-/normalization-insensitive filesystem (README.md vs
60
+ * readme.md, NFC vs NFD) are E_REMOTE_TREE_UNSAFE { why: "collision" }.
61
+ *
62
+ * CONCURRENCY: per-key operations on one cache repo are serialized in-process
63
+ * (two observes of the same remote never race `git init` or `fetch`), and a
64
+ * fetch that loses an on-disk `.lock` race to another process is retried.
65
+ *
66
+ * CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
67
+ * pruned object store is detected (`rev-parse <oid>^{commit}`) and refetched;
68
+ * the pin never turns a stale cache into a claim about the remote. The object
69
+ * must PEEL to a COMMIT: `at` naming a tree/blob (or a tag of one) is E_REMOTE_UNREADABLE.
70
+ *
71
+ * CONTENT DIGEST (canonical framing, shared by fetchRemoteTree and contentDigest):
72
+ * sha256 over the concatenation, for every REGULAR FILE sorted by relpath
73
+ * (byte-wise UTF-8 order, "/" separated, relative to the tree root), of
74
+ * "F" NUL relpath NUL mode-octal NUL size-decimal NUL bytes NUL
75
+ * where mode-octal is the GIT-NORMALIZED mode: "755" when the owner-exec bit
76
+ * is set, else "644" (Git stores only 100644/100755, so a local checkout under
77
+ * any umask digests identically to the fetched tree). Empty directories do not
78
+ * enter identity. Result: "sha256-<hex>". Empty tree → sha256 of the empty input.
79
+ */
80
+ import { execFile, execFileSync } from "node:child_process";
81
+ import { createHash } from "node:crypto";
82
+ import {
83
+ closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
84
+ writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
85
+ import { homedir } from "node:os";
86
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
87
+ import { fileURLToPath } from "node:url";
88
+ import { oatsError } from "./errors.mjs";
89
+
90
+ export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
91
+ export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
92
+ export const GIT_TIMEOUT_MS = 30_000;
93
+ const OID_RE = /^[0-9a-f]{40}$/;
94
+ const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
95
+ /** A ref name we are willing to hand to `git ls-remote` as a pattern: never a leading
96
+ * dash (option injection), no whitespace/control chars, no revision syntax (`^{`, `@{`, `~`,
97
+ * `:`), no `..`, no glob chars, no `\`, no `.lock` component, no leading/trailing `/`. */
98
+ const AT_BAD_RE = /^[-/]|\/$|\.\.|[\s~^:?*[\\\x00-\x1f\x7f]|@\{|\/\.|^\.|\.lock(?:\/|$)|\/\//;
99
+ /** Tree entry-name components that must never reach the filesystem. */
100
+ const BAD_COMPONENT_RE = /^(?:|\.|\.\.|\.git)$/i;
101
+
102
+ /** oatsError with the details reachable as BOTH e.provenance (today's field) and e.details. */
103
+ function fail(code, message, details) {
104
+ const e = oatsError(code, message, details);
105
+ if (details) e.details = details;
106
+ return e;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // parseRepoRef
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function repoPathSegments(rawPath, text) {
114
+ const cleaned = rawPath.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
115
+ const parts = cleaned.split("/");
116
+ if (parts.length < 2 || parts.some((p) => !SEGMENT_RE.test(p) || p === "." || p === "..")) {
117
+ throw fail("E_REPO_REF", `repository reference has an invalid path: ${text}`, { ref: text, path: rawPath });
118
+ }
119
+ return parts.join("/");
120
+ }
121
+
122
+ const TRANSPORTS = new Set(["https", "ssh"]);
123
+
124
+ /**
125
+ * A hosted ref. `fetchUrl` is the url the FORM WRITTEN asks for (null → derive from
126
+ * `transport`): ssh forms are kept verbatim (the operator's ssh access is what must be
127
+ * used), https forms are canonicalised, and the bare `git:` scheme follows `transport`.
128
+ */
129
+ function hostedRef(host, rawPath, text, { fetchUrl = null, transport = "https" } = {}) {
130
+ const h = host.toLowerCase();
131
+ if (!/^[a-z0-9.-]+$/.test(h)) throw fail("E_REPO_REF", `repository reference has an invalid host: ${text}`, { ref: text, host });
132
+ const path = repoPathSegments(rawPath, text);
133
+ const url = fetchUrl ?? (transport === "ssh" ? `git@${h}:${path}.git` : `https://${h}/${path}.git`);
134
+ return Object.freeze({ host: h, path, url, key: `${h}/${path}` });
135
+ }
136
+
137
+ function transportOf(options) {
138
+ const t = options?.transport;
139
+ if (t === undefined || t === null) return "https";
140
+ if (!TRANSPORTS.has(t)) throw fail("E_REPO_REF", `remoteOptions.transport must be "https" or "ssh", got ${JSON.stringify(t)}`, { transport: t });
141
+ return t;
142
+ }
143
+
144
+ function localRef(absPath) {
145
+ const normalized = resolve(absPath).replace(/[\\/]+$/, "") || sep;
146
+ return Object.freeze({ host: "local", path: normalized, url: normalized, key: `local/${normalized}` });
147
+ }
148
+
149
+ /**
150
+ * "git:github.com/org/repo(.git)" | "https://github.com/org/repo(.git)" | "git@github.com:org/repo(.git)"
151
+ * | "ssh://[user@]github.com[:port]/org/repo(.git)" | "/abs/path/to/bare.git" | "file:///abs/path"
152
+ * → { host, path, url, key } | throws E_REPO_REF.
153
+ * `key` ("<host>/<path>") is the identity: every form of one repo gives the same key.
154
+ * `url` is what git fetches: ssh forms verbatim, https canonical, the bare `git:` scheme
155
+ * per `options.transport` ("https" default | "ssh" → `git@<host>:<path>.git`).
156
+ * Already-parsed refs (objects with key+url) pass through once re-validated: the
157
+ * object's `url` must itself parse to the same `key` (no smuggled `ext::` urls or `../` keys).
158
+ */
159
+ export function parseRepoRef(text, options = undefined) {
160
+ const transport = transportOf(options);
161
+ if (text && typeof text === "object" && typeof text.key === "string" && typeof text.url === "string") {
162
+ const again = parseRepoRef(text.url);
163
+ if (again.key !== text.key) throw fail("E_REPO_REF", `repository reference object is inconsistent: key ${text.key} does not match url ${text.url}`, { ref: text.key, url: text.url });
164
+ return text;
165
+ }
166
+ if (typeof text !== "string" || !text.trim()) throw fail("E_REPO_REF", "repository reference must be a non-empty string", { ref: text });
167
+ const ref = text.trim();
168
+ let m;
169
+ if ((m = /^git:([^/:@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref, { transport });
170
+ if ((m = /^https?:\/\/([^/@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
171
+ // ssh forms: the url is kept AS WRITTEN (user, port, host case) — it is the operator's access.
172
+ if ((m = /^git@([^:/\s]+):(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref, { fetchUrl: ref });
173
+ if ((m = /^ssh:\/\/(?:[^@/\s]+@)?([^/:@\s]+)(?::\d+)?\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref, { fetchUrl: ref });
174
+ if (/^file:\/\//.test(ref)) {
175
+ try { return localRef(fileURLToPath(ref)); } catch { throw fail("E_REPO_REF", `invalid file URL: ${ref}`, { ref }); }
176
+ }
177
+ if (isAbsolute(ref)) return localRef(ref);
178
+ throw fail("E_REPO_REF", `unrecognized repository reference: ${ref}`, { ref });
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // git plumbing
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /** The operator's own ssh command (GIT_SSH_COMMAND, else core.sshCommand, else `ssh`),
186
+ * ALWAYS with `-o BatchMode=yes` appended so ssh can never prompt for a passphrase or
187
+ * host key. Resolved once per process; `-o` after the operator's flags is valid for ssh
188
+ * and for every ssh-compatible wrapper that forwards its argv. */
189
+ let sshCommandCache;
190
+ export function sshCommand() {
191
+ if (sshCommandCache !== undefined) return sshCommandCache;
192
+ let base = process.env.GIT_SSH_COMMAND?.trim();
193
+ if (!base) {
194
+ try { base = execFileSync("git", ["config", "--get", "core.sshCommand"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim(); } catch { base = ""; }
195
+ }
196
+ if (!base) base = "ssh";
197
+ sshCommandCache = /(?:^|\s)-o\s*BatchMode=yes(?:\s|$)/i.test(base) ? base : `${base} -o BatchMode=yes`;
198
+ return sshCommandCache;
199
+ }
200
+
201
+ function gitEnv() {
202
+ return {
203
+ ...process.env,
204
+ GIT_TERMINAL_PROMPT: "0",
205
+ GIT_ASKPASS: "/usr/bin/false",
206
+ GIT_SSH_COMMAND: sshCommand(),
207
+ GIT_LITERAL_PATHSPECS: "1",
208
+ };
209
+ }
210
+
211
+ /** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
212
+ * rejects with { code, signal, killed, stderr, stdout, timedOut, overflowed }.
213
+ * timedOut — the `timeout` kill (Node reports killed=true + our killSignal, no error.code);
214
+ * overflowed — stdout/stderr exceeded `maxBuffer` (Node also kills the child, but sets
215
+ * error.code = ERR_CHILD_PROCESS_STDIO_MAXBUFFER): NOT a timeout.
216
+ * Injectable via options.exec. */
217
+ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS } = {}) {
218
+ return new Promise((resolvePromise, reject) => {
219
+ execFile("git", args, { cwd, env: gitEnv(), timeout, maxBuffer, shell: false, encoding: "buffer", killSignal: "SIGKILL" },
220
+ (error, stdout, stderr) => {
221
+ if (error) {
222
+ error.stdout = stdout; error.stderr = stderr;
223
+ error.overflowed = error.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
224
+ || (Buffer.isBuffer(stdout) && stdout.length >= maxBuffer);
225
+ error.timedOut = !error.overflowed && (error.killed === true || error.signal === "SIGKILL");
226
+ reject(error);
227
+ } else resolvePromise({ stdout, stderr });
228
+ });
229
+ });
230
+ }
231
+
232
+ function stderrText(error) {
233
+ const s = error?.stderr;
234
+ return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
235
+ }
236
+
237
+ /** Classify a failed network git call into the contract's four reasons. A `maxBuffer`
238
+ * overflow is never a timeout (the child is killed in both cases; only the timeout kill
239
+ * counts) — it falls through to the stderr text, else "network". */
240
+ export function classifyRemoteFailure(error) {
241
+ if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return classifyText(error) ?? "network";
242
+ if (error?.timedOut || error?.signal === "SIGKILL" || error?.signal === "SIGTERM") return "timeout";
243
+ return classifyText(error) ?? "network";
244
+ }
245
+
246
+ /** The stderr-text half of classifyRemoteFailure; null when the text says nothing recognisable. */
247
+ function classifyText(error) {
248
+ const text = stderrText(error).toLowerCase();
249
+ if (/authentication failed|could not read username|could not read password|permission denied|publickey|403|forbidden|terminal prompts disabled|invalid username or password|access denied|unauthori[sz]ed/.test(text)) return "auth";
250
+ if (/repository not found|repository '.*' not found|not found|does not appear to be a git repository|no such file or directory|not our ref|unadvertised object|couldn't find remote ref|remote ref .* not found|not a valid object name|not a tree object|bad object|is not a valid revision|no such ref/.test(text)) return "not-found";
251
+ if (/could not resolve host|connection refused|connection timed out|network is unreachable|unable to access|early eof|remote end hung up|connection reset|ssl|tls|unable to connect/.test(text)) return "network";
252
+ return null;
253
+ }
254
+
255
+ function unreadable(ref, error, extra = {}) {
256
+ const reason = classifyRemoteFailure(error);
257
+ return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})`, { url: ref.url, key: ref.key, reason, ...extra });
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // cache
262
+ // ---------------------------------------------------------------------------
263
+
264
+ function defaultCacheRoot() { return join(homedir(), ".cache", "oats", "remotes"); }
265
+
266
+ /** Per-cache-dir in-process mutex: operations on one bare cache repo run one at a time. */
267
+ const cacheLocks = new Map();
268
+ async function withCacheLock(dir, fn) {
269
+ const previous = cacheLocks.get(dir) ?? Promise.resolve();
270
+ let release;
271
+ const mine = new Promise((r) => { release = r; });
272
+ cacheLocks.set(dir, previous.then(() => mine));
273
+ await previous;
274
+ try { return await fn(); } finally {
275
+ release();
276
+ if (cacheLocks.get(dir) === mine) cacheLocks.delete(dir);
277
+ }
278
+ }
279
+
280
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
281
+ const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running/i.test(stderrText(error));
282
+
283
+ async function cacheRepo(ref, options) {
284
+ const exec = options.exec ?? runGit;
285
+ const root = options.cacheDir ?? defaultCacheRoot();
286
+ const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
287
+ if (!existsSync(join(dir, "HEAD"))) {
288
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
289
+ try { await exec(["init", "-q", "--bare", dir]); }
290
+ catch (error) {
291
+ // Another process may have won the init race; a usable repo is all we need.
292
+ if (!existsSync(join(dir, "HEAD"))) throw unreadable(ref, error, { cacheDir: dir, stage: "init" });
293
+ }
294
+ try { writeFileSync(join(dir, "oats-remote.json"), JSON.stringify({ key: ref.key, url: ref.url }, null, 2) + "\n", { flag: "wx" }); } catch {}
295
+ }
296
+ const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...opts });
297
+ return { dir, exec, local };
298
+ }
299
+
300
+ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
301
+
302
+ /** The COMMIT <oid> peels to, when <oid> is present in the cache and is a commit or an
303
+ * annotated tag chain ending in one; null on a missing object, a pruned store, or a
304
+ * tag/tree/blob that does not peel to a commit. (`rev-parse <oid>^{commit}` peels.) */
305
+ async function peelCommit(repo, oid) {
306
+ try {
307
+ const out = (await repo.local(["rev-parse", "--verify", "-q", `${oid}^{commit}`])).stdout.toString("utf8").trim();
308
+ return OID_RE.test(out) ? out : null;
309
+ } catch { return null; }
310
+ }
311
+
312
+ async function objectType(repo, oid) {
313
+ try { return (await repo.local(["cat-file", "-t", oid])).stdout.toString("utf8").trim(); } catch { return null; }
314
+ }
315
+
316
+ /**
317
+ * Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one) and all
318
+ * its trees/blobs are present in the cache. → { repo, commit } where `commit` is the PEELED
319
+ * commit: a tag OID given as `at` is accepted, but the commit recorded everywhere is the
320
+ * commit it points to (fix M4). The pin is on the peeled commit; a tag object gets its own
321
+ * `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
322
+ */
323
+ async function ensureCommit(ref, oid, options) {
324
+ const root = options.cacheDir ?? defaultCacheRoot();
325
+ const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
326
+ return withCacheLock(dir, async () => {
327
+ const repo = await cacheRepo(ref, options);
328
+ const cached = await peelCommit(repo, oid);
329
+ if (cached) return { repo, commit: cached }; // pinned AND present AND (peels to) a commit
330
+ let lastError;
331
+ for (let attempt = 0; attempt < 3; attempt++) {
332
+ try {
333
+ await repo.local(["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ref.url, oid]);
334
+ lastError = null;
335
+ break;
336
+ } catch (error) {
337
+ lastError = error;
338
+ if (!isLockRace(error) || error.timedOut) break;
339
+ await sleep(100 * (attempt + 1) ** 2);
340
+ }
341
+ }
342
+ if (lastError) throw unreadable(ref, lastError, { commit: oid });
343
+ const commit = await peelCommit(repo, oid);
344
+ if (!commit) {
345
+ const type = await objectType(repo, oid);
346
+ throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
347
+ }
348
+ await repo.local(["update-ref", pinRef(commit), commit]);
349
+ if (commit !== oid) await repo.local(["update-ref", `refs/oats/tags/${oid}`, oid]);
350
+ return { repo, commit };
351
+ });
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // observeRemote
356
+ // ---------------------------------------------------------------------------
357
+
358
+ function requireCommit(commit) {
359
+ if (typeof commit !== "string" || !OID_RE.test(commit)) throw fail("E_REPO_REF", "commit must be a full 40-hex OID", { commit });
360
+ return commit;
361
+ }
362
+
363
+ function parseLsRemote(stdout) {
364
+ const lines = stdout.toString("utf8").split("\n").filter(Boolean);
365
+ const symrefs = new Map(), oids = new Map();
366
+ for (const line of lines) {
367
+ const [left, right] = line.split("\t");
368
+ if (left.startsWith("ref: ")) symrefs.set(right, left.slice(5));
369
+ else oids.set(right, left);
370
+ }
371
+ return { symrefs, oids };
372
+ }
373
+
374
+ function resolveAt(parsed, at) {
375
+ if (at === undefined || at === null || at === "" || at === "HEAD") {
376
+ const oid = parsed.oids.get("HEAD");
377
+ return oid ? { commit: oid, ref: parsed.symrefs.get("HEAD") ?? null } : null;
378
+ }
379
+ const candidates = [`refs/tags/${at}^{}`, `refs/tags/${at}`, `refs/heads/${at}`, at, `${at}^{}`];
380
+ for (const name of candidates) {
381
+ const oid = parsed.oids.get(name);
382
+ if (oid) {
383
+ const bare = name.replace(/\^\{\}$/, "");
384
+ return { commit: oid, ref: bare.startsWith("refs/") ? bare : null };
385
+ }
386
+ }
387
+ return null;
388
+ }
389
+
390
+ /**
391
+ * at: undefined → remote default branch (ls-remote --symref HEAD); a full OID; or a tag/branch name.
392
+ * → { key, url, commit, ref, observedAt } | throws E_REMOTE_UNREADABLE { url, reason }.
393
+ * Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
394
+ */
395
+ export async function observeRemote(refText, { at, ...options } = {}) {
396
+ const ref = parseRepoRef(refText, options);
397
+ const exec = options.exec ?? runGit;
398
+ if (at !== undefined && at !== null && typeof at !== "string") throw fail("E_REPO_REF", "at must be a string (full OID, tag or branch name)", { at });
399
+ if (typeof at === "string" && OID_RE.test(at)) {
400
+ // A tag OID is accepted here; the commit recorded is the one it peels to (M4).
401
+ const { commit } = await ensureCommit(ref, at, options);
402
+ return { key: ref.key, url: ref.url, commit, ref: null, observedAt: new Date().toISOString() };
403
+ }
404
+ const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
405
+ if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
406
+ const args = ["ls-remote", "--symref", ref.url];
407
+ if (wantHead) args.push("HEAD");
408
+ else args.push(`refs/tags/${at}`, `refs/tags/${at}^{}`, `refs/heads/${at}`, at);
409
+ let out;
410
+ try { out = await exec(args, { timeout: GIT_TIMEOUT_MS }); }
411
+ catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
412
+ const parsed = parseLsRemote(out.stdout);
413
+ const hit = resolveAt(parsed, at);
414
+ if (!hit) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} has no ref matching ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
415
+ if (!OID_RE.test(hit.commit)) throw fail("E_REMOTE_UNREADABLE", `remote ${ref.url} returned a non-OID for ${at ?? "HEAD"}`, { url: ref.url, key: ref.key, reason: "not-found", at: at ?? null });
416
+ const { commit } = await ensureCommit(ref, hit.commit, options);
417
+ return { key: ref.key, url: ref.url, commit, ref: hit.ref, observedAt: new Date().toISOString() };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // tree reading
422
+ // ---------------------------------------------------------------------------
423
+
424
+ function normalizeTreePath(path, { allowRoot }) {
425
+ if (path === undefined || path === null || path === "" || path === ".") {
426
+ if (allowRoot) return "";
427
+ throw fail("E_REPO_REF", "path must name a file, not the tree root", { path });
428
+ }
429
+ if (typeof path !== "string") throw fail("E_REPO_REF", "path must be a string", { path });
430
+ const parts = path.replace(/\\/g, "/").split("/").filter((p) => p !== "" && p !== ".");
431
+ if (parts.length === 0) { if (allowRoot) return ""; throw fail("E_REPO_REF", "path must name a file", { path }); }
432
+ if (parts.some((p) => p === "..")) throw fail("E_REPO_REF", "path must be relative and must not escape the tree", { path });
433
+ return parts.join("/");
434
+ }
435
+
436
+ /** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. */
437
+ function parseLsTree(stdout) {
438
+ const entries = [];
439
+ for (const record of stdout.toString("utf8").split("\0")) {
440
+ if (!record) continue;
441
+ const tab = record.indexOf("\t");
442
+ const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
443
+ const [mode, type, oid, size] = meta;
444
+ entries.push({ mode, type, oid, size: size === "-" ? null : Number(size), path });
445
+ }
446
+ return entries;
447
+ }
448
+
449
+ /** Refuse any entry whose name could escape or subvert a checkout: a component that is
450
+ * empty, `.`, `..`, `.git` (any case), or a name containing `\` or NUL. Git's transport does
451
+ * not fsck tree entry names, so a hostile remote can serve them. */
452
+ function assertSafeEntryPath(entryPath, ref, commit, shown = entryPath) {
453
+ const bad = /[\\\x00]/.test(entryPath) || entryPath.split("/").some((c) => BAD_COMPONENT_RE.test(c));
454
+ if (bad) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has an unsafe entry name`, { path: shown, why: "path", key: ref.key, commit });
455
+ }
456
+
457
+ /** Entries that would collide on a case- or normalization-insensitive filesystem (APFS, NTFS). */
458
+ function assertNoCollisions(entries, ref, commit, prefix) {
459
+ const seen = new Map();
460
+ for (const e of entries) {
461
+ const folded = e.path.normalize("NFC").toLowerCase();
462
+ const other = seen.get(folded);
463
+ if (other !== undefined && other !== e.path) {
464
+ const shown = prefix ? `${prefix}/${e.path}` : e.path;
465
+ throw fail("E_REMOTE_TREE_UNSAFE", `${shown} collides with ${prefix ? `${prefix}/${other}` : other} on a case-insensitive filesystem`, { path: shown, why: "collision", other: prefix ? `${prefix}/${other}` : other, key: ref.key, commit });
466
+ }
467
+ seen.set(folded, e.path);
468
+ }
469
+ }
470
+
471
+ /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
472
+ * Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, else
473
+ * "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
474
+ * row and would otherwise abort the whole discovery on one unexplained listing (L4). */
475
+ async function lsTree(repo, spec, { flags = [], path, ref, commit } = {}) {
476
+ try {
477
+ const args = ["ls-tree", "-l", "-z", ...flags, spec];
478
+ if (path !== undefined) args.push("--", path);
479
+ const out = await repo.local(args, { maxBuffer: 64 * 1024 * 1024 });
480
+ return parseLsTree(out.stdout);
481
+ } catch (error) {
482
+ if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
483
+ const text = stderrText(error).toLowerCase();
484
+ if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
485
+ const reason = error?.timedOut ? "timeout" : "unknown";
486
+ const why = error?.overflowed ? "listing exceeded the output budget" : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
487
+ throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true });
488
+ }
489
+ }
490
+
491
+ /** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
492
+ async function entryAt(repo, commit, path, ref) {
493
+ const entries = await lsTree(repo, commit, { path, ref, commit });
494
+ if (!entries) return null;
495
+ return entries.find((e) => e.path === path) ?? null;
496
+ }
497
+
498
+ /**
499
+ * → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
500
+ * A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
501
+ */
502
+ export async function readRemoteFile(refText, commitArg, path, options = {}) {
503
+ const ref = parseRepoRef(refText, options);
504
+ requireCommit(commitArg);
505
+ const rel = normalizeTreePath(path, { allowRoot: false });
506
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
507
+ const entry = await entryAt(repo, commit, rel, ref);
508
+ if (!entry || entry.type !== "blob") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a file in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
509
+ if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
510
+ if (entry.size > FILE_BUDGET) throw fail("E_REMOTE_FILE_OVERSIZE", `${rel} is ${entry.size} bytes (budget ${FILE_BUDGET})`, { path: rel, size: entry.size, budget: FILE_BUDGET, key: ref.key, commit });
511
+ let out;
512
+ try { out = await repo.local(["cat-file", "blob", entry.oid], { maxBuffer: FILE_BUDGET + 1024 }); }
513
+ catch (error) { throw unreadable(ref, error, { commit, path: rel }); }
514
+ return { bytes: out.stdout, size: out.stdout.length };
515
+ }
516
+
517
+ /**
518
+ * → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
519
+ * Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
520
+ */
521
+ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...options } = {}) {
522
+ const ref = parseRepoRef(refText, options);
523
+ requireCommit(commitArg);
524
+ if (!Number.isInteger(depth) || depth < 1) throw fail("E_REPO_REF", "depth must be a positive integer", { depth });
525
+ const rel = normalizeTreePath(dir, { allowRoot: true });
526
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
527
+ const spec = rel ? `${commit}:${rel}` : commit;
528
+ if (rel) {
529
+ const entry = await entryAt(repo, commit, rel, ref);
530
+ if (!entry || entry.type !== "tree") return [];
531
+ }
532
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
533
+ if (!entries) return [];
534
+ const result = [];
535
+ for (const e of entries) {
536
+ // Depth first, THEN the name check (L3): a hostile or merely odd name BELOW the
537
+ // requested depth is not part of this listing and must not blank it; a bad name AT
538
+ // a kept depth is still refused.
539
+ if (e.path.split("/").length > depth) continue;
540
+ assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
541
+ if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
542
+ const type = e.mode === "120000" ? "symlink" : e.type;
543
+ const row = { path: e.path, type };
544
+ if (e.type === "blob") row.size = e.size;
545
+ result.push(row);
546
+ }
547
+ result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
548
+ return result;
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // digest
553
+ // ---------------------------------------------------------------------------
554
+
555
+ const NUL = Buffer.from([0]);
556
+ const gitMode = (mode) => ((mode & 0o100) ? "755" : "644");
557
+ const byPath = (a, b) => Buffer.compare(Buffer.from(a.path, "utf8"), Buffer.from(b.path, "utf8"));
558
+
559
+ function createDigest() {
560
+ const hash = createHash("sha256");
561
+ let files = 0, bytes = 0;
562
+ return {
563
+ add(relpath, mode, content) {
564
+ hash.update("F"); hash.update(NUL);
565
+ hash.update(Buffer.from(relpath, "utf8")); hash.update(NUL);
566
+ hash.update(gitMode(mode)); hash.update(NUL);
567
+ hash.update(String(content.length)); hash.update(NUL);
568
+ hash.update(content); hash.update(NUL);
569
+ files += 1; bytes += content.length;
570
+ },
571
+ finish() { return { files, bytes, digest: `sha256-${hash.digest("hex")}` }; },
572
+ };
573
+ }
574
+
575
+ /** Same digest as fetchRemoteTree, computed over a local directory. Symlinks and
576
+ * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING. */
577
+ function posixNormalize(p) {
578
+ const out = [];
579
+ for (const seg of p.split("/")) { if (!seg || seg === ".") continue; if (seg === "..") { if (out.length && out.at(-1) !== "..") out.pop(); else out.push(".."); } else out.push(seg); }
580
+ return out.join("/");
581
+ }
582
+ /**
583
+ * The ONE symlink an OATS tree may carry (decision 13): a `CLAUDE.md` whose target
584
+ * is `AGENTS.md` beside it — the Claude-runtime alias of the canonical
585
+ * instructions. Souls carry it at their root; a capability's agents/<name>/ dirs
586
+ * carry it too. Anything else stays refused. The rule is on the PATH (any depth,
587
+ * basename CLAUDE.md); fetchRemoteTree still enforces the target constraint
588
+ * (relative, non-escaping) and contentDigest digests it as `symlink:<target>`.
589
+ */
590
+ export const OATS_ALIAS_SYMLINK = (relPath) => typeof relPath === "string" && (relPath === "CLAUDE.md" || relPath.endsWith("/CLAUDE.md"));
591
+
592
+ export function contentDigest(dir, { allowSymlinks = null } = {}) {
593
+ if (typeof dir !== "string" || !isAbsolute(dir)) throw fail("E_REPO_REF", "contentDigest requires an absolute directory path", { path: dir });
594
+ let st;
595
+ try { st = lstatSync(dir); } catch { throw fail("E_REMOTE_PATH_MISSING", `${dir} does not exist`, { path: dir }); }
596
+ if (!st.isDirectory()) throw fail("E_REMOTE_PATH_MISSING", `${dir} is not a directory`, { path: dir });
597
+ const files = [];
598
+ const walk = (abs, rel) => {
599
+ for (const name of readdirSync(abs)) {
600
+ if (!rel && name === ".git") continue; // a local checkout's metadata is not content
601
+ const p = join(abs, name), r = rel ? `${rel}/${name}` : name, s = lstatSync(p);
602
+ if (s.isSymbolicLink()) {
603
+ // Refused by default (contract §1). The same narrow opt-in fetchRemoteTree
604
+ // honours (a relative, non-escaping alias such as CLAUDE.md → AGENTS.md)
605
+ // digests as `symlink:<target>` so a fetched tree and its digest agree.
606
+ if (typeof allowSymlinks !== "function" || !allowSymlinks(r)) throw fail("E_REMOTE_TREE_UNSAFE", `${r} is a symlink`, { path: r, why: "symlink" });
607
+ files.push({ path: r, mode: 0o777, bytes: Buffer.from(`symlink:${readlinkSync(p)}`) });
608
+ continue;
609
+ }
610
+ if (s.isDirectory()) walk(p, r);
611
+ else if (s.isFile()) files.push({ path: r, mode: s.mode, abs: p });
612
+ else throw fail("E_REMOTE_TREE_UNSAFE", `${r} is not a regular file`, { path: r, why: "device" });
613
+ }
614
+ };
615
+ walk(dir, "");
616
+ files.sort(byPath);
617
+ const d = createDigest();
618
+ for (const f of files) d.add(f.path, f.mode, f.bytes ?? readFileSync(f.abs));
619
+ return d.finish().digest;
620
+ }
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // fetchRemoteTree
624
+ // ---------------------------------------------------------------------------
625
+
626
+ /**
627
+ * Copies the subtree <dir> of <commit> into destDir (created; must not exist).
628
+ * Regular files and dirs only: symlinks/submodules → E_REMOTE_TREE_UNSAFE { path, why }, total > 64 MiB → why "oversize".
629
+ * Missing <dir> → E_REMOTE_PATH_MISSING. → { files, bytes, digest }. Atomic: staging dir + rename, nothing left on failure.
630
+ */
631
+ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options = {}) {
632
+ const ref = parseRepoRef(refText, options);
633
+ requireCommit(commitArg);
634
+ const rel = normalizeTreePath(dir, { allowRoot: true });
635
+ if (typeof destDir !== "string" || !isAbsolute(destDir)) throw fail("E_REPO_REF", "destDir must be an absolute path", { destDir });
636
+ const dest = resolve(destDir);
637
+ let destStat = null;
638
+ try { destStat = lstatSync(dest); } catch {}
639
+ if (destStat) throw fail("E_REMOTE_TREE_UNSAFE", `${dest} already exists${destStat.isSymbolicLink() ? " (a symlink)" : ""}`, { path: dest, why: "exists" });
640
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
641
+ const spec = rel ? `${commit}:${rel}` : commit;
642
+ if (rel) {
643
+ const entry = await entryAt(repo, commit, rel, ref);
644
+ if (!entry || entry.type !== "tree") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a directory in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
645
+ }
646
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
647
+ if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
648
+ // Inspect everything BEFORE writing anything: names, modes, types, sizes, collisions.
649
+ let total = 0;
650
+ const blobs = [], trees = [], links = [];
651
+ // `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
652
+ // and stays inside the fetched subtree may be materialized as a symlink — the
653
+ // one legitimate case is a soul's `CLAUDE.md → AGENTS.md` alias. Anything else
654
+ // (absolute, escaping, or not allowed by the predicate) is refused as before.
655
+ const allowSymlinks = typeof options.allowSymlinks === "function" ? options.allowSymlinks : null;
656
+ for (const e of entries) {
657
+ const shown = rel ? `${rel}/${e.path}` : e.path;
658
+ assertSafeEntryPath(e.path, ref, commit, shown);
659
+ if (e.mode === "120000") {
660
+ if (!allowSymlinks || !allowSymlinks(e.path)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a symlink`, { path: shown, why: "symlink", key: ref.key, commit });
661
+ links.push(e); continue;
662
+ }
663
+ if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
664
+ if (e.type === "tree") { trees.push(e); continue; }
665
+ if (e.type !== "blob" || !/^100(644|755)$/.test(e.mode)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has unsupported mode ${e.mode}`, { path: shown, why: "device", key: ref.key, commit });
666
+ total += e.size;
667
+ if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
668
+ blobs.push(e);
669
+ }
670
+ blobs.sort(byPath);
671
+ assertNoCollisions(entries, ref, commit, rel);
672
+ mkdirSync(dirname(dest), { recursive: true });
673
+ const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
674
+ try {
675
+ mkdirSync(staging, { mode: 0o755 });
676
+ for (const t of trees) mkdirSync(join(staging, ...t.path.split("/")), { recursive: true, mode: 0o755 });
677
+ // One canonical pass over blobs AND links in byte order of path: contentDigest
678
+ // walks the written tree in the same order, so the two digests agree whatever
679
+ // the git tree listed first.
680
+ const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
681
+ const d = createDigest();
682
+ for (const { kind, e } of items) {
683
+ if (kind === "blob") {
684
+ const b = e;
685
+ let out;
686
+ try { out = await repo.local(["cat-file", "blob", b.oid], { maxBuffer: TREE_BUDGET + 1024 }); }
687
+ catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
688
+ const mode = b.mode === "100755" ? 0o755 : 0o644;
689
+ const target = join(staging, ...b.path.split("/"));
690
+ mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
691
+ const fd = openSync(target, "wx", mode);
692
+ try { writeSync(fd, out.stdout); } finally { closeSync(fd); }
693
+ d.add(b.path, mode, out.stdout);
694
+ continue;
695
+ }
696
+ const l = e;
697
+ let out;
698
+ try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
699
+ catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
700
+ const linkTarget = out.stdout.toString("utf8").trim();
701
+ const from = dirname(l.path === "" ? "x" : l.path);
702
+ const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
703
+ if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {
704
+ throw fail("E_REMOTE_TREE_UNSAFE", `${rel ? `${rel}/` : ""}${l.path} is a symlink escaping the fetched tree (${linkTarget})`, { path: l.path, why: "symlink", key: ref.key, commit });
705
+ }
706
+ const target = join(staging, ...l.path.split("/"));
707
+ mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
708
+ symlinkSync(linkTarget, target);
709
+ d.add(l.path, 0o777, Buffer.from(`symlink:${linkTarget}`));
710
+ }
711
+ const summary = d.finish();
712
+ renameSync(staging, dest);
713
+ return summary;
714
+ } catch (error) {
715
+ rmSync(staging, { recursive: true, force: true });
716
+ throw error;
717
+ }
718
+ }