@agfpd/iapeer 0.4.77 → 0.4.79
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/docs/16-tray.md +22 -18
- package/docs/17-approval.md +8 -6
- package/docs/ru/16-tray.md +22 -16
- package/package.json +1 -1
- package/src/approval/install.ts +15 -6
- package/src/launch/adapters/codex.ts +44 -14
- package/src/launch/types.ts +7 -0
- package/src/lifecycle/index.ts +14 -3
- package/src/status/index.ts +62 -1
- package/src/tray/render.ts +43 -57
package/docs/16-tray.md
CHANGED
|
@@ -34,24 +34,28 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
34
34
|
a human is attached, `🔒` launchd-managed (H4 read-only), `🛡` the peer runs in **gated**
|
|
35
35
|
human-approval mode (docs/17; `yolo` is the fleet default and carries no badge — a bare
|
|
36
36
|
row reads as yolo), `⏳N` an ephemeral peer's queue depth.
|
|
37
|
-
- **
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
- **Each peer is a SUBMENU** — one unified list (no separate Manage section; the peer rows
|
|
38
|
+
and a parallel Manage list of every peer used to be a visible dupe). Clicking a peer
|
|
39
|
+
EXPANDS it; its actions are the children, in order:
|
|
40
|
+
- **Attach** (agent peers only) — the FIRST child. Runs `iapeer tray attach-term <peer>`,
|
|
41
|
+
which writes a per-peer `.command` launcher and `open`s it — `open <file>.command` hands
|
|
42
|
+
the file to Terminal.app with **no Automation/Accessibility permission prompt** (this
|
|
43
|
+
deliberately avoids SwiftBar's own `terminal=true`, whose Terminal launch drives a `Cmd-T`
|
|
44
|
+
System-Events keystroke that silently no-ops unless SwiftBar holds Accessibility). Attach
|
|
45
|
+
is now expand-then-Attach (two clicks) — the trade for a single dupe-free list.
|
|
46
|
+
- **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, each a
|
|
47
|
+
`POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`. (No Wake item — a peer-row
|
|
48
|
+
click path already wakes a sleeping peer before it attaches, so it was redundant.)
|
|
49
|
+
- **Approval-mode toggle** (agent peers only, docs/17): reads the current mode and flips it
|
|
50
|
+
via the local `iapeer approval-mode <peer> <mode>` verb, applied on the peer's **next fresh
|
|
51
|
+
session** (a click never respawns a live session). The friction is asymmetric to the
|
|
52
|
+
security weight: `yolo → gated` (adds human approval) is one safe tap; `gated → yolo`
|
|
53
|
+
(REMOVES the approval perimeter) sits one level deeper behind an explicit red ⚠ confirm, so
|
|
54
|
+
it is never dropped by a stray click.
|
|
55
|
+
|
|
56
|
+
**Launchd-managed infra peers (telegram / notifier, `🔒`)** are not pty-attachable, so their
|
|
57
|
+
submenu carries **no Attach** and **no approval toggle** (always yolo) — just the lifecycle
|
|
58
|
+
commands (service Stop / Start etc.).
|
|
55
59
|
- **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
|
|
56
60
|
each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
|
|
57
61
|
**verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
|
package/docs/17-approval.md
CHANGED
|
@@ -4,7 +4,7 @@ A peer can run in one of two **approval modes**. In `yolo` (the default — ever
|
|
|
4
4
|
|
|
5
5
|
This is a **normative contract**. The approval broker is the daemon's, the fleet endpoints and `ev` kinds below are the surface that operator faces (CLI, tray, the Telegram runtime) are written against — not the daemon source. The interception facts (which runtime event fires, in which shape) are pinned to **Claude Code 2.1.201** and **codex-cli 0.142.5**, taken from the live binaries + current runtime docs.
|
|
6
6
|
|
|
7
|
-
`gated` means **«ask before acting
|
|
7
|
+
`gated` means **«ask before acting»** — blocking questions surface instead of being bypassed. On **claude**, gated stays sandbox-free (the `PermissionRequest` hook is the interceptor). On **codex**, gated is the EXEMPLAR (reframe 07.07): it runs `approvals ON + a workspace-write sandbox` so codex shows its **OWN native approval modal** in the TUI on a risky action — an attached human answers it right there — while the supervisor proxies that same modal to the bar. iapeer installs **no broker hook** on gated codex (a `PreToolUse` hook would silence the native modal). The shared memory vault (outside every peer cwd) is granted writable so routine memory writes never prompt. See the codex toggle table.
|
|
8
8
|
|
|
9
9
|
## The approval-mode toggle
|
|
10
10
|
|
|
@@ -37,17 +37,17 @@ The bypass is a **launch argument**, different per runtime, and the mode is a co
|
|
|
37
37
|
|
|
38
38
|
**Why matcher-free `PermissionRequest`, not `PreToolUse`.** `PermissionRequest` fires **only when the runtime's permission config decided to prompt** — a call not covered by an allow/deny rule. So the policy of *what to ask* stays 100% at the runtime: a user tunes it with ordinary permission rules (a tool in `permissions.allow` is never asked → the hook never fires → auto-allowed). No class matcher to drift, no new tool slipping through, no hang. iapeer only seeds the hook + the one allow-rule for the peer's own MCP tool.
|
|
39
39
|
|
|
40
|
-
**codex** (
|
|
40
|
+
**codex** (native modal + supervisor proxy — codex-cli 0.142.5):
|
|
41
41
|
|
|
42
42
|
| # | Surface | yolo | gated |
|
|
43
43
|
|---|---|---|---|
|
|
44
44
|
| X1 | argv `--dangerously-bypass-approvals-and-sandbox` | present | **removed** |
|
|
45
|
-
| X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=
|
|
46
|
-
| X3 | `PreToolUse` hook
|
|
45
|
+
| X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=workspace-write -c sandbox_workspace_write.network_access=false` (+ `-c sandbox_workspace_write.writable_roots=[…]` when the host has a memory vault). approvals **ON**, an OS sandbox that makes cwd/`/tmp`/`$TMPDIR` + the vault silent and a risky write (outside those) or outbound network pop codex's **native modal**. The old `danger-full-access` was disproven by the live sandbox matrix (07.07): `send_to_peer` (MCP) is unaffected by the sandbox because Seatbelt wraps the shell CHILDREN codex spawns, not the codex process, so the loopback MCP call is never sandboxed; and the vault is restored via `writable_roots`. Session-scoped via `-c`, not host config. |
|
|
46
|
+
| X3 | `PreToolUse` broker hook | **absent** | **absent** — gated codex relies on its NATIVE modal + the supervisor proxy, so iapeer installs no broker hook (it would silence the native modal). A prior (pre-reframe) hook is removed on the next gated toggle. |
|
|
47
47
|
| X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (both modes) | pre-trusted (matters more with approvals active) |
|
|
48
48
|
| X5 | ready-gate `isInputReady` (`›` composer) | mode-independent | unchanged |
|
|
49
49
|
|
|
50
|
-
The codex
|
|
50
|
+
The gated↔yolo difference for codex is thus ONLY the launch config + the profile field (no hooks.json either way), so the round-trip is byte-identical by construction. (The `iapeer approval-hook` binary + its codex `allow`→abstain shaping stay in the tree — still correct for any deny-by-rule hook an operator adds, and for claude — they are simply not installed by the codex gated toggle.)
|
|
51
51
|
|
|
52
52
|
### Idempotency and the application moment
|
|
53
53
|
|
|
@@ -62,7 +62,7 @@ All surfaces are read/applied **at session start**. A **live session keeps its l
|
|
|
62
62
|
Both runtimes carry a Claude-compatible **hook** system that intercepts an approval programmatically and returns `allow`/`deny` + a reason + the structured action content. That is the primary mechanism. **pty screen-scrape is a backstop for one class only** — claude's `dangerous-rm` circuit-breaker, which sits *above* the permission layer and no hook can see.
|
|
63
63
|
|
|
64
64
|
- **claude `PermissionRequest`** — stdin carries `{tool_name, tool_input, …}`; the hook prints `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. A `deny` carries `message` to the model. Fires only on prompt-worthy calls.
|
|
65
|
-
- **codex
|
|
65
|
+
- **codex** — gated codex is NOT hook-gated (reframe): it runs `approval_policy=on-request` + a `workspace-write` sandbox and shows its **own native modal**, which the supervisor pty-scrape catches and routes (see below). The `iapeer approval-hook` binary still exists and its codex path is correct (verified live 0.142.5: codex fires `PreToolUse` with `tool_name:"Bash"`; a `deny` hard-blocks the tool and the reason reaches the model; a codex `allow` is unsupported by codex's `PreToolUse` so the hook **abstains** on allow rather than emitting a rejected decision) — but the codex gated toggle does not install it. It remains available for a deny-by-rule hook an operator wires by hand.
|
|
66
66
|
- **supervisor circuit-breaker** — claude's `dangerous-rm`/`rmdir` guard (and the standard command-approval prompt a no-bypass session shows) is a TUI select the hook never sees. The pty supervisor reads it off its authoritative model and routes by class (see *Supervisor pty-scrape classification* below): the known auto-Yes classes are pressed under yolo (with an audit line) / brokered under gated, while `org-policy` and any unrecognized `unknown-modal` always go to the human.
|
|
67
67
|
|
|
68
68
|
The hook binary is the `iapeer approval-hook` subcommand: it reads the runtime's hook JSON on stdin, resolves `PEER_PERSONALITY`/`PEER_RUNTIME` from its session env and the daemon URL from `router.json`, POSTs a blocking request to the broker, and prints the runtime-appropriate decision JSON.
|
|
@@ -81,6 +81,8 @@ The supervisor nag-watcher classifies every mid-session blocking TUI modal it ob
|
|
|
81
81
|
- **`org-policy`** is a barrier ABOVE the peer (an MCP org-restriction). It is recognized but **always routed to the human** on both modes (the owner's org rule is human-only), with the precise 3-option keys. Previously it was excluded from the matcher and a yolo peer HUNG on it — now closed.
|
|
82
82
|
- **`unknown-modal`** is the generic residue: a numbered-select modal (a new one Anthropic/OpenAI shipped) matching none of the known signatures. It is **always routed to the human** on both modes. Because the option layout is unknown, the injection is **binary** (v1): **Allow presses option 1** (the proceed/primary position in every known select; the position-robust affirmative) and **Deny presses `Escape`** (the universal modal cancel — it commits no numbered choice, so it can never accidentally affirm). The broker `content` carries **explicit button semantics** — the verbatim modal block plus a header stating exactly what Allow presses (`option 1: "<verbatim label>"`) and that Deny cancels via Esc — so the human decides informed, not on an abstract Allow/Deny. A **>2-way** choice on an unknown modal is a v2 extension (most approvals are binary). If a modal ignores the injected keys (Esc not honored), the daemon **bounds re-enqueue** (a few rounds) then **logs once and safe-parks** — the peer stays blocked (safe), never an infinite ask loop nor a blind Yes. Both claude (`❯`) and codex (`›`) are covered; codex's known boot dialogs are excluded (owned by the boot-driver).
|
|
83
83
|
|
|
84
|
+
> **This is exactly the path that surfaces gated codex's native approval modal** (the reframe). Verified live (codex-cli 0.142.5): a write outside the workspace/writable roots yields `Would you like to run the following command? … › 1. Yes, proceed (y) / 2. … / 3. No, and tell Codex what to do differently (esc) — Press enter to confirm or esc to cancel`. The footer matches the select-footer signature, so `detectNumberedModal('›')` catches it; **Allow = option 1** ("Yes, proceed") and **Deny = Esc** (which is codex's own "No" option here), so the generic binary injection is exactly right for it. So gated codex needs no bespoke breaker — it rides this same generic path, and an attached human can equally answer the modal in the TUI.
|
|
85
|
+
|
|
84
86
|
The supervisor scrape is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm above the permission layer; a non-hookable modal such as codex MCP-elicitation). A blocking prompt that is NOT a numbered select (a free-form text-input prompt) is out of v1 scope — the numbered-select family is the approval/confirm class in practice.
|
|
85
87
|
|
|
86
88
|
## Nomenclature v1 and content per action (criterion «what is being approved»)
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -32,22 +32,28 @@ SwiftBar.app, если его нет.
|
|
|
32
32
|
`👤` подключён человек, `🔒` под launchd (H4 read-only), `🛡` пир в режиме **gated**
|
|
33
33
|
human-approval (docs/17; `yolo` — дефолт флота, без бейджа: голая строка читается как yolo),
|
|
34
34
|
`⏳N` глубина очереди эфемерного пира.
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
35
|
+
- **Каждый пир — ПОДМЕНЮ** — один единый список (без отдельной секции Manage; строки пиров и
|
|
36
|
+
параллельный Manage-список всех пиров раньше были видимым дублем). Клик по пиру РАСКРЫВАЕТ
|
|
37
|
+
его; действия — дочерние пункты, по порядку:
|
|
38
|
+
- **Attach** (только агент-пиры) — ПЕРВЫЙ пункт. Запускает `iapeer tray attach-term <peer>`:
|
|
39
|
+
пишет per-peer `.command`-лаунчер и `open`'ит его — `open <файл>.command` отдаёт файл
|
|
40
|
+
Terminal.app **без запроса Automation/Accessibility** (намеренно обходит родной
|
|
41
|
+
`terminal=true` SwiftBar, чей запуск Terminal бьёт `Cmd-T` кейстроком через System Events и
|
|
42
|
+
молча не срабатывает без права Accessibility). Attach теперь = раскрыть-и-Attach (два клика) —
|
|
43
|
+
плата за единый список без дубля.
|
|
44
|
+
- **Lifecycle** — Stop / Start / New / Interrupt / Refresh / Compact, каждое —
|
|
45
|
+
`POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`. (Пункта Wake нет — путь клика по
|
|
46
|
+
пиру и так будит спящего перед аттачем, отдельный Wake избыточен.)
|
|
47
|
+
- **Переключатель approval-режима** (только агент-пиры, docs/17): читает текущий режим и флипает
|
|
48
|
+
локальным verb'ом `iapeer approval-mode <peer> <mode>`, применяется на **следующей свежей
|
|
49
|
+
сессии** пира (клик никогда не респавнит живую сессию). Трение асимметрично весу безопасности:
|
|
50
|
+
`yolo → gated` (добавляет human-approval) — один безопасный тап; `gated → yolo` (СНИМАЕТ
|
|
51
|
+
периметр approval) на уровень глубже за явным красным ⚠ confirm, чтобы не сбросить случайным
|
|
52
|
+
кликом.
|
|
53
|
+
|
|
54
|
+
**Launchd-инфра-пиры (telegram / notifier, `🔒`)** не pty-аттачатся — их подменю несёт **без
|
|
55
|
+
Attach** и **без approval-переключателя** (всегда yolo), только lifecycle-команды (Stop / Start
|
|
56
|
+
сервиса и т.д.).
|
|
51
57
|
- **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
|
|
52
58
|
ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
|
|
53
59
|
**дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
|
package/package.json
CHANGED
package/src/approval/install.ts
CHANGED
|
@@ -188,12 +188,21 @@ export function setApprovalMode(cwd: string, mode: ApprovalMode, env: NodeJS.Pro
|
|
|
188
188
|
if (mode === 'gated') surfaces.push(`claude: ${installClaudeApproval(cwd, env) ? 'PermissionRequest hook + MCP allow-rule installed' : 'settings write SKIPPED (non-object)'}`)
|
|
189
189
|
else surfaces.push(`claude: ${removeClaudeApproval(cwd) ? 'hook + allow-rule removed' : 'nothing to remove'}`)
|
|
190
190
|
} else if (rt === 'codex') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
191
|
+
// Gated codex is the EXEMPLAR (docs/17 reframe, 07.07): it shows its OWN native approval modal
|
|
192
|
+
// (from the on-request / workspace-write launch config in codex buildArgv) and the SUPERVISOR
|
|
193
|
+
// proxies that modal to the bar (unknownBlockingModal → routeCircuitBreaker). So iapeer installs
|
|
194
|
+
// NO broker PreToolUse hook on gated codex — the hook would SILENCE the native modal (its whole
|
|
195
|
+
// pre-reframe purpose). We still REMOVE any hook a prior (pre-reframe) gated toggle left behind, so
|
|
196
|
+
// an existing peer migrates cleanly to the native-modal model. yolo is identical (no hook). The
|
|
197
|
+
// gated↔yolo difference for codex is now ONLY the launch config + the profile field, not hooks.json
|
|
198
|
+
// — so the round-trip stays byte-identical. (claude keeps its PermissionRequest broker hook — its
|
|
199
|
+
// native prompt is what that hook cleanly intercepts; only codex switched to the native-modal model.)
|
|
200
|
+
const removed = removeCodexApproval(cwd, env)
|
|
201
|
+
surfaces.push(
|
|
202
|
+
mode === 'gated'
|
|
203
|
+
? `codex: native modal + supervisor proxy, no broker hook${removed ? ' (cleared prior hook)' : ''}`
|
|
204
|
+
: `codex: ${removed ? 'hooks.json + trust removed' : 'trust cleared (no hooks.json)'}`,
|
|
205
|
+
)
|
|
197
206
|
}
|
|
198
207
|
}
|
|
199
208
|
return { mode, surfaces }
|
|
@@ -209,13 +209,32 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
209
209
|
* NO currency — no marketplace/install/update on this path.
|
|
210
210
|
*/
|
|
211
211
|
buildArgv(spec: LaunchSpec, cfg: LaunchAdapterConfig): string[] {
|
|
212
|
-
// Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
212
|
+
// Human-approval mode (docs/17). yolo (default) = the YOLO bypass flag (current fleet behavior).
|
|
213
|
+
//
|
|
214
|
+
// gated = codex as the EXEMPLAR (docs/17 reframe, 07.07): the peer shows its OWN NATIVE approval
|
|
215
|
+
// modal in the TUI on a risky action — an attached human approves right there — while the supervisor
|
|
216
|
+
// proxies that same modal to the bar (unknownBlockingModal → routeCircuitBreaker → broker card). So
|
|
217
|
+
// gated launches WITHOUT the bypass and WITHOUT a broker PreToolUse hook (the hook would SILENCE the
|
|
218
|
+
// native modal — that was the pre-reframe design; setApprovalMode no longer installs it on gated
|
|
219
|
+
// codex). The config, session-scoped `-c` (never a host-config edit):
|
|
220
|
+
// - `approval_policy=on-request` — the model asks before a non-auto-approved action → native modal.
|
|
221
|
+
// - `sandbox_mode=workspace-write` — writes to the peer cwd (+ /tmp, $TMPDIR) are SILENT; a write
|
|
222
|
+
// OUTSIDE those is blocked → native modal. This SUPERSEDES the old `danger-full-access` (whose
|
|
223
|
+
// comment claimed ws-write would sever MCP + vault writes — DISPROVEN by the live sandbox matrix
|
|
224
|
+
// 07.07): send_to_peer (MCP) goes THROUGH regardless of network_access because Seatbelt wraps the
|
|
225
|
+
// shell CHILDREN codex spawns, not the codex process itself, so the loopback MCP call is never
|
|
226
|
+
// sandboxed; and the vault is restored to writable via writable_roots just below.
|
|
227
|
+
// - `sandbox_workspace_write.writable_roots=[…]` — the extra roots (the shared memory vault, which
|
|
228
|
+
// lives OUTSIDE every peer cwd) so ROUTINE memory writes stay silent instead of popping the modal.
|
|
229
|
+
// Omitted when spec.writableRoots is empty (no memory provider). The value is a TOML inline array
|
|
230
|
+
// of strings — JSON.stringify yields exactly that (paths quoted, `"`/`\` escaped); launch shell-
|
|
231
|
+
// quotes the whole `-c` element once, so spaces/`~` in the path arrive intact. Array `-c` form
|
|
232
|
+
// verified live (codex-cli 0.142.5).
|
|
233
|
+
// - `sandbox_workspace_write.network_access=false` — pin outbound network OFF (the ws-write default,
|
|
234
|
+
// set explicitly so a codex default change can't silently open it). Real outbound stays a risk →
|
|
235
|
+
// native modal; MCP send_to_peer is unaffected (see above).
|
|
218
236
|
const gated = spec.approvalMode === 'gated'
|
|
237
|
+
const roots = spec.writableRoots ?? []
|
|
219
238
|
return [
|
|
220
239
|
cfg.codexBin,
|
|
221
240
|
...(spec.resume ? ['resume', '--last'] : []),
|
|
@@ -226,7 +245,15 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
226
245
|
? ['-c', `model_instructions_file=${spec.systemPromptFile}`]
|
|
227
246
|
: []),
|
|
228
247
|
...(gated
|
|
229
|
-
? [
|
|
248
|
+
? [
|
|
249
|
+
'-c',
|
|
250
|
+
'approval_policy=on-request',
|
|
251
|
+
'-c',
|
|
252
|
+
'sandbox_mode=workspace-write',
|
|
253
|
+
...(roots.length > 0 ? ['-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`] : []),
|
|
254
|
+
'-c',
|
|
255
|
+
'sandbox_workspace_write.network_access=false',
|
|
256
|
+
]
|
|
230
257
|
: ['--dangerously-bypass-approvals-and-sandbox']),
|
|
231
258
|
...(spec.extraArgs ?? []),
|
|
232
259
|
]
|
|
@@ -259,13 +286,16 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
259
286
|
},
|
|
260
287
|
|
|
261
288
|
/**
|
|
262
|
-
* GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness)
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
289
|
+
* GENERIC unknown blocking-modal detector (docs/17 — yolo-robustness) — AND, since the gated reframe
|
|
290
|
+
* (07.07), the catcher for codex's OWN NATIVE approval modal. codex declares no blockingConfirm: under
|
|
291
|
+
* yolo the bypass flag means no modal ever shows; under gated codex shows its native `approval_policy=
|
|
292
|
+
* on-request` / sandbox modal, which is a numbered `›` select — so THIS structural detector (bottom-most
|
|
293
|
+
* `›` is a numbered option + ≥2 options; see modalDetect.ts) is what surfaces it to the human, exactly
|
|
294
|
+
* like a truly-unforeseen modal. The daemon routes it to the human (bar card) with explicit button
|
|
295
|
+
* semantics + the timing stuck-gate on top; an ATTACHED human can equally answer it in the TUI directly.
|
|
296
|
+
* Known boot dialogs (owned by the boot-driver) are excluded first. NB modalDetect keys on a SELECT
|
|
297
|
+
* FOOTER (isSelectFooter) — that the codex approval modal's footer matches it is a LIVE-verified
|
|
298
|
+
* invariant (docs/17 §gated-codex); a codex footer that ever diverges would need its own signature here.
|
|
269
299
|
*/
|
|
270
300
|
unknownBlockingModal(pane: string): { content: string; option1: string } | null {
|
|
271
301
|
if (isKnownCodexBlockingSignature(pane)) return null
|
package/src/launch/types.ts
CHANGED
|
@@ -175,6 +175,13 @@ export interface LaunchSpec {
|
|
|
175
175
|
* requests route to a human via the daemon broker (a PreToolUse hook, installed
|
|
176
176
|
* separately at toggle time, is the interceptor). buildArgv reads it. */
|
|
177
177
|
approvalMode?: ApprovalMode
|
|
178
|
+
/** Extra sandbox-writable roots BEYOND the peer cwd, for a gated codex peer launched under
|
|
179
|
+
* `sandbox_mode=workspace-write` (docs/17). Consumed ONLY by codex's gated buildArgv (emitted as
|
|
180
|
+
* `-c sandbox_workspace_write.writable_roots=[…]`) — claude/router/yolo ignore it, the same
|
|
181
|
+
* codex-only shape as `disallowedTools` is claude-only. Resolved at spec-build from the host's
|
|
182
|
+
* memory provider (the shared vault lives OUTSIDE every peer cwd, so a memory write would otherwise
|
|
183
|
+
* hit codex's native modal); empty/omitted → no extra roots. */
|
|
184
|
+
writableRoots?: string[]
|
|
178
185
|
}
|
|
179
186
|
|
|
180
187
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/
|
|
|
28
28
|
import { err, ok, type Result } from '../core/errors.ts'
|
|
29
29
|
import { capPaneLogs } from '../launch/cmdlog.ts'
|
|
30
30
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
31
|
+
import { resolveMemoryWritableRoots } from '../status/index.ts'
|
|
31
32
|
import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
|
|
32
33
|
import {
|
|
33
34
|
ephemeralQueueDepth,
|
|
@@ -1109,6 +1110,17 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1109
1110
|
? composePeerPrompt(peer, cwd, identity, cfg, env, peersIndex.peers.map(publicPeerSummary))
|
|
1110
1111
|
: undefined
|
|
1111
1112
|
|
|
1113
|
+
// Human-approval mode from the LOCAL profile (docs/17): drives the runtime bypass flag +
|
|
1114
|
+
// gated permission/approval config in buildArgv. Default yolo. Resolved ONCE — also gates the
|
|
1115
|
+
// writable-roots resolution below.
|
|
1116
|
+
const approvalMode = peerApprovalMode(cwd)
|
|
1117
|
+
// Extra sandbox-writable roots for a GATED CODEX peer (docs/17): under sandbox_mode=workspace-write
|
|
1118
|
+
// a write outside the peer cwd (a memory note into the shared vault) is BLOCKED and pops codex's
|
|
1119
|
+
// native modal, so the vault is granted at launch. Resolved from the host's memory provider ONLY when
|
|
1120
|
+
// it can matter (codex + gated) — a yolo/claude wake never reads it. codex's gated buildArgv consumes
|
|
1121
|
+
// it; every other path ignores the field. Fail-open (empty when no memory provider).
|
|
1122
|
+
const writableRoots = runtime === 'codex' && approvalMode === 'gated' ? resolveMemoryWritableRoots(env) : undefined
|
|
1123
|
+
|
|
1112
1124
|
// Hand the fully-resolved spec to the launch primitive (HOW). lifecycle has
|
|
1113
1125
|
// made every WHEN/HOW-MANY decision (lock, registry, H4, runtime, resume).
|
|
1114
1126
|
const spec: LaunchSpec = {
|
|
@@ -1124,9 +1136,8 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1124
1136
|
// Carry the peer's nature so the launch primitive can enforce an adapter's
|
|
1125
1137
|
// intelligence gate (telegram requires natural). From the registry record.
|
|
1126
1138
|
intelligence: peer.intelligence,
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
approvalMode: peerApprovalMode(cwd),
|
|
1139
|
+
approvalMode,
|
|
1140
|
+
...(writableRoots && writableRoots.length > 0 ? { writableRoots } : {}),
|
|
1130
1141
|
}
|
|
1131
1142
|
const launchCfg: LaunchConfig = {
|
|
1132
1143
|
claudeBin: cfg.claudeBin,
|
package/src/status/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { closeSync, openSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
16
|
-
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
16
|
+
import { resolveGlobalRoot, pluginInstallDir } from '../storage/index.ts'
|
|
17
17
|
import { daemonDiscoveryPath } from '../daemon/index.ts'
|
|
18
18
|
import { waitForDaemonHealthy } from '../update/index.ts'
|
|
19
19
|
import { iapeerBinPath } from '../install/index.ts'
|
|
@@ -112,6 +112,67 @@ export function heartbeatAgeSecs(provider: { heartbeat?: string }, nowMs: number
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// Sandbox writable roots for a gated codex peer (docs/17). A gated codex session
|
|
117
|
+
// launches under `sandbox_mode=workspace-write`: writes to its own cwd (+ /tmp,
|
|
118
|
+
// $TMPDIR) are silent, but a write OUTSIDE those — a memory note into the shared
|
|
119
|
+
// vault, which lives in iCloud OUTSIDE every peer cwd — is BLOCKED and pops codex's
|
|
120
|
+
// native approval modal. Routine memory writes must not prompt, so the vault is added
|
|
121
|
+
// to `sandbox_workspace_write.writable_roots` at launch. This resolves those extra
|
|
122
|
+
// writable root(s) for the host (empty when the host has no memory provider).
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** config.env key the memory provider records its vault path under. */
|
|
126
|
+
const MEMORY_VAULT_ENV_KEY = 'IAPEER_MEMORY_VAULT_PATH'
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extra sandbox-writable roots a gated codex peer needs beyond its cwd — the memory vault
|
|
130
|
+
* on a host that has a memory provider, else empty.
|
|
131
|
+
*
|
|
132
|
+
* INTERIM TECHDEBT (v1 — a durable, tracked debt, NOT a permanent design): the vault path is
|
|
133
|
+
* read from the provider's OPERATOR-OWNED `config.env` (`IAPEER_MEMORY_VAULT_PATH`), located via
|
|
134
|
+
* the provider name the memory SLOT declares. This deliberately BYPASSES the slot contract — the
|
|
135
|
+
* slot (`memory-provider.json`) is the ONLY declarative foundation↔provider boundary; `config.env`
|
|
136
|
+
* is the provider's PRIVATE surface. It is read-only and fail-open, but it IS a side-coupling that a
|
|
137
|
+
* future change to the slot contract must be aware of. Why the bypass: the slot is single-writer
|
|
138
|
+
* (the provider), so foundation cannot populate a vault field itself, and adding one is a cross-repo
|
|
139
|
+
* change to `@agfpd/iapeer-memory` + its release — a disproportionate cost that would block delivery
|
|
140
|
+
* on a foreign deploy. FOLLOW-UP (the way out of the debt): promote the data root INTO the slot (a
|
|
141
|
+
* `MemoryProvider.dataRoots` field the provider writes) so foundation reads it from the declared
|
|
142
|
+
* contract and this `config.env` read is retired.
|
|
143
|
+
*
|
|
144
|
+
* Fail-open at every step: no memory slot / no config.env / no key / a non-absolute value → `[]`. A
|
|
145
|
+
* gated codex peer still works with an empty result — only a cross-vault write would then prompt (a
|
|
146
|
+
* safe degraded state, never a break). Uses the injected `env` for path resolution (IAPEER_ROOT-aware
|
|
147
|
+
* → sandbox-isolated in tests, exactly like the rest of the storage layer).
|
|
148
|
+
*/
|
|
149
|
+
export function resolveMemoryWritableRoots(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
150
|
+
const slot = readMemoryProvider(env)
|
|
151
|
+
if (!slot) return [] // no memory provider on this host → no vault to grant
|
|
152
|
+
try {
|
|
153
|
+
const cfgPath = join(pluginInstallDir(slot.provider, { env }), 'config.env')
|
|
154
|
+
const text = readFileSync(cfgPath, 'utf8')
|
|
155
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
156
|
+
const line = rawLine.trim()
|
|
157
|
+
if (!line || line.startsWith('#')) continue
|
|
158
|
+
const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line)
|
|
159
|
+
if (!m || m[1] !== MEMORY_VAULT_ENV_KEY) continue
|
|
160
|
+
let value = m[2].trim()
|
|
161
|
+
if (
|
|
162
|
+
value.length >= 2 &&
|
|
163
|
+
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
|
|
164
|
+
) {
|
|
165
|
+
value = value.slice(1, -1)
|
|
166
|
+
}
|
|
167
|
+
// Absolute path only — a relative writable_root is meaningless to codex's sandbox.
|
|
168
|
+
return value.startsWith('/') ? [value] : []
|
|
169
|
+
}
|
|
170
|
+
return [] // key absent from config.env
|
|
171
|
+
} catch {
|
|
172
|
+
return [] // unreadable config.env — fail-open to bare
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
115
176
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
177
|
// VOICE slot — the voice-connect provider declaration (the same declarative
|
|
117
178
|
// contract as memory, two differences by design: NO provision/unprovision (voice
|
package/src/tray/render.ts
CHANGED
|
@@ -198,19 +198,17 @@ export function renderSwiftBar(snapshot: TraySnapshot, opts: RenderOptions): str
|
|
|
198
198
|
if (providers) lines.push(line(0, providers, { color: COLOR.meta }))
|
|
199
199
|
lines.push('---')
|
|
200
200
|
|
|
201
|
-
// ── peers
|
|
201
|
+
// ── peers — each peer is a SUBMENU: expand → Attach (first) + its actions. ONE list
|
|
202
|
+
// (no separate Manage section): the peer rows and their lifecycle used to be two
|
|
203
|
+
// parallel lists of every peer (a click-attach list + a Manage list) — a visible
|
|
204
|
+
// dupe. Folded here (owner decision — 2 clicks to attach: expand peer → Attach). ──
|
|
202
205
|
if (peers.length === 0) {
|
|
203
206
|
lines.push(line(0, 'no peers registered', { color: COLOR.meta }))
|
|
204
207
|
}
|
|
205
208
|
for (const p of peers) {
|
|
206
|
-
lines.push(
|
|
209
|
+
lines.push(...renderPeerSubmenu(p, bin, now, pendingByPeer.get(p.personality) ?? 0))
|
|
207
210
|
}
|
|
208
211
|
|
|
209
|
-
// ── Manage submenu (lifecycle commands per peer; kept off the rows so a row click
|
|
210
|
-
// attaches instead of just expanding a submenu) ──
|
|
211
|
-
lines.push('---')
|
|
212
|
-
lines.push(...renderManage(peers, bin))
|
|
213
|
-
|
|
214
212
|
// ── footer ──
|
|
215
213
|
// No explicit "Refresh" item: the plugin is streamable + SSE-driven, so the menu is
|
|
216
214
|
// always current on open — a manual refresh gives the user nothing and (being a
|
|
@@ -243,64 +241,52 @@ function attachable(p: TrayPeer): boolean {
|
|
|
243
241
|
return !p.launchd_managed
|
|
244
242
|
}
|
|
245
243
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
|
|
251
|
-
* deliberately AVOIDS SwiftBar's own `terminal=true`, whose Terminal.app launch drives a
|
|
252
|
-
* `Cmd-T` System-Events keystroke that silently no-ops without Accessibility permission.
|
|
253
|
-
* Infra (launchd) rows are plain status lines. Lifecycle lives in the Manage submenu.
|
|
254
|
-
*/
|
|
255
|
-
function renderPeerRow(p: TrayPeer, bin: string, now: number, pendingCount = 0): string {
|
|
256
|
-
const agg = aggregate(p)
|
|
244
|
+
/** The status label for a peer's submenu-parent row: `<peer> <rt><glyph> … <badges> <age>`.
|
|
245
|
+
* Badges: 👤 attached · 🔒 launchd · 🛡 gated approval (docs/17; yolo=fleet default=no badge) ·
|
|
246
|
+
* ⏳N ephemeral queue. A pending approval prepends ⚠ + a 🔴N count so the owner sees WHICH peer
|
|
247
|
+
* waits (the Allow/Deny itself lives in the top approvals section, not here). */
|
|
248
|
+
function peerLabel(p: TrayPeer, now: number, pendingCount: number): string {
|
|
257
249
|
const detail = p.runtimes.map(r => `${r.runtime}${GLYPH[r.status]}`).join(' ')
|
|
258
|
-
// approval mode (docs/17): the whole fleet defaults to `yolo`, so showing it on every row is
|
|
259
|
-
// noise — mark ONLY the exception, `gated` (this peer's blocking approvals go to a human), with a
|
|
260
|
-
// 🛡 shield. No badge ⇒ yolo (the default; also how a pre-approval snapshot with the field absent
|
|
261
|
-
// renders). So the row reads the mode: 🛡 = gated, bare = yolo.
|
|
262
250
|
const badges =
|
|
263
251
|
(p.attached ? ' 👤' : '') +
|
|
264
252
|
(p.launchd_managed ? ' 🔒' : '') +
|
|
265
253
|
(p.approval_mode === 'gated' ? ' 🛡' : '') +
|
|
266
254
|
(p.wake_policy === 'ephemeral' && (p.queue_depth ?? 0) > 0 ? ` ⏳${p.queue_depth}` : '')
|
|
267
255
|
const ageStr = p.last_active_ms ? ` ${fmtAge(p.last_active_ms, now)}` : ''
|
|
268
|
-
// HIGHLIGHT a peer with a pending approval: a ⚠ prefix + a red count badge + the row painted red
|
|
269
|
-
// so the owner sees WHICH peer is waiting (owner criterion — the static-menu equivalent of a pulse;
|
|
270
|
-
// SwiftBar cannot animate). The highlight wins over the live/meta color.
|
|
271
256
|
const prefix = pendingCount > 0 ? '⚠ ' : ''
|
|
272
257
|
const pendingBadge = pendingCount > 0 ? ` 🔴${pendingCount}` : ''
|
|
273
|
-
|
|
274
|
-
const rowColor = pendingCount > 0 ? COLOR.down : agg === 'live' ? COLOR.live : COLOR.meta
|
|
275
|
-
if (!attachable(p)) return line(0, label, { color: rowColor })
|
|
276
|
-
return line(0, label, { color: rowColor, bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false })
|
|
258
|
+
return `${prefix}${p.personality} ${detail}${badges}${pendingBadge}${ageStr}`
|
|
277
259
|
}
|
|
278
260
|
|
|
279
|
-
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
261
|
+
/**
|
|
262
|
+
* One peer as a SUBMENU (single unified list — no separate Manage). The parent row is the
|
|
263
|
+
* status label (no direct action — clicking EXPANDS); the children are, in order:
|
|
264
|
+
* 1. **Attach** (agentic peers only) — the terminal handoff, unchanged (0.4.67): `iapeer tray
|
|
265
|
+
* attach-term <peer>` in the BACKGROUND (terminal=false) opens a `.command` via `open`, needing
|
|
266
|
+
* NO Accessibility/Automation TCC (avoids SwiftBar's `terminal=true` Cmd-T keystroke). This is
|
|
267
|
+
* the ONE trade Arthur accepted: attach is now expand-then-Attach (2 clicks) for a dupe-free list.
|
|
268
|
+
* 2. lifecycle commands (Stop/Start/New/Interrupt/Refresh/Compact) — `iapeer tray cmd <c> <peer>`.
|
|
269
|
+
* 3. approval-mode toggle (agentic peers only — docs/17).
|
|
270
|
+
*
|
|
271
|
+
* Launchd-managed peers (arthur/timer/watcher, 🔒): NO Attach (no pty session) and NO approval toggle
|
|
272
|
+
* (always yolo); they keep the lifecycle submenu (service Stop/Start etc — the SAME set the old Manage
|
|
273
|
+
* already offered them, just relocated under the peer). Per-infra-type trimming (e.g. dropping
|
|
274
|
+
* Interrupt/Compact for the human peer) is a separate call, out of scope for a de-dupe render change.
|
|
275
|
+
*/
|
|
276
|
+
function renderPeerSubmenu(p: TrayPeer, bin: string, now: number, pendingCount = 0): string[] {
|
|
277
|
+
const rowColor = pendingCount > 0 ? COLOR.down : aggregate(p) === 'live' ? COLOR.live : COLOR.meta
|
|
278
|
+
const out: string[] = [line(0, peerLabel(p, now, pendingCount), { color: rowColor })]
|
|
279
|
+
// 1) Attach FIRST (agentic only — launchd infra/human peers have no pty session to attach)
|
|
280
|
+
if (attachable(p)) {
|
|
281
|
+
out.push(line(1, 'Attach', { bash: bin, param1: 'tray', param2: 'attach-term', param3: p.personality, terminal: false }))
|
|
282
|
+
}
|
|
283
|
+
// 2) lifecycle commands — each `iapeer tray cmd <c> <peer>` → POST /fleet/v1/peers/<peer>/<c>.
|
|
284
|
+
// No refresh=true: the SSE stream reflects the outcome (streamable is always fresh).
|
|
285
|
+
for (const c of MANAGE_COMMANDS) {
|
|
286
|
+
out.push(line(1, c.label, { bash: bin, param1: 'tray', param2: 'cmd', param3: c.cmd, param4: p.personality, terminal: false }))
|
|
303
287
|
}
|
|
288
|
+
// 3) approval-mode toggle (agentic only)
|
|
289
|
+
if (attachable(p)) out.push(...renderApprovalToggle(p, bin, 1))
|
|
304
290
|
return out
|
|
305
291
|
}
|
|
306
292
|
|
|
@@ -325,7 +311,7 @@ function renderManage(peers: TrayPeer[], bin: string): string[] {
|
|
|
325
311
|
* stray click. SwiftBar has no native confirm dialog — the nested explicit item IS the
|
|
326
312
|
* confirm.
|
|
327
313
|
*/
|
|
328
|
-
function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
|
|
314
|
+
function renderApprovalToggle(p: TrayPeer, bin: string, depth: number): string[] {
|
|
329
315
|
const flip = (target: 'gated' | 'yolo'): Record<string, string | boolean> => ({
|
|
330
316
|
bash: bin,
|
|
331
317
|
param1: 'approval-mode',
|
|
@@ -337,12 +323,12 @@ function renderApprovalToggle(p: TrayPeer, bin: string): string[] {
|
|
|
337
323
|
if (p.approval_mode === 'gated') {
|
|
338
324
|
// WEAKEN — current on the parent (no action); the flip is an explicit red confirm child.
|
|
339
325
|
return [
|
|
340
|
-
line(
|
|
341
|
-
line(
|
|
326
|
+
line(depth, 'Approval: gated 🛡', { color: COLOR.warn }),
|
|
327
|
+
line(depth + 1, '⚠ Switch to yolo — removes human approval (next session)', { color: COLOR.down, ...flip('yolo') }),
|
|
342
328
|
]
|
|
343
329
|
}
|
|
344
330
|
// STRENGTHEN (yolo → gated) — safe, one tap.
|
|
345
|
-
return [line(
|
|
331
|
+
return [line(depth, 'Approval: yolo → gated 🛡 (next session)', flip('gated'))]
|
|
346
332
|
}
|
|
347
333
|
|
|
348
334
|
// ── approval renderers (docs/17 — the always-on approval channel in the bar) ─────
|