@awebai/oats 0.32.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.
@@ -0,0 +1,174 @@
1
+ # OATS 0.33.0
2
+
3
+ ## Added
4
+
5
+ - **`oats spawn <soul> --preview --max-age <seconds>`** (feature
6
+ `spawn-preview-max-age`). A preview reuses member heads this machine
7
+ observed at most that many seconds ago, as the read verbs do since 0.32.0,
8
+ so a warm preview asks no remote. With the flag the preview's JSON carries
9
+ the read verbs' `observation {observedAt, reused, localRevision}` block;
10
+ without it the preview is unchanged. The decision still covers the heads
11
+ the preview used: an apply with `--expect-decision` observes live and
12
+ refuses `E_DECISION_STALE` when a reused head has moved, and a re-preview
13
+ then shows the new head. The apply refuses `--max-age` (`E_BAD_ARGS`), and
14
+ the refusal message of every form that refuses the flag now lists
15
+ `spawn --preview` among the read forms.
16
+
17
+ ## Changed
18
+
19
+ - **OATS fetches only what it reads from a remote** (awebai/oats#384). A
20
+ commit comes with its trees and its files up to 64 KiB; a larger file is
21
+ fetched when a read or a module needs it. The first spawn from a large
22
+ workspace host downloads megabytes instead of its whole tree (tsm: 13 MB in
23
+ 3 s, instead of 2.4 GB in 6.5 minutes). A server that cannot serve partial
24
+ fetches (a default `git daemon`, an old self-hosted git), or a local git
25
+ older than 2.45, gets whole trees as before, with one `oats: warning` per
26
+ repository.
27
+
28
+ - **Installing a soul's modules reads their files without a process per
29
+ file.** `oats spawn` copies each module's files from the remote cache
30
+ through the command's one `git cat-file --batch` reader per repository,
31
+ as the reads and discovery already do, instead of one `git cat-file blob`
32
+ per file. A Desktop developer spawn on this deployment ran 50 to 59
33
+ processes instead of 81 (34 per-file reads became 3 to 7 readers).
34
+ Budgets, digests, partial caches and errors are unchanged.
35
+
36
+ - **oats.engineering 1.4.0** (catalog and workspace pin, and the bundled
37
+ mirrors): the developer's loop REQUIRES loading `/understand-the-spec` and
38
+ `/execution-strategy` before implementation.
39
+
40
+ - **oats.aweb 1.17.5** (catalog and workspace pin, and the bundled mirror;
41
+ 1.17.4 and 1.17.5): a faster spawn and retire. The spawn hook mints the identity with one
42
+ `aw init --join-from` instead of `aw team invite` + `aw team join` +
43
+ `aw init`, so the invite token no longer appears in any argv. The aw floor
44
+ check stops at `aw version`'s version line instead of waiting on its GitHub
45
+ update check. Retire deletes by workspace id, with `aw wake deregister`
46
+ running at the same time. Measured spawn hook: 7.72 s to 4.62 s mean
47
+ (awebai/oats-aweb#31). Hook output, compensation and the aw floor (1.36.13)
48
+ are unchanged. The mint now has one 120 s budget where three calls had
49
+ about 210 s; raise `OATS_AWEB_JOIN_TIMEOUT_MS` on a slow network. Since the
50
+ hook no longer holds the invite token, 1.17.5 always records the alias it
51
+ requested and warns, without quoting the reply, when aw reports another
52
+ (awebai/oats-aweb#32).
53
+
54
+ - **Pressing Spawn (Cmd-Enter) closes the spawn dialog at once.** The spawn
55
+ completes in the background: a pending row ("Spawning…") appears in the
56
+ sidebar roster where the instance will stand, and is replaced in place by
57
+ the real row once the roster reports it. Outcomes arrive as notifications:
58
+ "<name> spawned" with Open, what didn't finish for a partial or incomplete
59
+ spawn (with View schedules for a wake that wasn't saved), and, for a
60
+ refused or failed spawn, the reason with **Reopen spawn**, which restores
61
+ the whole draft. An unknown outcome keeps the row, reading "Outcome
62
+ unknown", with Check result. Prepare reuses the dialog's fresh preview
63
+ instead of reading it again, and the kernel's `--expect-decision` still
64
+ refuses a decision that changed. Dialog previews use `--max-age 60` when the
65
+ CLI supports it (feature `spawn-preview-max-age`)
66
+ ([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
67
+
68
+ - **The spawn dialog never waits on the preview while you edit.** The last
69
+ preview stays visible while it updates, Spawn stays pressable, and answers
70
+ are reused for 60 s (#378).
71
+
72
+ - **Core capabilities and Capabilities read as one system** on the soul page,
73
+ the capability page, the instance inspector and the spawn preview. Every
74
+ core row says why it's there, including a slot the soul empties or that has
75
+ no default. The capability page opened from a soul gains a "Why" row.
76
+ Workspace › Capabilities lists Repo owned before Packages (#377).
77
+
78
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.34.0`**, so it runs against
79
+ this release's kernel. Install the CLI and the Desktop 0.33.0 together: the
80
+ Desktop 0.32.x refuses a 0.33 CLI.
81
+
82
+ - **Unattended claude and codex launches** (awebai/oats#341,
83
+ [souls-and-instances.md](../souls-and-instances.md#unattended-launches-folder-trust)).
84
+ - Every codex launch passes `-c check_for_update_on_startup=false`, so
85
+ Codex's update prompt no longer blocks it.
86
+ - Codex does not apply a trusted parent to the folders below it. When
87
+ `~/.codex/config.toml` trusts the deployment or an ancestor, a codex
88
+ launch now trusts its new home for that session; the file is never
89
+ written.
90
+ - A claude or codex spawn whose home is not covered by the operator's
91
+ one-time trust of the deployment warns `the <harness> session will stop
92
+ at its folder-trust prompt: trust <deployment> once (…)`. `oats readiness`
93
+ reports the same as a `harness-trust` item in `checks.configured` (not
94
+ required).
95
+
96
+ - **The Desktop names an OATS cache problem, and keeps the roster through an
97
+ unreadable remote.** When the kernel can't read a remote
98
+ (`E_REMOTE_UNREADABLE`), the sidebar roster keeps the instances it last
99
+ observed instead of going empty. A cache problem (`details.reason: "cache"`)
100
+ reads "Couldn't refresh instances · OATS cache problem · observed <age>",
101
+ with the kernel's message in full (it names the lock file or the process
102
+ holding it, and the remedy) and Retry. With nothing observed yet, the failed
103
+ state shows that message. A network failure or a timeout reads "Couldn't
104
+ reach <host> · showing what was read <age>", with the raw code behind
105
+ Details. Of the kernel's structured `details`, only the reason and the
106
+ host of the remote cross to the window; the kernel's message is shown as
107
+ given, with the file or process it names
108
+ ([desktop-cli-api.md](../desktop-cli-api.md#workspace)).
109
+
110
+ ## Fixed
111
+
112
+ - **A background spawn never fails silently, and a soul can't be spawned
113
+ twice at once** (awebai/oats#383). While a spawn of a soul is in flight,
114
+ opening Spawn for it shows the press disabled with "A spawn of <soul> is in
115
+ progress" and a link to its pending row; it re-enables when the spawn
116
+ settles. After a window reload, a spawn whose outcome wasn't known yet comes
117
+ back as a pending row and its outcome is reported, a failure included, even
118
+ when it settled while another workspace was on screen; Reopen spawn then
119
+ restores the soul and the name. Quitting the Desktop mid-spawn
120
+ still loses an unreported failure
121
+ ([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md#background-spawn-the-dialog-closes-on-spawn)).
122
+
123
+ - **A git that the system kills is no longer reported as a timeout**
124
+ (awebai/oats#387). A remote read reports `timeout` only when OATS's own
125
+ timer stopped git. When something else kills git (an out-of-memory kill
126
+ during a large fetch, for example), the error now reads `cannot read remote
127
+ <url> (killed): git was killed (signal SIGKILL)`, with `reason: killed` and
128
+ `details.signal`. Before, it said `(timeout)`.
129
+
130
+ - **Codex sessions see their instance** (awebai/oats#342). Codex can run tool
131
+ commands under its shared app-server daemon, without the session's
132
+ environment, so `oats aweb roster` inside Codex asked for `--soul`.
133
+ - A codex launch now sets the instance environment for tool commands with
134
+ `shell_environment_policy.set`: `OATS_INSTANCE`/`OATS_INSTANCE_HOME`, the
135
+ capabilities' launch environment (`AWEB_IDENTITY_HOME`, `AWEB_DELIVERY`)
136
+ and the launch configuration's literals.
137
+ - It also sets `PATH` with the home's `.oats/bin` first. The user's login
138
+ shell may put its profile's entries ahead of it.
139
+ - A capability command with no home in its environment finds the home from
140
+ its working directory.
141
+
142
+ - The partial-fetch tests now pass on a host whose git is older than 2.45
143
+ (awebai/oats#389). The kernel already fell back to whole trees there; the
144
+ tests now read the kernel's own git probe:
145
+ - on an older git they assert that fallback (whole trees, one warning per
146
+ repository);
147
+ - they skip the partial-cache mechanics that cannot happen there, and name
148
+ why;
149
+ - under CI, an older git fails those tests instead.
150
+
151
+ - **A killed fetch no longer leaves a remote cache unusable** (awebai/oats#386).
152
+ OATS ends git with SIGTERM first, so git removes its own lock files, and
153
+ SIGKILL only after a grace. A lock left by a git killed earlier is removed
154
+ once it is older than the longest fetch, with an `oats: warning` naming it.
155
+ A younger one is never removed: the error names the file and says it is
156
+ safe to remove once no oats or git process is running.
157
+
158
+ - **Ending git leaves no survivor in its process group** (awebai/oats#386). After
159
+ git's own output closes, OATS keeps checking git's process group until the grace
160
+ ends: a member that ignores SIGTERM and holds no pipe is still killed, and a
161
+ group seen empty is never signalled again (its id may already belong to another
162
+ process).
163
+
164
+ - **Processes making the first fetch from one remote at once all succeed**
165
+ (two spawns, two Desktop previews). Every write to a cache takes a
166
+ per-cache lock: a live holder is waited for, a dead one's lock is reclaimed,
167
+ and the cache repo appears whole. A failure to write the local cache is now
168
+ reported as `E_REMOTE_UNREADABLE` with the new reason `cache`, never as
169
+ `network`.
170
+
171
+ - Fetching a commit into the remote cache may take 10 minutes instead of 30 s, so
172
+ the first spawn from a large workspace host no longer fails with `cannot read
173
+ remote … (timeout)`; the timeout error names the fetch and the elapsed time
174
+ (awebai/oats#362).
@@ -204,6 +204,70 @@ the harness's own precedence. The `CLAUDE.md → AGENTS.md` and
204
204
  `.claude/skills → ../.agents/skills` aliases are kept. OATS composes
205
205
  instructions and pins model/provider settings; it excludes nothing.
206
206
 
207
+ ### Unattended launches: folder trust
208
+
209
+ Claude Code and Codex ask before they work in a folder they have not seen, and
210
+ every instance home is new. A launch stopped at that prompt waits for a human,
211
+ so the operator trusts the **deployment directory** (where `oats-local.yaml`
212
+ is) once per harness. OATS only reads the harnesses' configuration; it never
213
+ writes it.
214
+
215
+ - **Claude Code** looks for an accepted entry for its folder or an ancestor, up
216
+ to a git root. Instance homes are not inside a git repository, so one entry
217
+ for the deployment covers every home under it. To add it, run `claude` in the
218
+ deployment once and accept the prompt. That records
219
+ `projects["<deployment>"].hasTrustDialogAccepted` in `~/.claude.json`
220
+ (`$CLAUDE_CONFIG_DIR/.claude.json` when that is set).
221
+ - **Codex** applies only an exact entry: a trusted parent does not cover the
222
+ folders below it. To give the operator's consent, run `codex` in the
223
+ deployment once and choose "Trust and continue". That records
224
+ `[projects."<deployment>"] trust_level = "trusted"` in
225
+ `~/.codex/config.toml` (`$CODEX_HOME/config.toml`). With that entry, or one
226
+ for an ancestor of the deployment, each codex launch trusts its own new home
227
+ for that session (`-c 'projects={"<home>"={trust_level="trusted"}}'`) and
228
+ leaves the config file unchanged. A yolo launch always does this. The plan
229
+ re-reads the entry at every start.
230
+ - Every codex launch also passes `-c check_for_update_on_startup=false`, so
231
+ Codex's "Update available" choice cannot block it.
232
+
233
+ When a claude or codex home is not covered, the spawn says so (text and
234
+ `--json` `warnings`): `the <harness> session will stop at its folder-trust
235
+ prompt: trust <deployment> once (<the step>)`. `oats readiness` reports the
236
+ same in `checks.configured` (code `harness-trust`, not required).
237
+
238
+ ### Codex tool commands and the instance environment
239
+
240
+ Codex can run tool commands under its shared app-server daemon rather than as
241
+ children of the session OATS launched, and then they do not inherit the
242
+ session's environment. Codex (0.157.1) runs a session that has `-c` overrides
243
+ embedded, without the daemon, and every kernel codex launch has them, so an
244
+ OATS codex session does not appear in `codex agents`. So that the environment
245
+ does not depend on this, a codex launch also sets it for tool
246
+ commands explicitly with `-c shell_environment_policy.set.<NAME>="<value>"`:
247
+
248
+ - the instance: `OATS_INSTANCE`, `OATS_INSTANCE_HOME`, `PI_AGENT_INSTANCE`,
249
+ `PI_AGENT_HOME`;
250
+ - every capability's launch environment (for example the messaging
251
+ provider's identity home and delivery mode);
252
+ - the launch configuration's literal values. A reference's value never goes
253
+ on a command line.
254
+
255
+ `PATH` comes from the launching shell, with the home's `.oats/bin` first, so
256
+ only the execution passes it; the persisted command does not carry it. Codex
257
+ runs tool commands through the user's login shell, and a profile that prepends
258
+ directories puts those entries ahead of `.oats/bin`. A second `oats` in such a
259
+ directory is found first.
260
+
261
+ A capability command (`oats <namespace> …`) run with none of
262
+ `OATS_INSTANCE_HOME`, `PI_AGENT_HOME` or `OATS_HOME` set finds its instance
263
+ from the working directory. It uses the nearest enclosing directory laid out as
264
+ `<agents-root>/<soul>/instances/<name>` whose `instance.json` records that
265
+ name, and validates it like a home named by the environment. The walk uses the
266
+ directory as the shell names it (`$PWD`). That matters for an attached
267
+ instance, whose `work/` links into its owner's tree: below it, the physical
268
+ path is the owner's. A process that has no `$PWD` there would act as the
269
+ owner, so an attached instance runs capability commands from its home.
270
+
207
271
  ## Lifecycle
208
272
 
209
273
  ### Spawn
@@ -183,7 +183,7 @@ as `confirmed` or the reason it is not:
183
183
  | `not-listed` | the workspace does not list the repo |
184
184
  | `no-backlink` | no (or invalid) `oats-membership.yaml` at the member's default branch |
185
185
  | `backlink-elsewhere` | the member names a different workspace (a case-only difference is flagged: repo paths are case-sensitive identities) |
186
- | `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout) |
186
+ | `cannot-read` | the operator cannot read the member (auth / not-found / network / timeout / killed: the system killed git, e.g. out of memory), or this machine's remote cache could not be written (cache) |
187
187
 
188
188
  An unconfirmed member contributes nothing but its row: its souls are invisible,
189
189
  its capabilities unresolvable (`E_NOT_A_MEMBER` / `E_MEMBERSHIP_UNCONFIRMED`).
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;