@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/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
- function which(bin) { return shTry(`command -v ${shq(bin)}`); }
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
- if (!existsSync(file)) return empty;
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
- try { doc = JSON.parse(readFileSync(file, "utf8")); }
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 CONTEXT-SELECTED (oats-claude-config may name a wrapper
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
- /** The claude binary for a context: closest `oats-claude-config` (a one-line file
1852
- * naming the binary, e.g. "claude-personal") walking up from contextDir wins; no
1853
- * file → "claude". Local-only by design — a personal machine preference (account
1854
- * selection), never committed config; keep it out of version control. */
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
- const name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
1861
- if (name) return name;
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 "claude";
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
- // or the context-selected one: oats-claude-config may name
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 } : harness === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
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 default (claude through oats-claude-config). Never executed. */
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 claudeBin = harness === "claude" ? resolveClaudeBinary(contextDir) : undefined;
2073
- const name = harness === "claude" ? claudeBin : harness;
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
- const exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
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.slice(binary).map((t) => t.text)].join(" ");
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
- * yolo also trusts this generated home for the launch (projects=...). */
2268
- export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
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
- cmdline = `${shq(executable)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
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
- const hookEnv = recipe.hooks?.env || {};
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.slice(binary).map(t => t.text).join(" ")}`;
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
- const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozen.yolo : agentLike?.yolo ?? resolvedCfg?.yolo));
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 command = renderLaunchRecipe(recipe, { home, instance: inst });
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 cmdline = renderLaunchRecipe(recipe, { home, instance });
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
+ }
@@ -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, launched: !!m.launched, createdAt: m.createdAt || 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;