@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/bin/oats.mjs +68 -21
- package/docs/conventions.md +51 -24
- package/docs/design/2026-09-20-redesign-program-board.md +1 -1
- package/docs/design/2026-09-23-simplified-workspace-model.md +1 -1
- package/docs/design/2026-09-23-workspace-module-contracts.md +151 -0
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +2 -2
- package/docs/desktop-cli-api.md +32 -0
- package/docs/desktop-succession.md +9 -2
- package/docs/desktop.md +9 -4
- package/docs/execution-targets.md +16 -4
- package/docs/implementation.md +35 -7
- package/docs/integrations.md +5 -3
- package/docs/knowledge-migration.md +16 -8
- package/docs/knowledge.md +36 -10
- package/docs/migration-from-oas.md +20 -9
- package/docs/rebuild-to-v2.md +119 -5
- package/docs/release-notes/v0.25.1.md +94 -0
- package/docs/schedules.md +12 -6
- package/docs/souls-and-instances.md +2 -2
- package/docs/workspaces.md +8 -1
- package/lib/core.mjs +45 -8
- package/lib/instance-resolution.mjs +96 -21
- package/lib/operator-dispatch.mjs +117 -0
- package/lib/packages.mjs +62 -1
- package/lib/remote.mjs +128 -49
- package/lib/resolve.mjs +70 -8
- package/lib/workspace.mjs +25 -6
- package/package.json +1 -1
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 (`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
247
|
-
* missing object, a pruned store, or a
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
281
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
427
|
-
const ref = parseRepoRef(refText);
|
|
428
|
-
requireCommit(
|
|
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,
|
|
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,
|
|
446
|
-
const ref = parseRepoRef(refText);
|
|
447
|
-
requireCommit(
|
|
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,
|
|
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
|
-
|
|
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,
|
|
553
|
-
const ref = parseRepoRef(refText);
|
|
554
|
-
requireCommit(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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.
|
|
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",
|