@awebai/oats 0.24.8 → 0.24.10

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/readiness.mjs CHANGED
@@ -7,7 +7,8 @@
7
7
  * executable approval, activation, runtime-package requirements, soul
8
8
  * declarations) — never a second opinion. Unknown is unknown; "Ready" is the
9
9
  * consumer's word and only when every required check passes. */
10
- import { execFileSync } from "node:child_process";
10
+ import { spawnSync } from "node:child_process";
11
+ import { killGroup } from "./process-group.mjs";
11
12
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
12
13
  import { tmpdir } from "node:os";
13
14
  import { join } from "node:path";
@@ -29,26 +30,67 @@ const item = (subject, status, { required = true, reason = null, producer = "ker
29
30
  /** Verified Git signature of a source commit, named signer or nothing.
30
31
  * Requires network (fetch) — only when the caller asks (`verify: true`);
31
32
  * otherwise `unknown` with the reason. Never a URL, owner or hash as signer. */
32
- export function signatureOf({ url, commit }, { verify = false, timeoutMs = 30000 } = {}) {
33
- if (!url || !commit || commit === "local") return { status: "not-applicable", signer: null, reason: commit === "local" ? "path-installed capability has no source commit" : "no source recorded" };
34
- if (!verify) return { status: "unknown", signer: null, reason: "signature verification needs a network fetch; pass --verify-signatures" };
35
- const dir = mkdtempSync(join(tmpdir(), "oats-sig-"));
36
- const env = { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", LC_ALL: "C" };
37
- const git = (args) => execFileSync("git", ["-C", dir, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, env, shell: false });
33
+ export const SIGNATURE_FAILURES = Object.freeze(["transport-not-allowed", "fetch-failed", "fetch-timeout", "budget-exhausted", "verifier-failed", "verifier-timeout", "cannot-check", "no-source"]);
34
+ const ALLOWED_TRANSPORTS = /^(https:\/\/|ssh:\/\/|git@[^/:]+:)/;
35
+ /** Verified Git signature of a source commit, named signer or nothing.
36
+ * Requires network (fetch) — only when the caller asks (`verify: true`);
37
+ * otherwise `unknown` with the reason. Never a URL, owner or hash as signer.
38
+ * Bounded custody: one total budget per call (`budgetMs`, default 60s) shared
39
+ * by fetch and verify; each Git child runs in its own process group and is
40
+ * killed with the group on timeout; the scratch repository is removed on every
41
+ * exit including signals; Git reads NO global/system config and cannot prompt;
42
+ * only https/ssh transports are fetched. Failures carry a closed `failure`
43
+ * code — never stderr. */
44
+ /** One verification budget for a whole readiness read: every signatureOf call
45
+ * in that read draws from it, so a target with N capabilities is bounded by the
46
+ * TOTAL (default 120 s), not N × per-call. */
47
+ export function verificationBudget(totalMs = 120000) {
48
+ const started = Date.now();
49
+ return { totalMs, remaining: () => totalMs - (Date.now() - started) };
50
+ }
51
+ const scratchDirs = new Set();
52
+ let signalCleanupInstalled = false;
53
+ function installSignalCleanup() {
54
+ if (signalCleanupInstalled) return; signalCleanupInstalled = true;
55
+ const sweep = () => { for (const d of scratchDirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } } scratchDirs.clear(); };
56
+ process.once("exit", sweep);
57
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(sig, () => { sweep(); process.exit(128 + (sig === "SIGINT" ? 2 : sig === "SIGTERM" ? 15 : 1)); });
58
+ }
59
+ export function signatureOf({ url, commit }, { verify = false, budgetMs = 60000, budget = null } = {}) {
60
+ if (!url || !commit || commit === "local") return { status: "not-applicable", signer: null, reason: commit === "local" ? "path-installed capability has no source commit" : "no source recorded", failure: null };
61
+ if (!verify) return { status: "unknown", signer: null, reason: "signature verification needs a network fetch; pass --verify-signatures", failure: null };
62
+ if (!ALLOWED_TRANSPORTS.test(url)) return { status: "unknown", signer: null, reason: "source transport is not https or ssh; not fetched", failure: { code: "transport-not-allowed" } };
63
+ const shared = budget ?? verificationBudget(budgetMs);
64
+ if (shared.remaining() <= 0) return { status: "unknown", signer: null, reason: "the verification budget was exhausted", failure: { code: "budget-exhausted" } };
65
+ installSignalCleanup();
66
+ const dir = mkdtempSync(join(tmpdir(), "oats-sig-")); scratchDirs.add(dir);
67
+ const cleanup = () => { scratchDirs.delete(dir); try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
68
+ const env = { PATH: process.env.PATH ?? "", HOME: dir, GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_SSH_COMMAND: "ssh -o BatchMode=yes", LC_ALL: "C" };
69
+ const git = (args, stage) => {
70
+ const left = shared.remaining();
71
+ if (left <= 0) throw Object.assign(new Error("budget"), { failure: "budget-exhausted" });
72
+ const child = spawnSync("git", ["-C", dir, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "protocol.ssh.allow=always", ...args],
73
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: left, killSignal: "SIGKILL", detached: true, env, shell: false });
74
+ if (child.error?.code === "ETIMEDOUT" || child.signal === "SIGKILL") { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-timeout` }); }
75
+ if (child.error || child.status !== 0) { killGroup(child); throw Object.assign(new Error(stage), { failure: `${stage}-failed` }); }
76
+ return child.stdout;
77
+ };
38
78
  try {
39
- git(["init", "-q"]);
40
- try { git(["fetch", "-q", "--depth", "1", url, commit]); }
41
- catch (e) { return { status: "unknown", signer: null, reason: `fetch failed: ${String(e.stderr ?? e.message ?? "").trim().slice(0, 200) || "unknown"}` }; }
79
+ git(["init", "-q"], "verifier");
80
+ git(["fetch", "-q", "--depth", "1", url, commit], "fetch");
42
81
  // %G? : G good, B bad, U good-untrusted, X expired, Y expired key, R revoked, E cannot check, N none
43
- const out = git(["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", commit]).trim();
82
+ const out = git(["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", commit], "verifier").trim();
44
83
  const [code, signerName, keyId, fingerprint] = out.split("\0");
45
- if (code === "N") return { status: "unsigned", signer: null, reason: "commit carries no signature" };
46
- if (code === "G") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: null };
47
- if (code === "U") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: "good signature from a key not marked trusted in the local keyring", trust: "untrusted-key" };
48
- if (code === "E") return { status: "unknown", signer: null, reason: "signature present but cannot be checked (missing public key)" };
49
- return { status: "invalid", signer: null, reason: { B: "bad signature", X: "good signature that has expired", Y: "good signature made by an expired key", R: "good signature made by a revoked key" }[code] || `git reported ${code || "nothing"}` };
50
- } catch (e) { return { status: "unknown", signer: null, reason: String(e.stderr ?? e.message ?? "").trim().slice(0, 200) || "verification failed" }; }
51
- finally { rmSync(dir, { recursive: true, force: true }); }
84
+ if (code === "N") return { status: "unsigned", signer: null, reason: "commit carries no signature", failure: null };
85
+ if (code === "G") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: null, failure: null };
86
+ if (code === "U") return { status: "verified", signer: { id: fingerprint || keyId || null, label: signerName || null }, reason: "good signature from a key not marked trusted in the local keyring", trust: "untrusted-key", failure: null };
87
+ if (code === "E") return { status: "unknown", signer: null, reason: "signature present but cannot be checked (missing public key)", failure: { code: "cannot-check" } };
88
+ return { status: "invalid", signer: null, reason: { B: "bad signature", X: "good signature that has expired", Y: "good signature made by an expired key", R: "good signature made by a revoked key" }[code] || "verifier reported an unrecognised state", failure: null };
89
+ } catch (e) {
90
+ const code = SIGNATURE_FAILURES.includes(e.failure) ? e.failure : "verifier-failed";
91
+ const reasons = { "fetch-failed": "the source could not be fetched", "fetch-timeout": "the fetch exceeded the verification budget", "budget-exhausted": "the verification budget was exhausted", "verifier-failed": "the local verifier failed", "verifier-timeout": "the local verifier exceeded the verification budget" };
92
+ return { status: "unknown", signer: null, reason: reasons[code], failure: { code } };
93
+ } finally { cleanup(); }
52
94
  }
53
95
 
54
96
  function sourceOfCapability(cap, catalog) {
@@ -61,37 +103,58 @@ function sourceOfCapability(cap, catalog) {
61
103
  }
62
104
 
63
105
  /** The quartet for a scope or one soul, from an inspect result. */
64
- export function readinessOf(inspect, { soul = null, verifySignatures = false, catalog = null, deploymentDir = null, memberDocument = null } = {}) {
106
+ export function readinessOf(inspect, { soul = null, verifySignatures = false, catalog = null, deploymentDir = null, memberDocument = null, selector = null, verificationBudgetMs = 120000 } = {}) {
65
107
  const caps = inspect.capabilities || [];
108
+ const budget = verifySignatures ? verificationBudget(verificationBudgetMs) : null;
66
109
  const soulEntry = soul ? (inspect.souls || []).find((s) => s.name === soul) : null;
67
- const required = new Set(soulEntry?.declarations?.requires?.capabilities ? Object.keys(soulEntry.declarations.requires.capabilities) : caps.filter((c) => c.activation?.enabled).map((c) => c.id));
68
- const relevant = soul ? caps.filter((c) => required.has(c.id) || c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`))) : caps;
110
+ const declaredRequires = soulEntry?.declarations?.requires?.capabilities ? Object.keys(soulEntry.declarations.requires.capabilities) : null;
111
+ const required = new Set(declaredRequires ?? caps.filter((c) => c.activation?.enabled).map((c) => c.id));
112
+ const declaredForSoul = (c) => !!c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`));
113
+ const relevant = soul ? caps.filter((c) => required.has(c.id) || declaredForSoul(c)) : caps;
114
+ // Typed linkage for consumers (frame-level per-capability rows): WHICH
115
+ // capability, at which config level/scope, and WHY it is in this quartet.
116
+ const capabilityOf = (c) => ({ id: c.id, level: c.activation?.level ?? null, scope: c.activation?.target ?? null });
117
+ const originOf = (c) => declaredRequires?.includes(c.id) ? { kind: "requires", target: `soul:${soul}` }
118
+ : soul && declaredForSoul(c) ? { kind: "declares", target: `soul:${soul}` }
119
+ : c.activation?.enabled ? { kind: "default", target: c.activation?.target ?? "global" } : { kind: "inventory", target: null };
120
+ const typed = (c) => ({ capability: capabilityOf(c), origin: originOf(c) });
69
121
 
70
122
  // installed — the artifact's bytes are present and locked with matching integrity
71
123
  const installed = relevant.map((c) => {
72
124
  const ok = c.health?.installed === true && (c.health.integrity == null || c.health.installedIntegrity == null || c.health.integrity === c.health.installedIntegrity);
73
125
  return item(c.id, ok ? "pass" : c.health?.installed === false ? "fail" : "unknown", { required: required.has(c.id), producer: "oats list",
74
126
  reason: ok ? null : c.health?.installed === false ? "not acquired" : c.health?.code || "integrity drift", evidence: { version: c.version ?? null, integrity: c.health?.integrity ?? null, origin: c.origin ?? null },
75
- remedy: ok ? null : `oats install ${c.package || c.id}` });
127
+ remedy: ok ? null : `oats install ${c.package || c.id}`, ...typed(c) });
76
128
  });
77
- for (const id of required) if (!caps.some((c) => c.id === id)) installed.push(item(id, "fail", { producer: "soul declaration", reason: "declared by the soul but not in the inventory", remedy: `oats install <package providing ${id}>` }));
129
+ for (const id of required) if (!caps.some((c) => c.id === id)) installed.push(item(id, "fail", { producer: "soul declaration", reason: "declared by the soul but not in the inventory", remedy: `oats install <package providing ${id}>`,
130
+ capability: { id, level: null, scope: null }, origin: { kind: "requires", target: `soul:${soul}` } }));
78
131
 
79
132
  // trusted — executable approval of the exact artifact; signature separately
80
133
  const trusted = relevant.map((c) => {
81
- const executable = !!(c.operations?.length || c.health?.code === "untrusted-surface" || c.health?.trusted !== undefined);
134
+ // The inspect row states whether the manifest has anything trust approves
135
+ // (commands/hooks/launch env). No surface → trust is not-applicable, however
136
+ // the lock records it. Older rows without the flag fall back to the old heuristic.
137
+ const executable = typeof c.health?.executableSurface === "boolean" ? c.health.executableSurface
138
+ : !!(c.operations?.length || c.health?.code === "untrusted-surface");
82
139
  const approved = c.health?.trusted === true;
83
- const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures });
140
+ const sig = signatureOf(sourceOfCapability(c, catalog), { verify: verifySignatures, budget });
84
141
  return item(c.id, !executable ? "not-applicable" : approved ? "pass" : c.health?.trusted === false ? "fail" : "unknown", { required: required.has(c.id), producer: "artifact approval",
85
142
  reason: !executable ? "no executable surface" : approved ? null : "executable surface not approved", evidence: { integrity: c.health?.integrity ?? null }, remedy: approved || !executable ? null : `oats trust ${c.id}`,
86
- signature: sig });
143
+ signature: sig, ...typed(c) });
87
144
  });
88
145
 
89
- // configured — activation for the subject + runtime package requirements + layer readiness problems
146
+ // configured — EFFECTIVE activation for the subject (a declaration at the
147
+ // soul is not activation: `enabled` is the resolved verdict for this subject,
148
+ // and a declared-but-disabled binding is a fail that says so) + runtime
149
+ // package requirements + layer readiness problems
90
150
  const configured = [];
91
151
  for (const c of relevant) {
92
- const active = soul ? (c.activation?.enabled === true || c.activation?.declaredAt?.some((d) => (d.targets || []).includes(`soul:${soul}`))) : c.activation?.enabled === true;
93
- if (required.has(c.id)) configured.push(item(`${c.id} activation`, active ? "pass" : "fail", { producer: "oats-config.yaml", reason: active ? null : `not active for ${soul ? `soul ${soul}` : "this scope"}`, evidence: { target: c.activation?.target ?? null, level: c.activation?.level ?? null }, remedy: active ? null : `oats use ${c.id}${soul ? ` --soul ${soul}` : ""}` }));
94
- for (const miss of c.missingRequires || []) configured.push(item(`${c.id} requires ${miss.command}`, "fail", { producer: "capability manifest", reason: miss.why || "required command not on PATH", remedy: miss.install || null }));
152
+ const active = c.activation?.enabled === true;
153
+ const declaredOnly = !active && soul && declaredForSoul(c);
154
+ if (required.has(c.id)) configured.push(item(`${c.id} activation`, active ? "pass" : "fail", { producer: "oats-config.yaml",
155
+ reason: active ? null : declaredOnly ? `declared for soul ${soul} but disabled${c.activation?.reason ? ` (${c.activation.reason})` : ""}` : `not active for ${soul ? `soul ${soul}` : "this scope"}`,
156
+ evidence: { target: c.activation?.target ?? null, level: c.activation?.level ?? null, source: c.activation?.source ?? null }, remedy: active ? null : `oats use ${c.id}${soul ? ` --soul ${soul}` : ""}`, ...typed(c) }));
157
+ for (const miss of c.missingRequires || []) configured.push(item(`${c.id} requires ${miss.command}`, "fail", { producer: "capability manifest", reason: miss.why || "required command not on PATH", remedy: miss.install || null, ...typed(c) }));
95
158
  }
96
159
  for (const p of inspect.problems || []) if (/runtime package|DISABLED|extension/i.test(p.message || "")) configured.push(item(p.capability || p.code, "fail", { producer: "runtime settings", reason: p.message, remedy: null }));
97
160
  if (soulEntry && soulEntry.readiness?.status === "undeclared") configured.push(item(`${soul} declarations`, "not-applicable", { required: false, producer: "soul.yaml", reason: "no requirements declared" }));
@@ -101,16 +164,32 @@ export function readinessOf(inspect, { soul = null, verifySignatures = false, ca
101
164
  const enrolled = [];
102
165
  const member = memberDocument ?? readMemberDocument(deploymentDir);
103
166
  if (!member) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "standalone deployment: no workspace declared in oats.yaml" }));
167
+ else if (member.unreadable) enrolled.push(item("workspace membership", "unknown", { producer: "oats.yaml", reason: "workspace member document is unreadable; membership cannot be stated", evidence: { file: member.file ?? null }, remedy: "repair oats.yaml (valid YAML) and re-run" }));
104
168
  else if (!member.workspace?.source) enrolled.push(item("workspace membership", "not-applicable", { required: false, producer: "oats.yaml", reason: "oats.yaml declares exports but no workspace backlink" }));
105
169
  else enrolled.push(item(`member of ${member.workspace.source}`, member.admitted === true ? "pass" : member.admitted === false ? "fail" : "unknown", { producer: "workspace discovery",
106
- reason: member.admitted === true ? null : member.admitted === false ? "this repository is not admitted in the workspace's members" : "admission not verified (needs the workspace observation)",
170
+ reason: member.admitted === true ? null : member.admitted === false ? "this repository is not admitted in the workspace's members" : "reciprocal admission not observed: this CLI reads the backlink but does not yet fetch the workspace's members (declared, not verified)",
107
171
  evidence: { workspace: member.workspace.source, revision: member.workspace.revision ?? null }, remedy: member.admitted === true ? null : "ask the workspace maintainer to admit this repository (oats-workspace.yaml members) — enrolment is admission, not login" }));
108
172
 
109
173
  const checks = { installed: { status: roll(installed), items: installed }, trusted: { status: roll(trusted), items: trusted }, configured: { status: roll(configured), items: configured }, enrolled: { status: roll(enrolled), items: enrolled } };
110
174
  const requiredStatuses = Object.values(checks).flatMap((c) => c.items.filter((i) => i.required).map((i) => i.status));
111
- return { readinessApi: READINESS_API, subject: soul ? { kind: "soul", name: soul } : { kind: "scope", context: inspect.scope?.context ?? null }, at: new Date().toISOString(),
175
+ // Per-capability grouping of the same items (no second observation): each
176
+ // capability's four verdicts, ready only if all its REQUIRED items pass.
177
+ const byCapability = [...new Set(Object.values(checks).flatMap((c) => c.items.map((i) => i.capability?.id).filter(Boolean)))].sort().map((id) => {
178
+ const of = (name) => checks[name].items.filter((i) => i.capability?.id === id);
179
+ const statuses = Object.keys(checks).flatMap((name) => of(name).filter((i) => i.required).map((i) => i.status));
180
+ const any = Object.keys(checks).flatMap((name) => of(name)).find(Boolean);
181
+ return { capability: any?.capability ?? { id, level: null, scope: null }, origin: any?.origin ?? null, required: statuses.length > 0,
182
+ checks: Object.fromEntries(Object.keys(checks).map((name) => [name, of(name).length ? roll(of(name)) : "not-applicable"])),
183
+ ownReady: statuses.length > 0 && statuses.every((s) => s === "pass" || s === "not-applicable") };
184
+ });
185
+ // Items that belong to no capability (workspace membership, soul declarations)
186
+ // are SUBJECT-level: a capability's own verdict never overrides them, and a
187
+ // row must not read "ready" while the subject is blocked by one of them.
188
+ const subjectBlockers = Object.entries(checks).flatMap(([name, c]) => c.items.filter((i) => i.required && !i.capability && i.status !== "pass" && i.status !== "not-applicable").map((i) => ({ check: name, subject: i.subject, status: i.status })));
189
+ for (const g of byCapability) g.ready = g.ownReady && subjectBlockers.length === 0;
190
+ return { readinessApi: READINESS_API, subject: { ...(soul ? { kind: "soul", name: soul } : { kind: "scope", context: inspect.scope?.context ?? null }), ...(selector ? { selector } : {}) }, at: new Date().toISOString(),
112
191
  checks, summary: { ready: requiredStatuses.length > 0 && requiredStatuses.every((s) => s === "pass" || s === "not-applicable"), required: requiredStatuses.length,
113
- pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length },
192
+ pass: requiredStatuses.filter((s) => s === "pass").length, fail: requiredStatuses.filter((s) => s === "fail").length, unknown: requiredStatuses.filter((s) => s === "unknown").length, byCapability, subjectBlockers },
114
193
  notes: [
115
194
  ...(verifySignatures ? [] : ["signatures are unknown until --verify-signatures (network fetch)"]),
116
195
  "ready means every REQUIRED check passes; it is never inferred from an empty set",
@@ -122,8 +201,13 @@ function readMemberDocument(deploymentDir) {
122
201
  if (!deploymentDir) return null;
123
202
  const file = join(deploymentDir, "oats.yaml");
124
203
  if (!existsSync(file)) return null;
125
- try { const doc = parseYamlNested(readFileSync(file, "utf8")); return { workspace: doc.workspace && typeof doc.workspace === "object" ? doc.workspace : null, admitted: null, exports: doc.exports ?? null }; }
126
- catch { return { workspace: null, admitted: null, unreadable: true }; }
204
+ try {
205
+ const doc = parseYamlNested(readFileSync(file, "utf8"));
206
+ // The lenient parser never throws: a `workspace:` that is present but not a
207
+ // mapping is a document we cannot read a membership from — unreadable, not absent.
208
+ if (doc.workspace !== undefined && (doc.workspace === null || typeof doc.workspace !== "object")) return { workspace: null, admitted: null, unreadable: true, file };
209
+ return { workspace: doc.workspace && typeof doc.workspace === "object" ? doc.workspace : null, admitted: null, exports: doc.exports ?? null };
210
+ } catch { return { workspace: null, admitted: null, unreadable: true, file }; }
127
211
  }
128
212
 
129
213
  /** Enforced policy for an instance (from its recorded metadata) or a soul
@@ -3,7 +3,7 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v2.1.2",
6
+ "ref": "v2.1.3",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.24.8",
3
+ "version": "0.24.10",
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",