@awebai/oats 0.32.0 → 0.34.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
@@ -46,6 +46,7 @@ import { killGroup } from "./process-group.mjs";
46
46
  import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
47
47
  import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
48
48
  import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
49
+ import { claudeTrusts, codexTrustsRoot, harnessTrustWarning } from "./harness-trust.mjs";
49
50
  async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
50
51
  // Capability rows for a PREPARED spawn (workspace model): the one function that
51
52
  // turns a Resolution's modules into the row shape hooks/environment/requirements/
@@ -2287,11 +2288,43 @@ function shimPathPrefix(tokens, binary, home) {
2287
2288
  if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
2288
2289
  return texts;
2289
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
+ }
2290
2301
  /** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
2291
2302
  * first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
2292
- export function launchShellCommand(command, home) {
2303
+ export function launchShellCommand(command, home, harness) {
2293
2304
  const { tokens, binary } = parseLaunchCommand(command);
2294
- 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 };
2295
2328
  }
2296
2329
 
2297
2330
  /** The harness command line of a recipe. With no configuration the bytes
@@ -2306,19 +2339,38 @@ export function launchShellCommand(command, home) {
2306
2339
  * under <home>/.agents/skills are found from cwd=home like any repo's — and OATS
2307
2340
  * contributes only the composed AGENTS.md (--append-system-prompt). The task
2308
2341
  * positional goes ahead of contributed options because pi has no `--`. claude gets `--` before the prompt so a
2309
- * greedy contributed flag cannot eat it. codex keeps its native policy;
2310
- * yolo also trusts this generated home for the launch (projects=...). */
2311
- 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 }) {
2312
2350
  const { harness, executable, model, yolo } = recipe;
2313
2351
  const cfgArgs = (recipe.args || []).map(shq).join(" ");
2314
2352
  const hookArgs = recipe.hooks?.launch?.[harness] || "";
2315
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
+ }
2316
2363
  let cmdline;
2317
2364
  if (harness === "claude") {
2318
2365
  cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2319
2366
  } else if (harness === "codex") {
2320
2367
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
2321
- 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)"`;
2322
2374
  } else {
2323
2375
  // Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
2324
2376
  // .agents/skills up the tree, so the instance's copied capability skills are
@@ -2326,13 +2378,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
2326
2378
  // the composed instructions.
2327
2379
  cmdline = `${shq(executable)} --append-system-prompt ${shq(join(home, "AGENTS.md"))} --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${tail}`;
2328
2380
  }
2329
- const hookEnv = recipe.hooks?.env || {};
2330
- const envTokens = Object.keys(hookEnv).sort().map((name) => `${name}=${shq(redact ? "<redacted>" : hookEnv[name])}`);
2331
- for (const name of Object.keys(recipe.env || {}).sort()) {
2332
- const v = recipe.env[name];
2333
- envTokens.push(typeof v === "string" ? `${name}=${shq(redact ? "<redacted>" : v)}` : `${name}="$${LAUNCH_REF_PREFIX}${name}"`);
2334
- }
2335
- 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}`;
2336
2382
  }
2337
2383
 
2338
2384
  /** Execution, not preview: mark pending before dispatch, then resolve native
@@ -2344,7 +2390,7 @@ function nativeRecordCommand(command, home, harness) {
2344
2390
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2345
2391
  const id = prepareNativeStart(home, harness);
2346
2392
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2347
- 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(" ")}`;
2348
2394
  return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
2349
2395
  }
2350
2396
 
@@ -2485,9 +2531,10 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2485
2531
  ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
2486
2532
  };
2487
2533
  const inst = instance || meta?.instance || basename(home);
2488
- 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 });
2489
2536
  const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.harness) ? "frozen" : "config") : "config";
2490
- 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 } : {}) };
2491
2538
  }
2492
2539
  /** The environment a planned launch runs under: the host's base, the
2493
2540
  * capabilities' validated env, the configuration's literals and its
@@ -3577,7 +3624,9 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3577
3624
  prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
3578
3625
  };
3579
3626
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3580
- 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 });
3581
3630
 
3582
3631
  // Module skills as materialize landed them (flat, .agents/skills/<skill>/),
3583
3632
  // beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
@@ -3884,6 +3933,29 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3884
3933
  return out;
3885
3934
  }
3886
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
+
3887
3959
  // Locate an instance home under an agents root, including capability-defined
3888
3960
  // agents homing under <root>/<name>/ WITHOUT a soul there (listAgents cannot
3889
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";
@@ -308,6 +308,20 @@ function launchItems(t) {
308
308
  reason: `this instance runs ${show(a)}; its launch preference now says ${show(b)} (${t.launchCurrent.at ?? t.launchCurrent.from})`,
309
309
  remedy: "`oats session restart --reselect-launch`, or respawn", recorded: a, current: b, from: t.launchCurrent.from, at: t.launchCurrent.at })];
310
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
+ }
311
325
  function roll(items) {
312
326
  const req = items.filter((i) => i.required !== false);
313
327
  if (!items.length) return "not-applicable";
@@ -411,7 +425,7 @@ export async function readinessDocument(t, { selector = null, remoteOptions, cat
411
425
  evidence: { command: req.command }, remedy: found ? null : req.install, ...cap(mod.name) }));
412
426
  }
413
427
  }
414
- configured.push(...teamItems(t), ...launchItems(t));
428
+ configured.push(...teamItems(t), ...launchItems(t), ...folderTrustItems(t));
415
429
  // member — the soul's member repository is a confirmed member of the workspace
416
430
  // (oats-membership.yaml backlink observed over the remotes).
417
431
  const d = t.discovery;
package/lib/packages.mjs CHANGED
@@ -452,7 +452,7 @@ export function memoizedRemote(remote) {
452
452
  export function bindRemote(remote, remoteOptions) {
453
453
  if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
454
454
  const bound = { ...remote };
455
- const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
455
+ const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, listRemoteFiles: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
456
456
  for (const name of Object.keys(arities)) {
457
457
  if (typeof remote[name] !== "function") continue;
458
458
  bound[name] = (...args) => {
@@ -10,3 +10,57 @@ export function killGroup(child, signal = "SIGKILL") {
10
10
  try { process.kill(pid, signal); } catch { /* already gone */ }
11
11
  return true;
12
12
  }
13
+
14
+ /** How long a group asked to end with SIGTERM gets before SIGKILL. */
15
+ export const TERM_GRACE_MS = 2_000;
16
+
17
+ /** Children whose 'close' has fired (watchGroup): the leader exited and was reaped, its stdio pipes shut. */
18
+ const closedGroups = new WeakSet();
19
+
20
+ /** Record a detached child's 'close': call it right after spawning a child that terminateGroup may end. → the child. */
21
+ export function watchGroup(child) {
22
+ child.once("close", () => closedGroups.add(child));
23
+ return child;
24
+ }
25
+
26
+ /** How often terminateGroup checks, after the leader's 'close', whether its group is empty yet. */
27
+ export const GROUP_PROBE_MS = 50;
28
+
29
+ /** Whether a detached child's process group still has a member we may signal (ESRCH: empty; EPERM: not ours). */
30
+ function groupAlive(child) {
31
+ try { process.kill(-child.pid, 0); return true; } catch { return false; }
32
+ }
33
+
34
+ /** End a detached child's whole process group gracefully: SIGTERM now, SIGKILL to the group after `graceMs`.
35
+ * git removes its own lock files (`shallow.lock`, `config.lock`, a ref's `.lock`) on SIGTERM, never on SIGKILL:
36
+ * a git killed outright leaves a lock that blocks every later write. The timers are unref'd, so they never keep
37
+ * the process alive. A group seen empty is never signalled again: its id may then lead an unrelated group.
38
+ * Returns whether anything was signalled. */
39
+ export function terminateGroup(child, graceMs = TERM_GRACE_MS) {
40
+ // A child already closed may have left an empty group, whose id is free: send nothing.
41
+ if (closedGroups.has(child)) return false;
42
+ if (!signalGroup(child, "SIGTERM")) return false;
43
+ // Until 'close', the group holds a pipe-holding member (git, or its ssh or remote helper), so its id is ours and
44
+ // the SIGKILL may come. After 'close' the leader is gone but a member that ignores SIGTERM and holds no pipe may
45
+ // remain: no pid is allocated while it is a live group's id, so a group seen non-empty is still ours. It is probed
46
+ // until the grace ends: empty → no SIGKILL, ever; still there → SIGKILL. What remains is a group that empties and is
47
+ // reused within one GROUP_PROBE_MS.
48
+ let probe = null;
49
+ const done = () => { clearTimeout(timer); clearInterval(probe); };
50
+ const timer = setTimeout(() => { clearInterval(probe); signalGroup(child, "SIGKILL"); }, graceMs);
51
+ timer.unref?.();
52
+ child.once?.("close", () => {
53
+ if (!groupAlive(child)) { done(); return; }
54
+ probe = setInterval(() => { if (!groupAlive(child)) done(); }, GROUP_PROBE_MS);
55
+ probe.unref?.();
56
+ });
57
+ return true;
58
+ }
59
+
60
+ /** Signal a detached child's process group only (see killGroup for the pid-0 guard). → whether it was a real child. */
61
+ export function signalGroup(child, signal) {
62
+ const pid = child?.pid;
63
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
64
+ try { process.kill(-pid, signal); } catch { /* the group is gone */ }
65
+ return true;
66
+ }