@awebai/oats 0.25.0 → 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.
package/lib/remote.mjs CHANGED
@@ -31,6 +31,26 @@
31
31
  * ssh ALWAYS in BatchMode — `-o BatchMode=yes` is appended to the operator's own
32
32
  * GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call.
33
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
+ *
34
54
  * TREE SAFETY: every entry name git reports is validated BEFORE anything touches
35
55
  * the disk. A component that is empty, `.`, `..`, `.git` (any case) or contains
36
56
  * `\` / NUL is E_REMOTE_TREE_UNSAFE { why: "path" } — a crafted tree object can
@@ -44,9 +64,9 @@
44
64
  * fetch that loses an on-disk `.lock` race to another process is retried.
45
65
  *
46
66
  * CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
47
- * pruned object store is detected (`cat-file -e <oid>^{commit}`) and refetched;
67
+ * pruned object store is detected (`rev-parse <oid>^{commit}`) and refetched;
48
68
  * the pin never turns a stale cache into a claim about the remote. The object
49
- * must be a COMMIT: a tag or `at` naming a tree/blob is E_REMOTE_UNREADABLE.
69
+ * must PEEL to a COMMIT: `at` naming a tree/blob (or a tag of one) is E_REMOTE_UNREADABLE.
50
70
  *
51
71
  * CONTENT DIGEST (canonical framing, shared by fetchRemoteTree and contentDigest):
52
72
  * sha256 over the concatenation, for every REGULAR FILE sorted by relpath
@@ -99,11 +119,26 @@ function repoPathSegments(rawPath, text) {
99
119
  return parts.join("/");
100
120
  }
101
121
 
102
- function hostedRef(host, rawPath, text) {
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" } = {}) {
103
130
  const h = host.toLowerCase();
104
131
  if (!/^[a-z0-9.-]+$/.test(h)) throw fail("E_REPO_REF", `repository reference has an invalid host: ${text}`, { ref: text, host });
105
132
  const path = repoPathSegments(rawPath, text);
106
- return Object.freeze({ host: h, path, url: `https://${h}/${path}.git`, key: `${h}/${path}` });
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;
107
142
  }
108
143
 
109
144
  function localRef(absPath) {
@@ -113,11 +148,16 @@ function localRef(absPath) {
113
148
 
114
149
  /**
115
150
  * "git:github.com/org/repo(.git)" | "https://github.com/org/repo(.git)" | "git@github.com:org/repo(.git)"
116
- * | "/abs/path/to/bare.git" | "file:///abs/path" → { host, path, url, key } | throws E_REPO_REF.
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`).
117
156
  * Already-parsed refs (objects with key+url) pass through once re-validated: the
118
157
  * object's `url` must itself parse to the same `key` (no smuggled `ext::` urls or `../` keys).
119
158
  */
120
- export function parseRepoRef(text) {
159
+ export function parseRepoRef(text, options = undefined) {
160
+ const transport = transportOf(options);
121
161
  if (text && typeof text === "object" && typeof text.key === "string" && typeof text.url === "string") {
122
162
  const again = parseRepoRef(text.url);
123
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 });
@@ -126,9 +166,11 @@ export function parseRepoRef(text) {
126
166
  if (typeof text !== "string" || !text.trim()) throw fail("E_REPO_REF", "repository reference must be a non-empty string", { ref: text });
127
167
  const ref = text.trim();
128
168
  let m;
129
- if ((m = /^git:([^/:@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
169
+ if ((m = /^git:([^/:@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref, { transport });
130
170
  if ((m = /^https?:\/\/([^/@\s]+)\/(.+)$/.exec(ref))) return hostedRef(m[1], m[2], ref);
131
- if ((m = /^git@([^:/\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 });
132
174
  if (/^file:\/\//.test(ref)) {
133
175
  try { return localRef(fileURLToPath(ref)); } catch { throw fail("E_REPO_REF", `invalid file URL: ${ref}`, { ref }); }
134
176
  }
@@ -167,14 +209,20 @@ function gitEnv() {
167
209
  }
168
210
 
169
211
  /** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
170
- * rejects with { code, signal, killed, stderr, stdout }. Injectable via options.exec. */
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. */
171
217
  export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS } = {}) {
172
218
  return new Promise((resolvePromise, reject) => {
173
219
  execFile("git", args, { cwd, env: gitEnv(), timeout, maxBuffer, shell: false, encoding: "buffer", killSignal: "SIGKILL" },
174
220
  (error, stdout, stderr) => {
175
221
  if (error) {
176
222
  error.stdout = stdout; error.stderr = stderr;
177
- error.timedOut = error.killed === true || error.signal === "SIGKILL";
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");
178
226
  reject(error);
179
227
  } else resolvePromise({ stdout, stderr });
180
228
  });
@@ -186,14 +234,22 @@ function stderrText(error) {
186
234
  return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
187
235
  }
188
236
 
189
- /** Classify a failed network git call into the contract's four reasons. */
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". */
190
240
  export function classifyRemoteFailure(error) {
241
+ if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return classifyText(error) ?? "network";
191
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) {
192
248
  const text = stderrText(error).toLowerCase();
193
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";
194
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";
195
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";
196
- return "network";
252
+ return null;
197
253
  }
198
254
 
199
255
  function unreadable(ref, error, extra = {}) {
@@ -243,23 +299,34 @@ async function cacheRepo(ref, options) {
243
299
 
244
300
  function pinRef(oid) { return `refs/oats/commits/${oid}`; }
245
301
 
246
- /** Is <oid> present in the cache AND a commit? (`cat-file -e <oid>^{commit}` fails on a
247
- * missing object, a pruned store, or a tag/tree/blob.) */
248
- async function hasCommit(repo, oid) {
249
- return repo.local(["cat-file", "-e", `${oid}^{commit}`]).then(() => true, () => false);
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; }
250
310
  }
251
311
 
252
312
  async function objectType(repo, oid) {
253
313
  try { return (await repo.local(["cat-file", "-t", oid])).stdout.toString("utf8").trim(); } catch { return null; }
254
314
  }
255
315
 
256
- /** Ensure <oid> (a full COMMIT OID) and all its trees/blobs are present in the cache. */
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
+ */
257
323
  async function ensureCommit(ref, oid, options) {
258
324
  const root = options.cacheDir ?? defaultCacheRoot();
259
325
  const dir = join(root, createHash("sha256").update(ref.key).digest("hex"));
260
326
  return withCacheLock(dir, async () => {
261
327
  const repo = await cacheRepo(ref, options);
262
- if (await hasCommit(repo, oid)) return repo; // pinned AND present AND a commit
328
+ const cached = await peelCommit(repo, oid);
329
+ if (cached) return { repo, commit: cached }; // pinned AND present AND (peels to) a commit
263
330
  let lastError;
264
331
  for (let attempt = 0; attempt < 3; attempt++) {
265
332
  try {
@@ -273,12 +340,14 @@ async function ensureCommit(ref, oid, options) {
273
340
  }
274
341
  }
275
342
  if (lastError) throw unreadable(ref, lastError, { commit: oid });
276
- if (!(await hasCommit(repo, oid))) {
343
+ const commit = await peelCommit(repo, oid);
344
+ if (!commit) {
277
345
  const type = await objectType(repo, oid);
278
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 });
279
347
  }
280
- await repo.local(["update-ref", pinRef(oid), oid]);
281
- return repo;
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 };
282
351
  });
283
352
  }
284
353
 
@@ -324,12 +393,13 @@ function resolveAt(parsed, at) {
324
393
  * Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
325
394
  */
326
395
  export async function observeRemote(refText, { at, ...options } = {}) {
327
- const ref = parseRepoRef(refText);
396
+ const ref = parseRepoRef(refText, options);
328
397
  const exec = options.exec ?? runGit;
329
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 });
330
399
  if (typeof at === "string" && OID_RE.test(at)) {
331
- await ensureCommit(ref, at, options);
332
- return { key: ref.key, url: ref.url, commit: at, ref: null, observedAt: new Date().toISOString() };
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() };
333
403
  }
334
404
  const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
335
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 });
@@ -343,8 +413,8 @@ export async function observeRemote(refText, { at, ...options } = {}) {
343
413
  const hit = resolveAt(parsed, at);
344
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 });
345
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 });
346
- await ensureCommit(ref, hit.commit, options);
347
- return { key: ref.key, url: ref.url, commit: hit.commit, ref: hit.ref, observedAt: new Date().toISOString() };
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() };
348
418
  }
349
419
 
350
420
  // ---------------------------------------------------------------------------
@@ -398,23 +468,29 @@ function assertNoCollisions(entries, ref, commit, prefix) {
398
468
  }
399
469
  }
400
470
 
401
- /** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree. */
402
- async function lsTree(repo, spec, { flags = [], path } = {}) {
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 } = {}) {
403
476
  try {
404
477
  const args = ["ls-tree", "-l", "-z", ...flags, spec];
405
478
  if (path !== undefined) args.push("--", path);
406
479
  const out = await repo.local(args, { maxBuffer: 64 * 1024 * 1024 });
407
480
  return parseLsTree(out.stdout);
408
481
  } catch (error) {
482
+ if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
409
483
  const text = stderrText(error).toLowerCase();
410
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;
411
- throw error;
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 });
412
488
  }
413
489
  }
414
490
 
415
491
  /** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
416
- async function entryAt(repo, commit, path) {
417
- const entries = await lsTree(repo, commit, { path });
492
+ async function entryAt(repo, commit, path, ref) {
493
+ const entries = await lsTree(repo, commit, { path, ref, commit });
418
494
  if (!entries) return null;
419
495
  return entries.find((e) => e.path === path) ?? null;
420
496
  }
@@ -423,12 +499,12 @@ async function entryAt(repo, commit, path) {
423
499
  * → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
424
500
  * A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
425
501
  */
426
- export async function readRemoteFile(refText, commit, path, options = {}) {
427
- const ref = parseRepoRef(refText);
428
- requireCommit(commit);
502
+ export async function readRemoteFile(refText, commitArg, path, options = {}) {
503
+ const ref = parseRepoRef(refText, options);
504
+ requireCommit(commitArg);
429
505
  const rel = normalizeTreePath(path, { allowRoot: false });
430
- const repo = await ensureCommit(ref, commit, options);
431
- const entry = await entryAt(repo, commit, rel);
506
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
507
+ const entry = await entryAt(repo, commit, rel, ref);
432
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 });
433
509
  if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
434
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 });
@@ -442,23 +518,26 @@ export async function readRemoteFile(refText, commit, path, options = {}) {
442
518
  * → [{ path, type: "blob"|"tree", size? }] relative to <dir>, depth-bounded (depth 1 = direct children).
443
519
  * Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
444
520
  */
445
- export async function listRemoteTree(refText, commit, dir, { depth = 2, ...options } = {}) {
446
- const ref = parseRepoRef(refText);
447
- requireCommit(commit);
521
+ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...options } = {}) {
522
+ const ref = parseRepoRef(refText, options);
523
+ requireCommit(commitArg);
448
524
  if (!Number.isInteger(depth) || depth < 1) throw fail("E_REPO_REF", "depth must be a positive integer", { depth });
449
525
  const rel = normalizeTreePath(dir, { allowRoot: true });
450
- const repo = await ensureCommit(ref, commit, options);
526
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
451
527
  const spec = rel ? `${commit}:${rel}` : commit;
452
528
  if (rel) {
453
- const entry = await entryAt(repo, commit, rel);
529
+ const entry = await entryAt(repo, commit, rel, ref);
454
530
  if (!entry || entry.type !== "tree") return [];
455
531
  }
456
- const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
532
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
457
533
  if (!entries) return [];
458
534
  const result = [];
459
535
  for (const e of entries) {
460
- assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
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.
461
539
  if (e.path.split("/").length > depth) continue;
540
+ assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
462
541
  if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
463
542
  const type = e.mode === "120000" ? "symlink" : e.type;
464
543
  const row = { path: e.path, type };
@@ -549,22 +628,22 @@ export function contentDigest(dir, { allowSymlinks = null } = {}) {
549
628
  * Regular files and dirs only: symlinks/submodules → E_REMOTE_TREE_UNSAFE { path, why }, total > 64 MiB → why "oversize".
550
629
  * Missing <dir> → E_REMOTE_PATH_MISSING. → { files, bytes, digest }. Atomic: staging dir + rename, nothing left on failure.
551
630
  */
552
- export async function fetchRemoteTree(refText, commit, dir, destDir, options = {}) {
553
- const ref = parseRepoRef(refText);
554
- requireCommit(commit);
631
+ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options = {}) {
632
+ const ref = parseRepoRef(refText, options);
633
+ requireCommit(commitArg);
555
634
  const rel = normalizeTreePath(dir, { allowRoot: true });
556
635
  if (typeof destDir !== "string" || !isAbsolute(destDir)) throw fail("E_REPO_REF", "destDir must be an absolute path", { destDir });
557
636
  const dest = resolve(destDir);
558
637
  let destStat = null;
559
638
  try { destStat = lstatSync(dest); } catch {}
560
639
  if (destStat) throw fail("E_REMOTE_TREE_UNSAFE", `${dest} already exists${destStat.isSymbolicLink() ? " (a symlink)" : ""}`, { path: dest, why: "exists" });
561
- const repo = await ensureCommit(ref, commit, options);
640
+ const { repo, commit } = await ensureCommit(ref, commitArg, options);
562
641
  const spec = rel ? `${commit}:${rel}` : commit;
563
642
  if (rel) {
564
- const entry = await entryAt(repo, commit, rel);
643
+ const entry = await entryAt(repo, commit, rel, ref);
565
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 });
566
645
  }
567
- const entries = await lsTree(repo, spec, { flags: ["-r", "-t"] });
646
+ const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
568
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 });
569
648
  // Inspect everything BEFORE writing anything: names, modes, types, sizes, collisions.
570
649
  let total = 0;
package/lib/resolve.mjs CHANGED
@@ -27,6 +27,22 @@
27
27
  * ⊕ workspace.defaults.capabilities ⊕ workspace.defaults.byTeam[soul.team].capabilities
28
28
  * ⊕ soul.capabilities
29
29
  *
30
+ * Slot `none` (contract §3, post-0.25.0 rule): a soul's `<slot>: none` EMPTIES the slot — it drops the
31
+ * workspace's `defaults.<slot>` AND any capability of that layer the workspace defaults contributed
32
+ * (`defaults.capabilities`, `defaults.byTeam[team]`). A layer-bearing capability the SOUL ITSELF declares
33
+ * next to `none` is contradictory and stays E_SLOT_CONFLICT { reason: "none" } (spell `<cap>: off` to
34
+ * remove a default explicitly; drop the soul's own line to fill the slot).
35
+ *
36
+ * Package modules are gated twice (decision 8): the lock must carry an approval AND the approval must
37
+ * describe the package tree at the locked commit — `executablesDigestAt(...)` (the same computation
38
+ * `oats sync` approved) must equal `approved.executables`, else E_PACKAGE_UNAPPROVED
39
+ * { reason: "digest-mismatch", approved, executables }. An edited lock never runs unapproved hooks.
40
+ *
41
+ * Revision (decision 14 + preview): `declRevision` fingerprints the declarations (soul identity, modules
42
+ * with their commits/versions/manifests, slots, skills, injects); `payloadRevision` fingerprints the merged
43
+ * provider payloads; `revision` = hash(declRevision, payloadRevision) so a spawn decision still binds to
44
+ * everything, while a preview can say WHAT changed since the previous instance (declarations | payload | both).
45
+ *
30
46
  * Payloads (decision 14), later wins on scalars/arrays, objects deep-merge:
31
47
  * workspace.messaging (messaging slot only) ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
32
48
  *
@@ -39,7 +55,7 @@ import { createHash } from "node:crypto";
39
55
  import { posix } from "node:path";
40
56
  import { oatsError as baseOatsError } from "./errors.mjs";
41
57
  import * as defaultRemote from "./remote.mjs";
42
- import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
58
+ import { bindRemote, executablesDigestAt, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
43
59
 
44
60
  export const RESOLUTION_API = 1;
45
61
  export const SLOTS = Object.freeze(["knowledge", "messaging", "tasks"]);
@@ -329,11 +345,37 @@ function lookupPackage(lock, name, via, soul) {
329
345
  const providing = packageProviding(lock, name);
330
346
  if (!providing) throw fail("E_PACKAGE_MISSING", `${name}: no locked package provides it — add the package to packages: and run \`oats sync\``, { ...where, locked: Object.keys(lock.packages).sort() });
331
347
  if (!providing.entry.approved || !isObject(providing.entry.approved) || typeof providing.entry.approved.executables !== "string" || !/^sha256-[0-9a-f]{64}$/.test(providing.entry.approved.executables)) {
332
- throw fail("E_PACKAGE_UNAPPROVED", `${name}: package ${providing.id} v${providing.entry.version} (${short(providing.entry.commit)}) is not approved — review its executables and approve once per version`, { ...where, id: providing.id, version: providing.entry.version, commit: providing.entry.commit });
348
+ throw fail("E_PACKAGE_UNAPPROVED", `${name}: package ${providing.id} v${providing.entry.version} (${short(providing.entry.commit)}) is not approved — review its executables and approve once per version`, { ...where, id: providing.id, version: providing.entry.version, commit: providing.entry.commit, reason: "unapproved" });
333
349
  }
334
350
  return providing;
335
351
  }
336
352
 
353
+ /**
354
+ * The approval must describe THIS tree (decision 8: the lock carries the approval next to the commit it
355
+ * approved). Recompute the executables digest over the package tree at entry.commit — the very computation
356
+ * `oats sync` approved — and require equality with approved.executables. A lock edited to another commit
357
+ * (same id/version, approval copied along) fails here: E_PACKAGE_UNAPPROVED { reason: "digest-mismatch" }
358
+ * naming both digests and the executables that would have run. Cached per (remote, repo, commit, path).
359
+ */
360
+ async function assertApprovalDescribesTree({ remote, remoteOptions, ref, id, entry, name, via, soul }) {
361
+ const where = { capability: name, from: "package", soul: soul.name, via, id, version: entry.version, commit: entry.commit, path: entry.path };
362
+ let computed;
363
+ try { computed = await executablesDigestAt(remote, ref, entry.commit, entry.path, Array.isArray(entry.capabilities) ? entry.capabilities : null, { remoteOptions }); }
364
+ catch (e) {
365
+ if (e?.code === "E_PACKAGE_INTEGRITY" && (e.details ?? e.provenance)?.why === "capabilities") {
366
+ const d = e.details ?? e.provenance;
367
+ throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides [${d.locked.join(", ")}], but ${entry.path}/oats-package.json at ${short(entry.commit)} declares [${d.listed.join(", ")}]`, { ...where, why: "capabilities", listed: d.listed, locked: d.locked });
368
+ }
369
+ throw e;
370
+ }
371
+ if (computed.digest !== entry.approved.executables) {
372
+ throw fail("E_PACKAGE_UNAPPROVED",
373
+ `${name}: package ${id} v${entry.version} @ ${short(entry.commit)}: the recorded approval ${entry.approved.executables} does not describe this tree's executables (${computed.digest}) — the lock was edited or the approval copied from another commit; run \`oats sync\` and approve what it shows`,
374
+ { ...where, reason: "digest-mismatch", approved: entry.approved.executables, executables: computed.digest, targets: computed.executables.map((x) => `${x.capability}: ${x.kind} ${x.name} → ${x.target}`) });
375
+ }
376
+ return computed;
377
+ }
378
+
337
379
  /** The repo ref a locked package is read from: the lock's recorded url, else the catalog's url for catalog ids, else the key for git refs. */
338
380
  export function packageRef(id, entry, catalog, remote) {
339
381
  const cat = isObject(catalog) ? (isObject(catalog.packages) && !("url" in catalog.packages) ? catalog.packages : catalog) : {};
@@ -421,6 +463,7 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
421
463
  }
422
464
  if (!isObject(spawn)) throw new TypeError("resolveSoul: spawn must be an object");
423
465
  if (lock !== null && lock !== undefined) validateLock(lock); // E_LOCK_SCHEMA: a lock passed in memory meets the same bar as one read from disk
466
+ const rawRemote = injected ?? defaultRemote; // identity for the per-process digest cache (bound copies are per call)
424
467
  const remote = remoteOf({ remote: injected, remoteOptions });
425
468
  const workspace = isObject(discovery?.workspace) ? discovery.workspace : null;
426
469
  assertSoulDiscovered(discovery, soulEntry);
@@ -436,27 +479,38 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
436
479
  const modules = [];
437
480
  const skills = [];
438
481
  const injects = [];
482
+ // Slot `none` (L1 rule): a layer-bearing capability the WORKSPACE DEFAULTS contributed for a slot the soul
483
+ // empties is dropped here, before any lookup — the soul asked for no <slot> and never named it. A layer
484
+ // is known only from the manifest, so a member capability is peeked at in discovery and a package one at
485
+ // its lock entry's manifest; a soul-declared one is never dropped (it is a conflict, judged below).
486
+ const emptied = new Set(SLOTS.filter((slot) => definition[slot] === "none"));
439
487
  for (const { name, from, via } of declared) {
440
488
  let module;
441
489
  if (from === "package") {
442
490
  const { id, entry } = lookupPackage(lock, name, via, soul);
443
491
  const ref = packageRef(id, entry, catalog, remote);
444
492
  const details = { capability: name, id, version: entry.version, commit: entry.commit, path: entry.path };
493
+ // M3: the approval must describe the tree at entry.commit (the digest `oats sync` approved), else refuse.
494
+ await assertApprovalDescribesTree({ remote: rawRemote, remoteOptions, ref, id, entry, name, via, soul });
445
495
  const { capabilities } = await readPackageManifests(remote, ref, entry.commit, entry.path, details);
446
496
  const cap = capabilities.find((c) => c.name === name);
447
497
  if (!cap) throw fail("E_PACKAGE_INTEGRITY", `${name}: the lock says package ${id} v${entry.version} provides it, but ${entry.path}/oats-package.json at ${short(entry.commit)} does not`, { ...details, listed: capabilities.map((c) => c.name) });
498
+ const layer = layerOf(cap.manifest);
499
+ if (layer && emptied.has(layer) && via !== "soul") continue;
448
500
  module = {
449
501
  name, from: { kind: "package", package: id, version: entry.version, commit: entry.commit, integrity: entry.integrity, repoKey: remote.parseRepoRef(ref).key },
450
- manifest: clone(cap.manifest), layer: layerOf(cap.manifest), private: cap.manifest.private === true, dir: cap.dir,
502
+ manifest: clone(cap.manifest), layer, private: cap.manifest.private === true, dir: cap.dir,
451
503
  };
452
504
  const missing = (raw, why, text) => fail("E_PACKAGE_MANIFEST", `${name} (package ${id} v${entry.version}) ${text}`, { ...details, skill: raw, why });
453
505
  skills.push(...await enumerateSkills({ remote, ref, commit: entry.commit, dir: cap.dir, manifest: cap.manifest, moduleName: name, missing }));
454
506
  } else {
455
507
  const { row, cap } = lookupMember(discovery, soul, name, from, via, lock);
508
+ const layer = layerOf(cap.manifest);
509
+ if (layer && emptied.has(layer) && via !== "soul") continue;
456
510
  const ref = memberRef(discovery, remote, cap.repoKey);
457
511
  module = {
458
512
  name, from: { kind: "member", repoKey: cap.repoKey, commit: cap.commit ?? row.commit },
459
- manifest: clone(cap.manifest), layer: layerOf(cap.manifest), private: cap.private === true, dir: cap.path,
513
+ manifest: clone(cap.manifest), layer, private: cap.private === true, dir: cap.path,
460
514
  };
461
515
  const missing = (raw, why, text) => fail("E_CAPABILITY_MISSING", `${name} (${cap.repoKey}@${short(module.from.commit)}) ${text}`, { capability: name, repoKey: cap.repoKey, commit: module.from.commit, skill: raw, why });
462
516
  skills.push(...await enumerateSkills({ remote, ref, commit: module.from.commit, dir: cap.path, manifest: cap.manifest, listing: cap.listing, moduleName: name, missing }));
@@ -469,7 +523,10 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
469
523
  modules.push(module);
470
524
  }
471
525
 
472
- // Slots (decision: a resolved capability whose manifest has layer X fills slot X; two → conflict; soul `none` empties).
526
+ // Slots: a resolved capability whose manifest has layer X fills slot X; two → conflict. A soul `none` has
527
+ // already emptied the slot of every workspace-default contribution (above); what remains under `none` is
528
+ // the soul's own contradiction → E_SLOT_CONFLICT { reason: "none" } — judged FIRST, so it is never reported
529
+ // as a two-module clash.
473
530
  const slots = { knowledge: null, messaging: null, tasks: null };
474
531
  const viaOf = new Map(declared.map((d) => [d.name, d.via]));
475
532
  for (const m of modules) {
@@ -480,8 +537,8 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
480
537
  throw fail("E_SLOT_CONFLICT", `slot ${slotDefault}: defaults.${slotDefault} names ${m.name}, whose manifest declares layer ${m.layer ? show(m.layer) : "none"} — a slot default must be a ${slotDefault}-layer capability`, { slot: slotDefault, modules: [m.name], soul: soul.name, reason: "layer-mismatch", layer: m.layer });
481
538
  }
482
539
  if (!m.layer) continue;
540
+ if (emptied.has(m.layer)) throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: the soul says ${m.layer}: none but itself names ${m.name}, which declares layer ${m.layer} — drop one of the two (a workspace default of that layer would have been dropped by none; this one is the soul's own)`, { slot: m.layer, modules: [m.name], soul: soul.name, reason: "none", via });
483
541
  if (slots[m.layer]) throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: both ${slots[m.layer]} and ${m.name} declare layer ${m.layer}; a soul fills each slot with at most one capability`, { slot: m.layer, modules: [slots[m.layer], m.name], soul: soul.name });
484
- if (definition[m.layer] === "none") throw fail("E_SLOT_CONFLICT", `slot ${m.layer}: the soul says ${m.layer}: none but names ${m.name}, which declares layer ${m.layer}`, { slot: m.layer, modules: [m.name], soul: soul.name, reason: "none" });
485
542
  slots[m.layer] = m.name;
486
543
  }
487
544
 
@@ -544,8 +601,13 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
544
601
  modules.sort((a, b) => byCodepoint(a.name, b.name));
545
602
  skills.sort((a, b) => byCodepoint(a.module, b.module) || byCodepoint(a.name, b.name));
546
603
  injects.sort((a, b) => byCodepoint(a.module, b.module));
547
- const body = { resolutionApi: RESOLUTION_API, soul, modules, slots, payloads, skills, injects };
548
- return deepFreeze({ ...body, revision: revisionOf(body) });
604
+ // L6: two fingerprints — declarations (what is composed, at which commits/versions) and payload (the merged
605
+ // provider settings) — so a preview can say what changed; `revision` binds both, exactly as before.
606
+ const decl = { resolutionApi: RESOLUTION_API, soul, modules, slots, skills, injects };
607
+ const declRevision = revisionOf(decl);
608
+ const payloadRevision = revisionOf(payloads);
609
+ const revision = revisionOf({ declRevision, payloadRevision });
610
+ return deepFreeze({ ...decl, payloads, declRevision, payloadRevision, revision });
549
611
  }
550
612
 
551
613
  function layerOf(manifest) {
package/lib/workspace.mjs CHANGED
@@ -159,10 +159,28 @@ export function validateAgainst(schema, value, { root = schema, path = "" } = {}
159
159
  /* ───────────────────────────── domain rules ───────────────────────────── */
160
160
 
161
161
  const ABSOLUTE_PATH = /^(?:\/|\\\\|[A-Za-z]:[\\/])/;
162
- function* strings(value, path = "") {
163
- if (typeof value === "string") yield [path, value];
164
- else if (Array.isArray(value)) for (let i = 0; i < value.length; i++) yield* strings(value[i], `${path}/${i}`);
165
- else if (isObject(value)) for (const [k, v] of Object.entries(value)) yield* strings(v, `${path}/${pointerKey(k)}`);
162
+ /** The workspace file's REF/PATH fields — the only values the absolute-path refusal applies to (contract §2,
163
+ * Phase B: "absolute paths" means bare filesystem paths as VALUES that stand for a repo or a path inside one;
164
+ * host state belongs in oats-local.yaml). Free text (`teams.*.description`) and the opaque provider payload
165
+ * (`messaging`, incl. `byTeam.*`) are never scanned: a description may mention `/srv`, a provider may
166
+ * legitimately carry a socket path or a URL, and neither is a place the kernel resolves.
167
+ * → [[path, value]] for members[], packages.*, stores.*, external[].source|soul, defaults.<slot|capabilities>.*.from,
168
+ * defaults.byTeam.*.capabilities.*.from */
169
+ function* refFields(value) {
170
+ if (Array.isArray(value.members)) for (let i = 0; i < value.members.length; i++) if (typeof value.members[i] === "string") yield [`/members/${i}`, value.members[i]];
171
+ if (isObject(value.packages)) for (const [id, v] of Object.entries(value.packages)) if (typeof v === "string") yield [`/packages/${pointerKey(id)}`, v];
172
+ if (isObject(value.stores)) for (const [name, v] of Object.entries(value.stores)) if (typeof v === "string") yield [`/stores/${pointerKey(name)}`, v];
173
+ if (Array.isArray(value.external)) for (let i = 0; i < value.external.length; i++) {
174
+ const entry = value.external[i];
175
+ if (!isObject(entry)) continue;
176
+ for (const f of ["source", "soul"]) if (typeof entry[f] === "string") yield [`/external/${i}/${f}`, entry[f]];
177
+ }
178
+ if (isObject(value.defaults)) {
179
+ const d = value.defaults;
180
+ const froms = function* (caps, path) { if (isObject(caps)) for (const [name, choice] of Object.entries(caps)) if (isObject(choice) && typeof choice.from === "string") yield [`${path}/${pointerKey(name)}/from`, choice.from]; };
181
+ for (const slot of ["knowledge", "messaging", "tasks", "capabilities"]) yield* froms(d[slot], `/defaults/${slot}`);
182
+ if (isObject(d.byTeam)) for (const [label, team] of Object.entries(d.byTeam)) yield* froms(team?.capabilities, `/defaults/byTeam/${pointerKey(label)}/capabilities`);
183
+ }
166
184
  }
167
185
  function refKey(remote, ref) {
168
186
  return remote.parseRepoRef(ref).key;
@@ -182,8 +200,9 @@ export function validateWorkspace(value, { remote = defaultRemote } = {}) {
182
200
  const problems = validateAgainst(schemaFor("workspace"), value);
183
201
  if (!isObject(value)) return problems;
184
202
  if (value.schemaVersion !== 2) return problems;
185
- for (const [path, s] of strings(value)) {
186
- if (ABSOLUTE_PATH.test(s)) problems.push({ path, message: `absolute paths are refused in the workspace file (${show(s)}); host paths belong in oats-local.yaml` });
203
+ // Absolute-path refusal applies to REF/PATH fields only (L2): never to descriptions or the provider payload.
204
+ for (const [path, s] of refFields(value)) {
205
+ if (ABSOLUTE_PATH.test(s)) problems.push({ path, message: `absolute paths are refused in the workspace file (${show(s)}); host paths belong in oats-local.yaml (a local remote is a repo ref: file:///… or git:/abs/bare.git@<ref>)` });
187
206
  }
188
207
  // packages: exactly two value forms (contract §2 non-collapse rule) — a catalog version or git:<repo>@<ref>.
189
208
  if (isObject(value.packages)) for (const [id, v] of Object.entries(value.packages)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",