@awebai/oats 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/oats.mjs +162 -42
- package/docs/capabilities.md +5 -0
- package/docs/configuration.md +65 -8
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +167 -18
- package/docs/desktop.md +16 -0
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +121 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +164 -46
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +18 -3
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +1363 -115
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package-catalog.json +2 -2
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
35
|
import { accessSync, constants as fsConstants } from "node:fs";
|
|
36
|
+
import { recordLocalInput } from "./local-inputs.mjs";
|
|
36
37
|
import { createHash, randomUUID } from "node:crypto";
|
|
37
38
|
import { fileURLToPath } from "node:url";
|
|
38
39
|
import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
|
|
@@ -45,6 +46,7 @@ import { killGroup } from "./process-group.mjs";
|
|
|
45
46
|
import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
|
|
46
47
|
import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
|
|
47
48
|
import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
|
|
49
|
+
import { claudeTrusts, codexTrustsRoot, harnessTrustWarning } from "./harness-trust.mjs";
|
|
48
50
|
async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
|
|
49
51
|
// Capability rows for a PREPARED spawn (workspace model): the one function that
|
|
50
52
|
// turns a Resolution's modules into the row shape hooks/environment/requirements/
|
|
@@ -183,7 +185,13 @@ export function slug(s) {
|
|
|
183
185
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
184
186
|
return r || "agent";
|
|
185
187
|
}
|
|
186
|
-
|
|
188
|
+
/** `command -v <bin>`, once per (bin, PATH) in this process: the answer only changes with PATH. */
|
|
189
|
+
const whichMemo = new Map();
|
|
190
|
+
function which(bin) {
|
|
191
|
+
const key = `${bin}\0${process.env.PATH ?? ""}`;
|
|
192
|
+
if (!whichMemo.has(key)) whichMemo.set(key, shTry(`command -v ${shq(bin)}`));
|
|
193
|
+
return whichMemo.get(key);
|
|
194
|
+
}
|
|
187
195
|
|
|
188
196
|
// ---------- yaml-ish ----------
|
|
189
197
|
/** `__proto__` is never data in a plain-object mapping: assigning it REWRITES
|
|
@@ -525,7 +533,7 @@ export const LAYERS = ["knowledge", "messaging", "tasks"];
|
|
|
525
533
|
// deployment's oats-local.yaml (lead decision 2: a spawn-time HOST choice,
|
|
526
534
|
// never a soul field). Selected at spawn or session start/restart by name.
|
|
527
535
|
export const LAUNCH_HARNESSES = ["pi", "claude", "codex"];
|
|
528
|
-
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo"]);
|
|
536
|
+
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo", "default"]);
|
|
529
537
|
/** A launch configuration's harness: `harness`, or `runtime`, its pre-0.27 name (0.26.0
|
|
530
538
|
* deployments wrote it) — read either (lead call 6). */
|
|
531
539
|
export const launchConfigHarness = (entry) => (Object.hasOwn(entry, "harness") ? entry.harness : entry.runtime);
|
|
@@ -541,7 +549,7 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
541
549
|
if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
|
|
542
550
|
if (name === "none") bad("cannot be named none: that word selects no configuration");
|
|
543
551
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
|
|
544
|
-
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo)`);
|
|
552
|
+
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo, default)`);
|
|
545
553
|
if (Object.hasOwn(entry, "harness") && Object.hasOwn(entry, "runtime") && entry.harness !== entry.runtime) bad(`names harness ${JSON.stringify(entry.harness)} and runtime ${JSON.stringify(entry.runtime)}: \`runtime\` is the pre-0.27 name of \`harness\` — keep one`);
|
|
546
554
|
if (!LAUNCH_HARNESSES.includes(launchConfigHarness(entry))) bad(`needs harness: one of ${LAUNCH_HARNESSES.join(", ")}`);
|
|
547
555
|
const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
|
|
@@ -562,12 +570,25 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
562
570
|
}
|
|
563
571
|
if (entry.model !== undefined) text(entry.model, "model");
|
|
564
572
|
if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
|
|
573
|
+
if (entry.default !== undefined && typeof entry.default !== "boolean") bad("default must be true or false");
|
|
565
574
|
return entry;
|
|
566
575
|
}
|
|
576
|
+
/** At most one `default: true` per harness (0.32, feature launch-config-default): the host's baseline for
|
|
577
|
+
* that harness has one source. `map` is name → entry; entries are validated first. */
|
|
578
|
+
export function validateLaunchConfigDefaults(map, where) {
|
|
579
|
+
const byHarness = new Map();
|
|
580
|
+
for (const [name, entry] of Object.entries(map || {})) {
|
|
581
|
+
if (entry?.default !== true) continue;
|
|
582
|
+
const harness = launchConfigHarness(entry);
|
|
583
|
+
if (byHarness.has(harness)) throw Object.assign(oatsError("E_LAUNCH_CONFIG_INVALID", `launch configurations ${JSON.stringify(byHarness.get(harness))} and ${JSON.stringify(name)}${where ? ` in ${where}` : ""} are both default: true for ${harness}; keep one default per harness`), { details: { harness, configurations: [byHarness.get(harness), name] } });
|
|
584
|
+
byHarness.set(harness, name);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
567
587
|
function validateLaunchConfigs(map, file) {
|
|
568
588
|
if (map === undefined) return;
|
|
569
589
|
if (!map || typeof map !== "object" || Array.isArray(map)) throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch-configs in ${file} must be a map of name to configuration`);
|
|
570
590
|
for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
|
|
591
|
+
validateLaunchConfigDefaults(map, file);
|
|
571
592
|
}
|
|
572
593
|
/** The launch configurations effective at `dir`: the `launch-configs:` of the
|
|
573
594
|
* oats-local.yaml found walking up from it (the deployment), validated. None
|
|
@@ -585,7 +606,7 @@ export function launchConfigsAt(dir) {
|
|
|
585
606
|
const source = dirname(found.path);
|
|
586
607
|
for (const [name, entry] of Object.entries(map || {})) {
|
|
587
608
|
if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`launch-configs.${name}.runtime in ${found.path}`);
|
|
588
|
-
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), source, shadows: [] };
|
|
609
|
+
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), ...(entry.default === true ? { default: true } : {}), source, shadows: [] };
|
|
589
610
|
}
|
|
590
611
|
return out;
|
|
591
612
|
}
|
|
@@ -858,9 +879,14 @@ export function officialPackageCatalog() {
|
|
|
858
879
|
function readCatalogFile() {
|
|
859
880
|
const file = officialCatalogFile();
|
|
860
881
|
const empty = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
861
|
-
|
|
882
|
+
// An OATS_PACKAGE_CATALOG override is local configuration (observation.localRevision); the bundled
|
|
883
|
+
// catalog belongs to the kernel install.
|
|
884
|
+
const override = !!process.env.OATS_PACKAGE_CATALOG;
|
|
885
|
+
if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
|
|
862
886
|
let doc;
|
|
863
|
-
|
|
887
|
+
const text = readFileSync(file, "utf8");
|
|
888
|
+
if (override) recordLocalInput(file, text);
|
|
889
|
+
try { doc = JSON.parse(text); }
|
|
864
890
|
catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
|
|
865
891
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
|
|
866
892
|
const out = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
@@ -1247,7 +1273,7 @@ export const HARNESS_PACKAGE_MANAGERS = {
|
|
|
1247
1273
|
* different marketplaces into one identity. */
|
|
1248
1274
|
identity: (spec) => String(spec || "").trim(),
|
|
1249
1275
|
safeSpec: (spec) => typeof spec === "string" && /^[a-z0-9][\w.-]*@[a-z0-9][\w.-]*$/i.test(spec),
|
|
1250
|
-
/** The executable is
|
|
1276
|
+
/** The executable is SELECTED by the launch (a configuration may name a wrapper
|
|
1251
1277
|
* such as `claude-personal`). Probing and installing through the literal
|
|
1252
1278
|
* `claude` would inspect a DIFFERENT account's plugins than the session
|
|
1253
1279
|
* actually launches with — passing preflight while the real harness lacks
|
|
@@ -1848,20 +1874,21 @@ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
|
|
|
1848
1874
|
* Spawn an instance of `agent` (as returned by findAgent/listAgents).
|
|
1849
1875
|
* o: { instance?, purpose?, name?, repo?, work?, harness?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
|
|
1850
1876
|
*/
|
|
1851
|
-
/**
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
|
|
1855
|
-
export function resolveClaudeBinary(contextDir) {
|
|
1877
|
+
/** `oats-claude-config` (a one-line file naming the claude binary, found walking up from the context) is no
|
|
1878
|
+
* longer read (0.32): a host names its claude executable in a default launch configuration. A file still in
|
|
1879
|
+
* reach of a new claude launch refuses it, naming the file and the configuration to declare instead. */
|
|
1880
|
+
export function legacyClaudeConfigRefusal(contextDir) {
|
|
1856
1881
|
let d = resolve(contextDir);
|
|
1857
1882
|
while (true) {
|
|
1858
1883
|
const f = join(d, "oats-claude-config");
|
|
1859
1884
|
if (existsSync(f)) {
|
|
1860
|
-
|
|
1861
|
-
|
|
1885
|
+
let name = null;
|
|
1886
|
+
try { name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")) || null; } catch {}
|
|
1887
|
+
const exe = name ? `executable: ${name}, ` : "";
|
|
1888
|
+
return Object.assign(oatsError("E_CLAUDE_CONFIG_REMOVED", `${f} is no longer read (OATS 0.32): declare this machine's claude default in oats-local.yaml instead — launch-configs: { <name>: { harness: claude, ${exe}default: true } } (oats launch-config set <name> --file <json>) — then delete ${f}`), { details: { file: f, executable: name, fix: `declare a claude default launch configuration (${exe}default: true), then delete ${f}` } });
|
|
1862
1889
|
}
|
|
1863
1890
|
const parent = dirname(d);
|
|
1864
|
-
if (parent === d) return
|
|
1891
|
+
if (parent === d) return null;
|
|
1865
1892
|
d = parent;
|
|
1866
1893
|
}
|
|
1867
1894
|
}
|
|
@@ -1961,11 +1988,10 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
|
|
|
1961
1988
|
const found = [];
|
|
1962
1989
|
const problems = [];
|
|
1963
1990
|
// The session launches with the SELECTED executable (a configuration's,
|
|
1964
|
-
//
|
|
1965
|
-
// `claude-personal`), so probing another binary would inspect a different
|
|
1991
|
+
// the host's harness default included), so probing another binary would inspect a different
|
|
1966
1992
|
// account's packages than the instance will actually use. The probe is the
|
|
1967
1993
|
// manager's controlled list subcommand; no launch argument is added to it.
|
|
1968
|
-
const probeOpts = { context: contextDir, ...(bin ? { bin } :
|
|
1994
|
+
const probeOpts = { context: contextDir, ...(bin ? { bin } : {}) };
|
|
1969
1995
|
for (const cap of resolved.capabilities || []) {
|
|
1970
1996
|
for (const raw of cap.manifest?.requires || []) {
|
|
1971
1997
|
if (!raw || typeof raw !== "object") continue;
|
|
@@ -2059,8 +2085,10 @@ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
|
|
|
2059
2085
|
|
|
2060
2086
|
/** The executable a launch uses: a configuration's declared one (a bare name
|
|
2061
2087
|
* on PATH; a path against the deployment directory when relative) or the
|
|
2062
|
-
* harness's
|
|
2088
|
+
* harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
|
|
2089
|
+
* of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
|
|
2063
2090
|
export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
|
|
2091
|
+
if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
|
|
2064
2092
|
if (declared) {
|
|
2065
2093
|
if (declared.includes("/")) {
|
|
2066
2094
|
const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
|
|
@@ -2069,10 +2097,8 @@ export function resolveLaunchExecutable({ harness, declared, declaringDir, conte
|
|
|
2069
2097
|
const found = which(declared);
|
|
2070
2098
|
return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
|
|
2071
2099
|
}
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
const found = which(name);
|
|
2075
|
-
return { path: found || null, declared: null, resolvedFrom: harness === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
|
|
2100
|
+
const found = which(harness);
|
|
2101
|
+
return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
|
|
2076
2102
|
}
|
|
2077
2103
|
/** null when `path` is a regular executable file; otherwise why not. */
|
|
2078
2104
|
export function checkLaunchExecutable(path) {
|
|
@@ -2107,7 +2133,7 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2107
2133
|
// applies the current definition.
|
|
2108
2134
|
// Named or not: the recorded executable (a saved wrapper, say), args and
|
|
2109
2135
|
// env are what runs; later edits of the scope never change it.
|
|
2110
|
-
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, frozen: true };
|
|
2136
|
+
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, ...(frozen.launchConfigDefault === true ? { harnessDefault: true } : {}), frozen: true };
|
|
2111
2137
|
wanted = "none";
|
|
2112
2138
|
}
|
|
2113
2139
|
if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
|
|
@@ -2120,6 +2146,9 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2120
2146
|
// host default would: below the flags and a selected configuration.
|
|
2121
2147
|
const harness = config?.harness || selection.harness || (frozen ? frozen.harness : preference?.harness || agent?.harness || "pi");
|
|
2122
2148
|
if (!LAUNCH_HARNESSES.includes(harness)) bad("E_UNSUPPORTED_HARNESS", `unknown harness "${harness}" (pi|claude|codex)`);
|
|
2149
|
+
// 0.32 (feature launch-config-default): a launch that selected no configuration runs this host's default
|
|
2150
|
+
// for its harness, if one is declared. An explicit `--launch-config none` asks for the bare harness.
|
|
2151
|
+
const harnessDefault = !config && !(selection.launchConfig === "none" && !preference) ? harnessDefaultConfig(launchConfigs, harness) : null;
|
|
2123
2152
|
let model, modelSource;
|
|
2124
2153
|
// K6: an EXPLICIT "use the harness's native default" is distinct from an
|
|
2125
2154
|
// omitted model (which inherits the configuration's or soul's preference).
|
|
@@ -2145,8 +2174,19 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2145
2174
|
// is no proof it fits another one. Nothing is passed across.
|
|
2146
2175
|
model = ""; modelSource = "native default (harness differs from the soul's)";
|
|
2147
2176
|
} else { model = resolveModelPreference(agent?.model || "", harness); modelSource = model ? "soul default" : "native default"; }
|
|
2177
|
+
// The harness default's model is the last fallback: below every layer that names one, above the harness's own.
|
|
2178
|
+
if (harnessDefault?.model && !model && !nativeDefault) {
|
|
2179
|
+
model = resolveModelPreference(harnessDefault.model, harness); modelSource = `launch-config ${harnessDefault.name}`;
|
|
2180
|
+
if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${harnessDefault.name} (the ${harness} default) names model ${JSON.stringify(harnessDefault.model)}, which has no entry usable by harness ${harness}`);
|
|
2181
|
+
}
|
|
2182
|
+
if (harnessDefault) config = harnessDefault;
|
|
2148
2183
|
return { config, harness, model, modelSource, configuredYolo: config?.yolo };
|
|
2149
2184
|
}
|
|
2185
|
+
/** This host's default launch configuration for `harness` (`default: true`), marked `harnessDefault`, or null. */
|
|
2186
|
+
export function harnessDefaultConfig(launchConfigs, harness) {
|
|
2187
|
+
const found = Object.values(launchConfigs || {}).find((c) => c?.default === true && c.harness === harness);
|
|
2188
|
+
return found ? { ...found, harnessDefault: true } : null;
|
|
2189
|
+
}
|
|
2150
2190
|
/** A home's launch layers now (feature launch-preference; --reselect-launch and inspect's launchCurrent): its
|
|
2151
2191
|
* RECORDED soul's launch (the soul copy it runs) and its deployment's oats-local.yaml souls.launch. */
|
|
2152
2192
|
export function homeLaunchLayers(realHome, meta) {
|
|
@@ -2186,7 +2226,11 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2186
2226
|
if (!Object.hasOwn(launchConfigs, name)) { const e = launchConfigUnknown({ name, from: choice.from, at: choice.at }); return report("pi", null, null, { code: e.code, message: e.message, fix: `declare launch configuration ${name} (oats launch-config set), or change oats-local.yaml souls.launch` }); }
|
|
2187
2227
|
config = launchConfigs[name]; harness = config.harness; model = typeof config.model === "string" && config.model ? config.model : null;
|
|
2188
2228
|
} else if (choice.preference) { harness = choice.preference.harness; model = choice.preference.model ?? null; }
|
|
2189
|
-
|
|
2229
|
+
// As a spawn: a layer naming "none" asks for the bare harness; otherwise no configuration is the harness default.
|
|
2230
|
+
if (!config && !(choice.selection.launchConfig === "none" && !choice.preference)) { config = harnessDefaultConfig(launchConfigs, harness); if (config && !model) model = typeof config.model === "string" && config.model ? config.model : null; }
|
|
2231
|
+
let exe;
|
|
2232
|
+
try { exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir }); }
|
|
2233
|
+
catch (e) { if (e?.code !== "E_CLAUDE_CONFIG_REMOVED") throw e; return report(harness, model, config?.name, { code: e.code, message: e.message, fix: `declare a claude default launch configuration (oats launch-config set), then delete ${e.details.file}` }); }
|
|
2190
2234
|
if (!exe.path && !config?.executable) { const e = harnessUnavailable({ harness, from: choice.from, at: choice.at, why: exe.missing }); return report(harness, model, config?.name, { code: e.code, message: e.message, fix: e.details.fix }); }
|
|
2191
2235
|
if (!exe.path) return report(harness, model, config.name, { code: "E_LAUNCH_EXECUTABLE", message: `launch configuration ${config.name}: ${exe.missing}`, fix: `fix the executable of launch configuration ${config.name} (oats launch-config set)` });
|
|
2192
2236
|
return report(harness, model, config?.name);
|
|
@@ -2244,11 +2288,43 @@ function shimPathPrefix(tokens, binary, home) {
|
|
|
2244
2288
|
if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
|
|
2245
2289
|
return texts;
|
|
2246
2290
|
}
|
|
2291
|
+
/** codex runs tool commands with the PATH it is given here, the one the launching shell has (the
|
|
2292
|
+
* shim first): its value is this host's, so only the execution carries it. The user's login shell,
|
|
2293
|
+
* which Codex runs tool commands through, may still put its own profile entries ahead of it. */
|
|
2294
|
+
const CODEX_TOOL_PATH = `-c "shell_environment_policy.set.PATH=\\"$PATH\\""`;
|
|
2295
|
+
/** The binary and its arguments as the execution runs them. */
|
|
2296
|
+
function executionArgv(tokens, binary, harness) {
|
|
2297
|
+
const texts = tokens.slice(binary).map((t) => t.text);
|
|
2298
|
+
if (harness === "codex") texts.splice(1, 0, CODEX_TOOL_PATH);
|
|
2299
|
+
return texts;
|
|
2300
|
+
}
|
|
2247
2301
|
/** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
|
|
2248
2302
|
* first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
|
|
2249
|
-
export function launchShellCommand(command, home) {
|
|
2303
|
+
export function launchShellCommand(command, home, harness) {
|
|
2250
2304
|
const { tokens, binary } = parseLaunchCommand(command);
|
|
2251
|
-
return [...shimPathPrefix(tokens, binary, home), ...tokens
|
|
2305
|
+
return [...shimPathPrefix(tokens, binary, home), ...executionArgv(tokens, binary, harness)].join(" ");
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** The deployment directory a home belongs to: the one its spawn recorded, else the nearest
|
|
2309
|
+
* ancestor holding oats-local.yaml, else the agents root's parent. */
|
|
2310
|
+
function deploymentOfHome(home, meta) {
|
|
2311
|
+
if (typeof meta?.workspace?.deployment === "string" && meta.workspace.deployment) return realPathOrNearest(meta.workspace.deployment);
|
|
2312
|
+
for (let d = dirname(resolve(home)); ; d = dirname(d)) {
|
|
2313
|
+
if (existsSync(join(d, "oats-local.yaml"))) return realPathOrNearest(d);
|
|
2314
|
+
if (dirname(d) === d) return realPathOrNearest(dirname(dirname(dirname(dirname(resolve(home))))));
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2317
|
+
/** Folder trust for a launch in `home` (lib/harness-trust.mjs, read-only): `trustHome` — the
|
|
2318
|
+
* codex launch trusts the home, because the operator trusts its deployment root or an
|
|
2319
|
+
* ancestor — and the warning when the session will stop at its harness's folder-trust prompt. */
|
|
2320
|
+
export function launchFolderTrust({ harness, home, meta, yolo, env = process.env }) {
|
|
2321
|
+
const root = deploymentOfHome(home, meta);
|
|
2322
|
+
if (harness === "codex") {
|
|
2323
|
+
const trustHome = codexTrustsRoot(root, { env });
|
|
2324
|
+
return { trustHome, warning: harnessTrustWarning({ harness, root, covered: trustHome || yolo === true }) };
|
|
2325
|
+
}
|
|
2326
|
+
if (harness === "claude") return { trustHome: false, warning: harnessTrustWarning({ harness, root, covered: claudeTrusts(home, { env }) }) };
|
|
2327
|
+
return { trustHome: false, warning: null };
|
|
2252
2328
|
}
|
|
2253
2329
|
|
|
2254
2330
|
/** The harness command line of a recipe. With no configuration the bytes
|
|
@@ -2263,19 +2339,38 @@ export function launchShellCommand(command, home) {
|
|
|
2263
2339
|
* under <home>/.agents/skills are found from cwd=home like any repo's — and OATS
|
|
2264
2340
|
* contributes only the composed AGENTS.md (--append-system-prompt). The task
|
|
2265
2341
|
* positional goes ahead of contributed options because pi has no `--`. claude gets `--` before the prompt so a
|
|
2266
|
-
* greedy contributed flag cannot eat it. codex keeps its native policy;
|
|
2267
|
-
*
|
|
2268
|
-
|
|
2342
|
+
* greedy contributed flag cannot eat it. codex keeps its native policy; it
|
|
2343
|
+
* never stops at Codex's update prompt, and the generated home is trusted for
|
|
2344
|
+
* the launch (projects=...) under yolo or `trustHome`: the operator trusts the
|
|
2345
|
+
* deployment root or an ancestor in Codex's config (lib/harness-trust.mjs),
|
|
2346
|
+
* which Codex itself does not apply to the homes below it. Codex's own form
|
|
2347
|
+
* of the override is the inline table: a dotted projects."<home>" key is not
|
|
2348
|
+
* honoured (codex-cli 0.157.1). */
|
|
2349
|
+
export function renderLaunchRecipe(recipe, { home, instance, redact = false, trustHome = false }) {
|
|
2269
2350
|
const { harness, executable, model, yolo } = recipe;
|
|
2270
2351
|
const cfgArgs = (recipe.args || []).map(shq).join(" ");
|
|
2271
2352
|
const hookArgs = recipe.hooks?.launch?.[harness] || "";
|
|
2272
2353
|
const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
|
|
2354
|
+
// The launch's environment, in prefix order: the instance, the capabilities' env, the
|
|
2355
|
+
// configuration's env (a reference by reference, never by value).
|
|
2356
|
+
const hookEnv = recipe.hooks?.env || {};
|
|
2357
|
+
const env = [["OATS_INSTANCE", instance], ["OATS_INSTANCE_HOME", home], ["PI_AGENT_INSTANCE", instance], ["PI_AGENT_HOME", home]].map(([name, value]) => ({ name, value }));
|
|
2358
|
+
for (const name of Object.keys(hookEnv).sort()) env.push({ name, value: redact ? "<redacted>" : hookEnv[name] });
|
|
2359
|
+
for (const name of Object.keys(recipe.env || {}).sort()) {
|
|
2360
|
+
const v = recipe.env[name];
|
|
2361
|
+
env.push(typeof v === "string" ? { name, value: redact ? "<redacted>" : v } : { name, reference: true });
|
|
2362
|
+
}
|
|
2273
2363
|
let cmdline;
|
|
2274
2364
|
if (harness === "claude") {
|
|
2275
2365
|
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
2276
2366
|
} else if (harness === "codex") {
|
|
2277
2367
|
const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
|
|
2278
|
-
|
|
2368
|
+
// Codex may run tool commands outside the session's process (its shared app-server daemon),
|
|
2369
|
+
// so the env the prefix below gives the session is also set for them. A reference's value
|
|
2370
|
+
// never goes on argv, and PATH is the execution's (CODEX_TOOL_PATH: the shim first).
|
|
2371
|
+
const toolEnvArgs = env.filter((e) => !e.reference && e.name !== "PATH")
|
|
2372
|
+
.map((e) => ` -c ${shq(`shell_environment_policy.set.${e.name}=${JSON.stringify(e.value)}`)}`).join("");
|
|
2373
|
+
cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
2279
2374
|
} else {
|
|
2280
2375
|
// Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
|
|
2281
2376
|
// .agents/skills up the tree, so the instance's copied capability skills are
|
|
@@ -2283,13 +2378,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
|
|
|
2283
2378
|
// the composed instructions.
|
|
2284
2379
|
cmdline = `${shq(executable)} --append-system-prompt ${shq(join(home, "AGENTS.md"))} --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${tail}`;
|
|
2285
2380
|
}
|
|
2286
|
-
|
|
2287
|
-
const envTokens = Object.keys(hookEnv).sort().map((name) => `${name}=${shq(redact ? "<redacted>" : hookEnv[name])}`);
|
|
2288
|
-
for (const name of Object.keys(recipe.env || {}).sort()) {
|
|
2289
|
-
const v = recipe.env[name];
|
|
2290
|
-
envTokens.push(typeof v === "string" ? `${name}=${shq(redact ? "<redacted>" : v)}` : `${name}="$${LAUNCH_REF_PREFIX}${name}"`);
|
|
2291
|
-
}
|
|
2292
|
-
return `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${envTokens.length ? ` ${envTokens.join(" ")}` : ""} ${cmdline}`;
|
|
2381
|
+
return `${env.map((e) => e.reference ? `${e.name}="$${LAUNCH_REF_PREFIX}${e.name}"` : `${e.name}=${shq(e.value)}`).join(" ")} ${cmdline}`;
|
|
2293
2382
|
}
|
|
2294
2383
|
|
|
2295
2384
|
/** Execution, not preview: mark pending before dispatch, then resolve native
|
|
@@ -2301,7 +2390,7 @@ function nativeRecordCommand(command, home, harness) {
|
|
|
2301
2390
|
const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
|
|
2302
2391
|
const id = prepareNativeStart(home, harness);
|
|
2303
2392
|
const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
|
|
2304
|
-
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens
|
|
2393
|
+
const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${executionArgv(tokens, binary, harness).join(" ")}`;
|
|
2305
2394
|
return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
|
|
2306
2395
|
}
|
|
2307
2396
|
|
|
@@ -2364,7 +2453,10 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2364
2453
|
if (selection.launchConfig !== undefined || selection.harness !== undefined) launchChoice = { from: "flag", at: null, declared: meta?.launchDeclared ?? null };
|
|
2365
2454
|
}
|
|
2366
2455
|
const { config, harness, model, modelSource } = chosen;
|
|
2367
|
-
|
|
2456
|
+
// A recorded yolo that came from a harness default belongs to that default: it never carries into a launch
|
|
2457
|
+
// the default no longer decides (a bare --launch-config none, another harness).
|
|
2458
|
+
const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
|
|
2459
|
+
const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
|
|
2368
2460
|
const executable = config?.frozen
|
|
2369
2461
|
? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
|
|
2370
2462
|
: resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
|
|
@@ -2432,16 +2524,17 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2432
2524
|
} else problems.push({ check: "harness-packages", ok: true, detail: "no harness package requirement declared for this harness; nothing probed" });
|
|
2433
2525
|
}
|
|
2434
2526
|
const recipe = {
|
|
2435
|
-
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
|
|
2527
|
+
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null, ...(config?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
2436
2528
|
executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
|
|
2437
2529
|
args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
2438
2530
|
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
|
|
2439
2531
|
...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
|
|
2440
2532
|
};
|
|
2441
2533
|
const inst = instance || meta?.instance || basename(home);
|
|
2442
|
-
const
|
|
2534
|
+
const { trustHome } = launchFolderTrust({ harness, home, meta, yolo: recipe.yolo, env });
|
|
2535
|
+
const command = renderLaunchRecipe(recipe, { home, instance: inst, trustHome });
|
|
2443
2536
|
const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.harness) ? "frozen" : "config") : "config";
|
|
2444
|
-
return { recipe, command, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
|
|
2537
|
+
return { recipe, command, trustHome, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, launchChoice, warnings: hooks.warnings || [], ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
|
|
2445
2538
|
}
|
|
2446
2539
|
/** The environment a planned launch runs under: the host's base, the
|
|
2447
2540
|
* capabilities' validated env, the configuration's literals and its
|
|
@@ -2984,7 +3077,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2984
3077
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
2985
3078
|
decision, preflight,
|
|
2986
3079
|
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
2987
|
-
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
3080
|
+
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, launchConfigDefault: launchConfig?.harnessDefault === true, yolo, backend,
|
|
2988
3081
|
launch: launchReport({ declared: launchChoice.declared, harness, model, launchConfig: launchConfig?.name, from: launchChoice.from, at: launchChoice.at }),
|
|
2989
3082
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
2990
3083
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
@@ -3523,7 +3616,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3523
3616
|
const shimTarget = writeKernelShim(home);
|
|
3524
3617
|
const recipe = {
|
|
3525
3618
|
version: LAUNCH_RECIPE_VERSION, harness,
|
|
3526
|
-
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
|
|
3619
|
+
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null, ...(launchConfig?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
3527
3620
|
executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
|
|
3528
3621
|
args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
|
|
3529
3622
|
model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
@@ -3531,7 +3624,9 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3531
3624
|
prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
|
|
3532
3625
|
};
|
|
3533
3626
|
if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
|
|
3534
|
-
const
|
|
3627
|
+
const folderTrust = launchFolderTrust({ harness, home, meta: { workspace: { deployment: o.prepared?.deployment } }, yolo });
|
|
3628
|
+
if (folderTrust.warning) warnings.push(folderTrust.warning);
|
|
3629
|
+
const cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
|
|
3535
3630
|
|
|
3536
3631
|
// Module skills as materialize landed them (flat, .agents/skills/<skill>/),
|
|
3537
3632
|
// beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
|
|
@@ -3838,6 +3933,29 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
|
|
|
3838
3933
|
return out;
|
|
3839
3934
|
}
|
|
3840
3935
|
|
|
3936
|
+
/** The working directory as the shell names it: $PWD when it is the process's own directory, else
|
|
3937
|
+
* the physical cwd. A path through a symlink (an attached instance's work/, which links into its
|
|
3938
|
+
* owner's tree) then still belongs to the instance it was entered from. */
|
|
3939
|
+
export function logicalCwd() {
|
|
3940
|
+
const cwd = process.cwd(), pwd = process.env.PWD;
|
|
3941
|
+
try { if (pwd && isAbsolute(pwd) && realpathSync(pwd) === realpathSync(cwd)) return pwd; } catch { /* a stale PWD */ }
|
|
3942
|
+
return cwd;
|
|
3943
|
+
}
|
|
3944
|
+
/** The instance home enclosing `dir` (itself or its nearest such ancestor): a directory laid out
|
|
3945
|
+
* as <agents-root>/<agent>/instances/<name> whose instance.json records `instance: <name>`.
|
|
3946
|
+
* For a process whose harness strips the session env (Codex's shared app-server daemon runs
|
|
3947
|
+
* tool commands outside the session); the caller validates the home as it does one named by
|
|
3948
|
+
* OATS_INSTANCE_HOME. undefined when no such directory encloses `dir`. */
|
|
3949
|
+
export function enclosingInstanceHome(dir) {
|
|
3950
|
+
for (let d = resolve(dir); ; d = dirname(d)) {
|
|
3951
|
+
if (basename(dirname(d)) === "instances" && INSTANCE_NAME_RE.test(basename(d))) {
|
|
3952
|
+
try { if (JSON.parse(readFileSync(join(d, "instance.json"), "utf8"))?.instance === basename(d)) return d; }
|
|
3953
|
+
catch { /* no readable instance.json: not a home */ }
|
|
3954
|
+
}
|
|
3955
|
+
if (dirname(d) === d) return undefined;
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3841
3959
|
// Locate an instance home under an agents root, including capability-defined
|
|
3842
3960
|
// agents homing under <root>/<name>/ WITHOUT a soul there (listAgents cannot
|
|
3843
3961
|
// see those). Shared by retireInstance and `oats spawn --parent`.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** Folder trust for the claude and codex harnesses (#341): whether a new instance home is
|
|
2
|
+
* covered by the operator's one-time trust of the deployment root, so the launched session
|
|
3
|
+
* does not stop at the harness's folder-trust prompt.
|
|
4
|
+
*
|
|
5
|
+
* The operator's harness configuration is only read, never written: trusting the deployment
|
|
6
|
+
* root is the operator's act. A configuration that is missing, unreadable or in a form this
|
|
7
|
+
* reader does not know counts as not trusted, so a launch is warned about rather than
|
|
8
|
+
* silently stalled.
|
|
9
|
+
*
|
|
10
|
+
* - Claude walks up from its working directory, stopping at a git root, and honours
|
|
11
|
+
* `projects["<dir>"].hasTrustDialogAccepted` in ~/.claude.json
|
|
12
|
+
* ($CLAUDE_CONFIG_DIR/.claude.json when that is set). Instance homes are not inside a git
|
|
13
|
+
* repository, so one entry for the deployment root covers every home under it.
|
|
14
|
+
* - Codex honours only an exact `[projects."<dir>"] trust_level = "trusted"` entry in
|
|
15
|
+
* ~/.codex/config.toml ($CODEX_HOME/config.toml), never a parent's. A per-invocation
|
|
16
|
+
* override does trust a home, so the kernel's codex launch adds one for the home when the
|
|
17
|
+
* operator trusts the deployment root or an ancestor of it (renderLaunchRecipe). */
|
|
18
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
23
|
+
const userHome = (env) => env.HOME || homedir();
|
|
24
|
+
|
|
25
|
+
/** `dir` and its ancestors, nearest first. */
|
|
26
|
+
function* upwards(dir) {
|
|
27
|
+
for (let d = real(dir); ; d = dirname(d)) { yield d; if (dirname(d) === d) return; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether Claude Code trusts `home` without asking: an accepted trust entry for the home or
|
|
31
|
+
* an ancestor, up to and including the nearest git root. */
|
|
32
|
+
export function claudeTrusts(home, { env = process.env } = {}) {
|
|
33
|
+
const file = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, ".claude.json") : join(userHome(env), ".claude.json");
|
|
34
|
+
let projects;
|
|
35
|
+
try { projects = JSON.parse(readFileSync(file, "utf8"))?.projects; } catch { return false; }
|
|
36
|
+
if (!projects || typeof projects !== "object") return false;
|
|
37
|
+
for (const d of upwards(home)) {
|
|
38
|
+
if (Object.hasOwn(projects, d) && projects[d]?.hasTrustDialogAccepted === true) return true;
|
|
39
|
+
if (existsSync(join(d, ".git"))) return false;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A TOML key: bare, "basic" (JSON-compatible escapes) or 'literal'. null when not a key. */
|
|
45
|
+
function tomlKey(text) {
|
|
46
|
+
const t = text.trim();
|
|
47
|
+
if (/^[A-Za-z0-9_-]+$/.test(t)) return t;
|
|
48
|
+
if (/^'[^']*'$/.test(t)) return t.slice(1, -1);
|
|
49
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) { try { return JSON.parse(t); } catch { return null; } }
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** A dotted TOML key path, split on the dots outside quotes. null when any part is not a key. */
|
|
53
|
+
function tomlPath(text) {
|
|
54
|
+
const parts = [];
|
|
55
|
+
let cur = "", quote = null;
|
|
56
|
+
for (let i = 0; i < text.length; i++) {
|
|
57
|
+
const c = text[i];
|
|
58
|
+
if (quote) { cur += c; if (c === "\\" && quote === '"') cur += text[++i] ?? ""; else if (c === quote) quote = null; }
|
|
59
|
+
else if (c === '"' || c === "'") { quote = c; cur += c; }
|
|
60
|
+
else if (c === ".") { parts.push(cur); cur = ""; }
|
|
61
|
+
else cur += c;
|
|
62
|
+
}
|
|
63
|
+
if (quote) return null;
|
|
64
|
+
parts.push(cur);
|
|
65
|
+
const keys = parts.map(tomlKey);
|
|
66
|
+
return keys.includes(null) ? null : keys;
|
|
67
|
+
}
|
|
68
|
+
const tomlString = (text) => { const t = text.trim(); return /^'[^']*'$/.test(t) || /^"(?:[^"\\]|\\.)*"$/.test(t) ? tomlKey(t) : null; };
|
|
69
|
+
/** The text of a line before a `#` comment that is outside quotes. */
|
|
70
|
+
function uncommented(line) {
|
|
71
|
+
let quote = null;
|
|
72
|
+
for (let i = 0; i < line.length; i++) {
|
|
73
|
+
const c = line[i];
|
|
74
|
+
if (quote) { if (c === "\\" && quote === '"') i++; else if (c === quote) quote = null; }
|
|
75
|
+
else if (c === '"' || c === "'") quote = c;
|
|
76
|
+
else if (c === "#") return line.slice(0, i);
|
|
77
|
+
}
|
|
78
|
+
return line;
|
|
79
|
+
}
|
|
80
|
+
/** `key = value` split at the first `=` outside quotes. */
|
|
81
|
+
function assignment(line) {
|
|
82
|
+
let quote = null;
|
|
83
|
+
for (let i = 0; i < line.length; i++) {
|
|
84
|
+
const c = line[i];
|
|
85
|
+
if (quote) { if (c === "\\" && quote === '"') i++; else if (c === quote) quote = null; }
|
|
86
|
+
else if (c === '"' || c === "'") quote = c;
|
|
87
|
+
else if (c === "=") return [line.slice(0, i), line.slice(i + 1)];
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The directories config.toml marks `trust_level = "trusted"`, in the forms Codex writes and
|
|
93
|
+
* their plain TOML equivalents: `[projects."<dir>"]` tables, `"<dir>" = { trust_level = … }`
|
|
94
|
+
* under `[projects]`, and dotted `projects."<dir>".trust_level` keys. */
|
|
95
|
+
function codexTrustedDirs(text) {
|
|
96
|
+
const trusted = new Set();
|
|
97
|
+
let table = [];
|
|
98
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
99
|
+
const line = uncommented(raw).trim();
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
if (line.startsWith("[")) {
|
|
102
|
+
const m = /^\[\s*([^[\]]+?)\s*\]$/.exec(line);
|
|
103
|
+
table = m && !line.startsWith("[[") ? tomlPath(m[1]) ?? [null] : [null];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const kv = assignment(line);
|
|
107
|
+
if (!kv) continue;
|
|
108
|
+
const key = tomlPath(kv[0].trim());
|
|
109
|
+
if (!key) continue;
|
|
110
|
+
const path = [...table, ...key], value = kv[1].trim();
|
|
111
|
+
if (path.length === 3 && path[0] === "projects" && path[2] === "trust_level" && tomlString(value) === "trusted") trusted.add(path[1]);
|
|
112
|
+
const inline = /^\{\s*trust_level\s*=\s*("[^"]*"|'[^']*')\s*\}$/.exec(value);
|
|
113
|
+
if (path.length === 2 && path[0] === "projects" && inline && tomlString(inline[1]) === "trusted") trusted.add(path[1]);
|
|
114
|
+
}
|
|
115
|
+
return trusted;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether Codex's config trusts the deployment `root` or one of its ancestors: the operator's
|
|
119
|
+
* one-time consent for the kernel to trust each new home under it at launch. */
|
|
120
|
+
export function codexTrustsRoot(root, { env = process.env } = {}) {
|
|
121
|
+
const file = join(env.CODEX_HOME || join(userHome(env), ".codex"), "config.toml");
|
|
122
|
+
let trusted;
|
|
123
|
+
try { trusted = codexTrustedDirs(readFileSync(file, "utf8")); } catch { return false; }
|
|
124
|
+
for (const d of upwards(root)) if (trusted.has(d)) return true;
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The step that trusts the deployment root, per harness. */
|
|
129
|
+
const TRUST_STEP = {
|
|
130
|
+
claude: (root) => `run \`claude\` in ${root} and accept its folder-trust prompt; one entry covers every instance home under it`,
|
|
131
|
+
codex: (root) => `run \`codex\` in ${root} and choose "Trust and continue"; OATS then trusts each new home under it at launch`,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The spawn and readiness warning for a home its harness will not trust without asking;
|
|
135
|
+
* null when `covered`, or for a harness without a folder-trust prompt. */
|
|
136
|
+
export function harnessTrustWarning({ harness, root, covered }) {
|
|
137
|
+
if (covered || !Object.hasOwn(TRUST_STEP, harness)) return null;
|
|
138
|
+
return `the ${harness} session will stop at its folder-trust prompt: trust ${root} once (${TRUST_STEP[harness](root)})`;
|
|
139
|
+
}
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
|
|
|
14
14
|
import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
15
15
|
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
|
|
17
|
+
import { capabilityManifests, sessionDefaults, homeLaunchLayers, instanceSoulDir, launchConfigsAt, launchFolderTrust, launchReportFor, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
|
|
18
18
|
import { agentDirOf, discoverOrStandalone, ensureWorkspaceSoul, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
|
|
19
19
|
import { declaredSettings } from "./capability-contract.mjs";
|
|
20
20
|
import { kernelCompatibility } from "./resolve.mjs";
|
|
@@ -265,7 +265,8 @@ export function inspectDocument(t, { kernel }) {
|
|
|
265
265
|
// What a new tmux spawn here would open in: {tmuxSession} (0.31).
|
|
266
266
|
session: t.session ?? null,
|
|
267
267
|
knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
|
|
268
|
-
instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null,
|
|
268
|
+
instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null,
|
|
269
|
+
launchConfig: m.launch?.launchConfig ?? null, launchConfigDefault: m.launch?.launchConfigDefault === true, launched: !!m.launched, createdAt: m.createdAt || null,
|
|
269
270
|
resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
|
|
270
271
|
...(m ? { identity: servedIdentityOf(m) } : {}),
|
|
271
272
|
problems: [...t.soul.problems, ...(t.soul.copyError ? [t.soul.copyError] : []), ...(t.discoveryError ? [t.discoveryError] : []),
|
|
@@ -307,6 +308,20 @@ function launchItems(t) {
|
|
|
307
308
|
reason: `this instance runs ${show(a)}; its launch preference now says ${show(b)} (${t.launchCurrent.at ?? t.launchCurrent.from})`,
|
|
308
309
|
remedy: "`oats session restart --reselect-launch`, or respawn", recorded: a, current: b, from: t.launchCurrent.from, at: t.launchCurrent.at })];
|
|
309
310
|
}
|
|
311
|
+
/** `harness-trust` (#341; a warning): the claude or codex session this subject launches will stop at
|
|
312
|
+
* its folder-trust prompt, because the operator has not trusted the deployment. An instance's recorded
|
|
313
|
+
* harness and home; a soul's launch here, for a home under the agents root. Reads, never writes, the
|
|
314
|
+
* harness's configuration (lib/harness-trust.mjs). */
|
|
315
|
+
function folderTrustItems(t) {
|
|
316
|
+
const harness = t.home ? t.meta?.harness : t.launch?.effective?.harness;
|
|
317
|
+
if (harness !== "claude" && harness !== "codex") return [];
|
|
318
|
+
const meta = t.home ? t.meta : { workspace: { deployment: t.deployment } };
|
|
319
|
+
// A soul's yolo is its selected launch configuration's (a host fact, never the soul's).
|
|
320
|
+
const config = () => { try { return launchConfigsAt(t.deployment)[t.launch?.effective?.launchConfig]; } catch { return undefined; } };
|
|
321
|
+
const yolo = t.home ? (t.meta?.yolo ?? t.meta?.launch?.yolo) : config()?.yolo;
|
|
322
|
+
const { warning } = launchFolderTrust({ harness, home: t.home ?? t.agentsRoot, meta, yolo });
|
|
323
|
+
return warning ? [item("launch", "fail", { required: false, producer: "harness folder trust", code: "harness-trust", reason: warning, harness, deployment: t.deployment })] : [];
|
|
324
|
+
}
|
|
310
325
|
function roll(items) {
|
|
311
326
|
const req = items.filter((i) => i.required !== false);
|
|
312
327
|
if (!items.length) return "not-applicable";
|
|
@@ -410,7 +425,7 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
|
|
|
410
425
|
evidence: { command: req.command }, remedy: found ? null : req.install, ...cap(mod.name) }));
|
|
411
426
|
}
|
|
412
427
|
}
|
|
413
|
-
configured.push(...teamItems(t), ...launchItems(t));
|
|
428
|
+
configured.push(...teamItems(t), ...launchItems(t), ...folderTrustItems(t));
|
|
414
429
|
// member — the soul's member repository is a confirmed member of the workspace
|
|
415
430
|
// (oats-membership.yaml backlink observed over the remotes).
|
|
416
431
|
const d = t.discovery;
|