@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/README.md +1 -1
- package/bin/oats.mjs +128 -20
- package/docs/capabilities.md +22 -0
- package/docs/configuration.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +231 -7
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +78 -2
- package/docs/integrations.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/release-notes/v0.34.0.md +63 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/capability-show.mjs +208 -0
- package/lib/core.mjs +89 -17
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +16 -2
- package/lib/packages.mjs +1 -1
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +721 -112
- package/lib/resolve.mjs +56 -14
- package/package-catalog.json +2 -2
- package/package.json +1 -1
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
|
|
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
|
-
*
|
|
2311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
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";
|
|
@@ -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) => {
|
package/lib/process-group.mjs
CHANGED
|
@@ -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
|
+
}
|