@agfpd/iapeer 0.4.68 → 0.4.70
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/15-fleet-api.md +10 -2
- package/docs/16-tray.md +18 -4
- package/docs/17-approval.md +128 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +7 -1
- package/docs/ru/16-tray.md +14 -2
- package/docs/ru/17-approval.md +128 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/approval/brokerClient.ts +92 -0
- package/src/approval/cli.ts +118 -0
- package/src/approval/hook.ts +193 -0
- package/src/approval/install.ts +200 -0
- package/src/cli/index.ts +66 -2
- package/src/daemon/approvals.ts +202 -0
- package/src/daemon/approvalslog.ts +54 -0
- package/src/daemon/fleet.ts +116 -1
- package/src/identity/index.ts +28 -0
- package/src/identity/profileStandard.ts +3 -0
- package/src/launch/adapters/claude.ts +83 -16
- package/src/launch/adapters/codex.ts +14 -3
- package/src/launch/index.ts +1 -1
- package/src/launch/ptyHost.ts +3 -3
- package/src/launch/types.ts +27 -8
- package/src/lifecycle/index.ts +16 -1
- package/src/supervisor/approvalRoute.ts +76 -0
- package/src/supervisor/boot.ts +13 -3
- package/src/supervisor/daemon.ts +62 -6
- package/src/tray/index.ts +25 -0
- package/src/tray/render.ts +107 -6
package/docs/15-fleet-api.md
CHANGED
|
@@ -55,8 +55,14 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
55
55
|
"last_active_ms": 1751731100000,
|
|
56
56
|
"attached": true,
|
|
57
57
|
"launchd_managed": false,
|
|
58
|
-
"wake_policy": "warm"
|
|
58
|
+
"wake_policy": "warm",
|
|
59
|
+
"approval_mode": "yolo"
|
|
59
60
|
}
|
|
61
|
+
],
|
|
62
|
+
"approvals": [
|
|
63
|
+
{ "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
|
|
64
|
+
"tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
|
|
65
|
+
"content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
|
|
60
66
|
]
|
|
61
67
|
}
|
|
62
68
|
```
|
|
@@ -68,11 +74,13 @@ Field notes:
|
|
|
68
74
|
- `attached` — any runtime attached (peer-level convenience).
|
|
69
75
|
- `launchd_managed` — launchd owns this peer's lifecycle (H4): the daemon never wakes/reaps it, and wake/stop/start commands are guarded accordingly.
|
|
70
76
|
- `wake_policy` — `warm` (default: wake-on-message, idle-reap, resume) or `ephemeral` (stateless worker: serial task queue, die-after-reply).
|
|
77
|
+
- `approval_mode` — the peer's human-approval mode (docs/17-approval): `yolo` (default — launched with the runtime bypass flag, the supervisor auto-confirms circuit-breakers) or `gated` (launched without bypass; the runtime's blocking approval requests are routed to a human through the daemon approval broker). Additive field; a pre-approval daemon omits it — clients MUST treat its absence as `yolo`.
|
|
71
78
|
- `queue_depth` — ephemeral peers only: pending tasks across the peer's serial queues.
|
|
72
79
|
- `host.memory` / `host.voice` — the provider slots (`null` = empty slot); `heartbeatAgeSecs: null` with a non-null slot = provider declared but its daemon isn't heartbeating.
|
|
73
80
|
- `host.fda` — Full Disk Access of the binary: `true` / `false` / `null` (undeterminable).
|
|
74
81
|
- The daemon answering **is** the health signal: there is no `healthy` boolean. A dashboard that cannot reach any advertised address should render the daemon as down (this is also why a purely API-fed dashboard is insufficient when the daemon itself is broken — the built-in TUI keeps its direct in-process read path for exactly that case).
|
|
75
82
|
- The peer's **LLM model is deliberately absent**: the effective model of a live session is not an observable registry fact (static launch pins exist, but reporting a pin as «the model» would lie under runtime auto-switching). If it ever becomes observable, it will appear as an additive field.
|
|
83
|
+
- `approvals` (top-level, docs/17-approval) — the pending human-approval queue, the SAME items `GET /fleet/v1/approvals` returns, carried in the snapshot so a client that already re-fetches `/snapshot` on every event renders the queue with no extra call. **ADDITIVE + OMITTED when the queue is empty** — a client MUST treat its absence as an empty queue (the same rule as `approval_mode`). Each item: `id` (broker id, the target of `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (whose action), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (the tool / breaker name), `summary` (a one-line badge string), `content` (the FULL verbatim action — command / diff / plan; criterion #7), `createdMs` / `expiresMs`. The broker's item is a superset — unknown extra fields (`title`, `approvers`) MUST be ignored (obligation 2).
|
|
76
84
|
|
|
77
85
|
## GET /fleet/v1/peers/<personality>
|
|
78
86
|
|
|
@@ -132,7 +140,7 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
|
|
|
132
140
|
|
|
133
141
|
`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`.
|
|
134
142
|
|
|
135
|
-
Not part of the API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them)
|
|
143
|
+
Not part of the *command* API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them). The **human-approval broker** now lives under `/fleet/v1/approvals*` (`GET` the queue / one item, `POST /<id>/(approve|deny)`) with the `approval-request` / `approval-resolved` SSE `ev` kinds and the `approvals` snapshot field above — the full contract is **docs/17-approval**.
|
|
136
144
|
|
|
137
145
|
Errors are always `{"error": "…"}` with an honest status: `400` malformed request, `401` bearer required, `404` unknown peer/endpoint, `405` wrong method, `502` the verb itself failed, `500` internal.
|
|
138
146
|
|
package/docs/16-tray.md
CHANGED
|
@@ -16,10 +16,13 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
16
16
|
|
|
17
17
|
## What you see
|
|
18
18
|
|
|
19
|
-
- **Menu bar:** an antenna glyph + the count of peers with a live session. When
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
- **Menu bar:** an antenna glyph + the count of peers with a live session. When one or more
|
|
20
|
+
**human-approval** requests are pending, the menu bar instead shows a **red `N.circle.fill`
|
|
21
|
+
badge** (the iOS-notification look — a red circle with the count) so the owner sees an
|
|
22
|
+
awaiting request at a glance. When no advertised daemon address answers, a red warning
|
|
23
|
+
triangle instead (the daemon is down — a purely API-fed dashboard cannot show a broken
|
|
24
|
+
daemon any other way; the built-in TUI keeps its direct read path for that case, see
|
|
25
|
+
[15](15-fleet-api.md)).
|
|
23
26
|
- **Dropdown:** a host header (`iapeer <version> · up <uptime>`) and the memory/voice
|
|
24
27
|
provider heartbeats, then one row per peer:
|
|
25
28
|
- `<peer> <runtime><glyph> … <age>` with the same glyph semantics as the CLI —
|
|
@@ -36,6 +39,16 @@ activation that also installs SwiftBar.app when it is absent.
|
|
|
36
39
|
peers (telegram / notifier, `🔒`) are not pty-attachable** — their rows are plain status
|
|
37
40
|
lines. Lifecycle actions (Wake / Stop / Start / New / Interrupt / Refresh / Compact) live
|
|
38
41
|
in the **Manage** submenu, each a `POST /fleet/v1/peers/<peer>/<cmd>` via `iapeer tray cmd`.
|
|
42
|
+
- **Pending approvals (docs/17):** when the queue is non-empty, the **top of the dropdown** shows
|
|
43
|
+
each pending request **expanded, with no extra clicks** — a header (`<peer> · <tool>`), the
|
|
44
|
+
**verbatim action content** (the command / diff / plan, monospace, capped for the menu; the FULL
|
|
45
|
+
content stays in `iapeer approvals`), then **Allow** / **Deny** items directly below. Clicking runs
|
|
46
|
+
`iapeer tray approve|deny <id>` in the background → the broker resolves → the stream re-renders (the
|
|
47
|
+
badge clears). The peer with a pending request is **highlighted in the fleet list** (a `⚠` prefix, a
|
|
48
|
+
red `🔴N` count, the row painted red) so the owner sees *which* peer is waiting. This channel is
|
|
49
|
+
**always on** — the approval queue is reflected whenever it is non-empty, independent of everything
|
|
50
|
+
else; an empty queue shows no section and no badge. (A true *pulse* animation is not possible in a
|
|
51
|
+
static SwiftBar menu — the highlight + badge is its equivalent.)
|
|
39
52
|
|
|
40
53
|
The list updates itself: the plugin is **streamable** — it subscribes to the event stream
|
|
41
54
|
and re-fetches the snapshot on every change (a peer waking, an operator stop/start, a
|
|
@@ -54,6 +67,7 @@ whole dashboard twice.
|
|
|
54
67
|
| `iapeer tray uninstall` | Remove the plugin file and refresh SwiftBar. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
|
|
55
68
|
| `iapeer tray render [--stream]` | Print the SwiftBar plugin output. `--stream` is the streamable loop the plugin runs; the one-shot form is the poll fallback and the test surface. |
|
|
56
69
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
|
|
70
|
+
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Resolve a pending human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` over the same unix-first fleet client. The menu's Allow/Deny items call these; the single-queue invariant means the resolution is seen by every channel (CLI, telegram). |
|
|
57
71
|
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present. Repairs nothing. |
|
|
58
72
|
|
|
59
73
|
## How it is wired
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# 17 — Human approval
|
|
2
|
+
|
|
3
|
+
A peer can run in one of two **approval modes**. In `yolo` (the default — every peer today) the agent acts autonomously: the runtime is launched with its bypass flag and nothing asks a human before a tool runs. In `gated` the peer is launched **without** bypass, and every blocking approval the runtime would raise is routed to a **human** through the daemon's approval broker — a Telegram button, a tray click, or `iapeer approve` on the CLI. The human's `allow`/`deny` (with a reason) flows back into the runtime and the tool proceeds or is refused.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
`gated` means **«ask before acting»**, not «sandboxed». It removes the bypass so blocking questions surface; it does **not** add an OS sandbox (a separate axis, not in v1). On codex, gated is explicitly `approvals ON + sandbox OFF` (see the toggle table).
|
|
8
|
+
|
|
9
|
+
## The approval-mode toggle
|
|
10
|
+
|
|
11
|
+
### Profile field
|
|
12
|
+
|
|
13
|
+
A foundation-owned lifecycle field in `peer-profile.json`, next to `wake_policy` / `initial_prompt`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{ "approval_mode": "gated" }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`"yolo" | "gated"`, **default `yolo`**. Only `gated` is persisted — writing `yolo` **removes** the field (so a `gated→yolo→gated` round-trip is byte-identical, and the whole fleet keeps behaving exactly as before this feature existed). The one place the «absent ⇒ yolo» default lives is `approvalModeOf(profile)`; every reader (launch, supervisor, fleet snapshot, CLI) resolves through it and never re-derives it. The mode appears in the fleet snapshot as the additive `approval_mode` field (docs/15) by reading the local profile — a pre-approval daemon omits it and clients MUST treat its absence as `yolo`.
|
|
20
|
+
|
|
21
|
+
### Launch surfaces the flip touches
|
|
22
|
+
|
|
23
|
+
The bypass is a **launch argument**, different per runtime, and the mode is a coordinated operation over **all** the surfaces that argument implies. iapeer owns the launch, so completeness is on the foundation.
|
|
24
|
+
|
|
25
|
+
**claude** (Option D — a matcher-free `PermissionRequest` hook; verified live 2.1.201):
|
|
26
|
+
|
|
27
|
+
| # | Surface | yolo | gated |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| C1 | argv `--dangerously-skip-permissions` | present | **removed** |
|
|
30
|
+
| C2 | argv permission-mode | (bypass) | `--permission-mode default` (explicit — never inherits an acceptEdits/bypass defaultMode) |
|
|
31
|
+
| C3 | ready-gate marker `isInputReady` | needs the `bypass permissions on` banner | banner is **absent** → the marker is mode-aware (composer `❯` + boot dialogs cleared), else the peer never becomes ready and the wake fails |
|
|
32
|
+
| C4 | boot-dialog «Bypass Permissions mode» accept | appears (first bypass) | not shown (no bypass) |
|
|
33
|
+
| C5 | `PermissionRequest` hook in `<cwd>/.claude/settings.json` | **absent** (0 overhead, byte-identical to today) | **installed** — matcher-free (see below), `command = iapeer approval-hook` |
|
|
34
|
+
| C6 | allow-rule for the peer's own MCP tool (`permissions.allow: ["mcp__iapeer__send_to_peer"]`) | not needed (bypass) | **needed** — else default-mode gates the peer's own IAP channel and it hangs on `send_to_peer` |
|
|
35
|
+
| C7 | supervisor circuit-breaker (dangerous-rm, above the permission layer) | auto-Yes + audit log | **routed to a human** through the broker |
|
|
36
|
+
| C8 | argv `--disallowedTools AskUserQuestion` | stays | stays in v1 (see Limitations) |
|
|
37
|
+
|
|
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
|
+
|
|
40
|
+
**codex** (`PreToolUse` hook — verified live, codex-cli 0.142.5):
|
|
41
|
+
|
|
42
|
+
| # | Surface | yolo | gated |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| X1 | argv `--dangerously-bypass-approvals-and-sandbox` | present | **removed** |
|
|
45
|
+
| X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — approvals **ON**, sandbox **OFF** (so loopback-MCP `send_to_peer` + writes to the memory vault outside cwd still work; `workspace-write` would cut the network and external paths). Session-scoped via `-c`, not host config. |
|
|
46
|
+
| X3 | `PreToolUse` hook `<cwd>/.codex/hooks.json` + trust pre-seed in `~/.codex/config.toml [hooks.state]` | **absent** | **installed + trusted** (`preSeedCodexHooksTrust`; an untrusted codex hook is silently skipped) |
|
|
47
|
+
| X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (both modes) | pre-trusted (matters more with approvals active) |
|
|
48
|
+
| X5 | ready-gate `isInputReady` (`›` composer) | mode-independent | unchanged |
|
|
49
|
+
|
|
50
|
+
The codex hook is matched by tool-name regex `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — the coarse class matcher lives on the runtime's own `hooks.json` (the user tunes it as ordinary runtime config).
|
|
51
|
+
|
|
52
|
+
### Idempotency and the application moment
|
|
53
|
+
|
|
54
|
+
- **argv surfaces** (C1–C2, C8, X1–X2) are computed from the mode on every launch — structurally idempotent, no accumulation.
|
|
55
|
+
- **settings.json / hooks.json** (C5–C6, X3) are add-on-gated / remove-on-yolo with a no-clobber merge, keyed on the stable `approval-hook` marker: our block is written deterministically and removed cleanly (an emptied `hooks.PermissionRequest` / `PreToolUse` array is dropped, an emptied `hooks` / `permissions` object is dropped, foreign hooks/rules untouched). A `gated→yolo→gated` round-trip restores the pre-install bytes.
|
|
56
|
+
- **codex trust** (X3–X4) reuses the already-idempotent `preSeedCodexHooksTrust` / `removeCodexHooksTrustUnder`.
|
|
57
|
+
|
|
58
|
+
All surfaces are read/applied **at session start**. A **live session keeps its launched mode** until the next fresh session — exactly like an agentic peer picking up new doctrine lazily on its next wake. The toggle (1) persists `approval_mode`, (2) idempotently brings the settings/hooks surfaces to the mode, (3) does **not** touch the live session. To apply immediately, start a fresh session (`iapeer approval-mode <peer> <mode> --now`, or `iapeer new`/`refresh`).
|
|
59
|
+
|
|
60
|
+
## Interception mechanism
|
|
61
|
+
|
|
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
|
+
|
|
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 `PreToolUse`** — same stdin shape; prints `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) also denies. Verified live (0.142.5): codex fires `PreToolUse` (not `PermissionRequest`) for tool calls with `tool_name:"Bash"` for shell; a `deny` hard-blocks the tool and the reason reaches the model even under `sandbox_mode=danger-full-access` — so under the gated config (danger-full-access + on-request → `permission_mode=bypassPermissions`) the hook is the SOLE gate.
|
|
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, under gated, POSTs it to the broker (below); under yolo it auto-presses YES with an audit line.
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
## Nomenclature v1 and content per action (criterion «what is being approved»)
|
|
71
|
+
|
|
72
|
+
In every channel a human sees the **concrete content** of the action, not just the tool name.
|
|
73
|
+
|
|
74
|
+
**claude** — `Bash` → full command (+ description); `Edit` → file + old/new; `Write` → file + content; `ExitPlanMode` → the plan text; `dangerous-rm` circuit-breaker → the full prompt + the rm command + target (pane-scraped, not structured).
|
|
75
|
+
|
|
76
|
+
**codex** — `Bash`/`exec` → the command; `apply_patch` → the patch/diff; escalations (shell/net/MCP) via `PermissionRequest` → tool + input + human-readable description.
|
|
77
|
+
|
|
78
|
+
An unknown tool falls back to a pretty-printed `tool_input`, so nothing is opaque.
|
|
79
|
+
|
|
80
|
+
**Limitations (not hidden):**
|
|
81
|
+
|
|
82
|
+
1. **claude `AskUserQuestion` is not hookable** — no permission-check, not a `PreToolUse` event. It stays suppressed by `--disallowedTools AskUserQuestion` in v1 (a deliberate owner decision, not a gap to close; re-enabling is only on the owner's explicit request).
|
|
83
|
+
2. **codex MCP-elicitation** delivers content only on screen, not via a structured hook — rare, not covered in v1.
|
|
84
|
+
3. **codex has no plan-mode approval** — the «plan» class exists only on claude (an asymmetry, not a missing feature).
|
|
85
|
+
|
|
86
|
+
## The broker (single source of truth)
|
|
87
|
+
|
|
88
|
+
The approval queue lives **in the daemon** (the always-on process). Every channel that asks (the runtime hook, the supervisor breaker) and every channel that answers (CLI, tray, the Telegram runtime) is an interface to this one queue: a decision from any face resolves the request everywhere. In-memory + ephemeral — pending requests do **not** survive a daemon restart (the blocking hook connection breaks → the hook fails safe to deny); the durable `approvals.log` is the audit trace, not a recovery store.
|
|
89
|
+
|
|
90
|
+
Flow: the gated peer's hook (or the supervisor breaker) blocks on `POST /fleet/v1/approvals` → the broker enqueues, emits `approval-request` (to `approvals.log` → SSE), and holds the connection → a face answers via `POST /fleet/v1/approvals/<id>/(approve|deny)` → the promise resolves, the daemon writes the runtime hook JSON back, the tool proceeds or is blocked with the reason.
|
|
91
|
+
|
|
92
|
+
**Fail-safety — every failure direction is `deny`:** broker unreachable, daemon restarted mid-wait (connection breaks), requester disconnect, unknown id, and the per-request timeout all resolve to deny. This is the engineering basis for removing the bypass under gated: with no bypass, «no decision» degrades to the runtime's own permission prompt (a blocked TUI), **not** an auto-run — so a supervision fault is safe, never permissive. (Under a *retained* bypass a hook timeout would auto-run — exactly the harm gated exists to prevent.)
|
|
93
|
+
|
|
94
|
+
**Timeouts (ordered so the broker's default-deny always wins):** the broker default-deny is **300 s** (`IAPEER_APPROVAL_TIMEOUT_MS`); the hook client's fetch ceiling is **600 s**; the installed runtime hook `timeout` is **900 s**. So the broker answers (or default-denies) first, the client aborts second, and the runtime only kills the hook last. A timeout is delivered to the model as `deny` with `reason="approval timed out (default-deny)"`.
|
|
95
|
+
|
|
96
|
+
## Fleet API
|
|
97
|
+
|
|
98
|
+
The approval surface is served on both daemon listeners under `/fleet/v1`, same auth and client obligations as the rest of the Fleet API (docs/15). It adds a fourth durable log, `approvals.log`, to the SSE tail — clients MUST ignore unknown `ev` kinds (obligation 1), which is exactly how `approval-request` / `approval-resolved` were reserved.
|
|
99
|
+
|
|
100
|
+
**Events** (compact logfmt line → SSE JSON; the full content is *not* in the event — read the queue for it):
|
|
101
|
+
|
|
102
|
+
- `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
|
|
103
|
+
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
|
|
104
|
+
|
|
105
|
+
**Endpoints:**
|
|
106
|
+
|
|
107
|
+
| Method + path | Purpose |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `GET /fleet/v1/approvals` | list pending requests (full items) |
|
|
110
|
+
| `GET /fleet/v1/approvals/<id>` | one pending request, full `content` (the multi-line diff / plan / command) |
|
|
111
|
+
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
|
|
112
|
+
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (reason to the model) |
|
|
113
|
+
| `POST /fleet/v1/approvals` | the **blocking long-poll** the asking hook/breaker holds: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
|
|
114
|
+
|
|
115
|
+
`kind` is the taxonomy tag (`tool` | `plan` | `question` | `circuit-breaker`; free-form-tolerant). So the SSE/badge stays light while the full content is available in every channel through the two `GET`s.
|
|
116
|
+
|
|
117
|
+
## CLI
|
|
118
|
+
|
|
119
|
+
- `iapeer approvals [--json]` — the pending queue: personality · runtime · kind · **the verbatim command/content** · age · id.
|
|
120
|
+
- `iapeer approve <id> [--approver <peer>]` — allow.
|
|
121
|
+
- `iapeer deny <id> [reason] [--approver <peer>]` — deny with a reason (delivered to the model).
|
|
122
|
+
- `iapeer approval-mode <peer> [gated|yolo] [--now]` — read the current mode (omit the mode), or flip it: persists the field, idempotently brings the runtime surfaces to the mode, and reports the application moment. `--now` also starts a fresh session so the mode takes effect immediately.
|
|
123
|
+
|
|
124
|
+
`approve` / `deny` / `approvals` reach the in-daemon broker over the Fleet API (like the tray), not in-process — the queue lives in the live daemon.
|
|
125
|
+
|
|
126
|
+
## Compatibility
|
|
127
|
+
|
|
128
|
+
Additive within `/fleet/v1` (docs/15): the `approval-*` endpoints and `ev` kinds grow the surface without a version bump. A pre-approval daemon simply omits `approval_mode` from the snapshot and serves no `/approvals` endpoints; a client feature-detects by the presence of the field / a `200` from `GET /fleet/v1/approvals`.
|
package/docs/README.md
CHANGED
|
@@ -78,6 +78,7 @@ The sections are layered: from "install and use" to "understand the architecture
|
|
|
78
78
|
- [14 — Configuration and logs](14-configuration-and-logs.md) — environment variables, logs, diagnostics
|
|
79
79
|
- [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
|
|
80
80
|
- [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
|
|
81
|
+
- [17 — Human approval](17-approval.md) — per-peer gated/yolo mode: routing a runtime's blocking approvals to a human through the daemon broker (hook interception, the broker, Fleet API + CLI)
|
|
81
82
|
|
|
82
83
|
## Status
|
|
83
84
|
|
package/docs/ru/15-fleet-api.md
CHANGED
|
@@ -57,6 +57,11 @@ HTTP-поверхность демона для **операторских кл
|
|
|
57
57
|
"launchd_managed": false,
|
|
58
58
|
"wake_policy": "warm"
|
|
59
59
|
}
|
|
60
|
+
],
|
|
61
|
+
"approvals": [
|
|
62
|
+
{ "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
|
|
63
|
+
"tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
|
|
64
|
+
"content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
|
|
60
65
|
]
|
|
61
66
|
}
|
|
62
67
|
```
|
|
@@ -73,6 +78,7 @@ HTTP-поверхность демона для **операторских кл
|
|
|
73
78
|
- `host.fda` — Full Disk Access бинаря: `true` / `false` / `null` (неопределимо).
|
|
74
79
|
- Сам факт ответа демона **и есть** сигнал здоровья: булева `healthy` нет. Дашборд, не достучавшийся ни до одного объявленного адреса, обязан рисовать демона лежачим (поэтому же дашборд, питающийся только API, недостаточен, когда сломан сам демон — встроенный TUI сохраняет прямой in-process путь чтения ровно на этот случай).
|
|
75
80
|
- **LLM-модель пира отсутствует намеренно**: эффективная модель живой сессии — не наблюдаемый факт реестра (статические пины запуска существуют, но выдавать пин за «модель» — врать при рантаймном автопереключении). Станет наблюдаемой — появится аддитивным полем.
|
|
81
|
+
- `approvals` (top-level, docs/17-approval) — очередь ожидающих human-approval, ТЕ ЖЕ item'ы, что отдаёт `GET /fleet/v1/approvals`, в снапшоте — чтобы клиент, уже перефетчащий `/snapshot` на каждое событие, рендерил очередь без доп. вызова. **АДДИТИВНО + ОПУСКАЕТСЯ при пустой очереди** — клиент ОБЯЗАН трактовать отсутствие как пустую очередь (то же правило, что `approval_mode`). Поля item: `id` (broker-id, цель `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (чьё действие), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (имя инструмента / breaker'а), `summary` (однострочник для бейджа), `content` (ПОЛНОЕ дословное действие — команда / диф / план; крит №7), `createdMs` / `expiresMs`. Item брокера — суперсет: неизвестные лишние поля (`title`, `approvers`) ОБЯЗАНЫ игнорироваться (обязательство 2).
|
|
76
82
|
|
|
77
83
|
## GET /fleet/v1/peers/<personality>
|
|
78
84
|
|
|
@@ -132,7 +138,7 @@ GET /fleet/v1/events?replay=50
|
|
|
132
138
|
|
|
133
139
|
`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`.
|
|
134
140
|
|
|
135
|
-
Не входит в API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью)
|
|
141
|
+
Не входит в *командный* API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью). **Брокер human-approval** теперь живёт под `/fleet/v1/approvals*` (`GET` очередь / один item, `POST /<id>/(approve|deny)`) с видами SSE `approval-request` / `approval-resolved` и полем `approvals` в снапшоте выше — полный контракт в **docs/17-approval**.
|
|
136
142
|
|
|
137
143
|
Ошибки — всегда `{"error": "…"}` с честным статусом: `400` некорректный запрос, `401` требуется bearer, `404` неизвестный пир/эндпоинт, `405` не тот метод, `502` сам verb упал, `500` внутренняя.
|
|
138
144
|
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -15,8 +15,10 @@ SwiftBar.app, если его нет.
|
|
|
15
15
|
|
|
16
16
|
## Что видно
|
|
17
17
|
|
|
18
|
-
- **Меню-бар:** глиф-антенна + число пиров с живой сессией.
|
|
19
|
-
|
|
18
|
+
- **Меню-бар:** глиф-антенна + число пиров с живой сессией. Когда есть ожидающие запросы
|
|
19
|
+
**human-approval**, меню-бар вместо этого показывает **красный бейдж `N.circle.fill`** (iOS-look —
|
|
20
|
+
красный круг с числом), чтобы владелец сразу видел ожидающий запрос. Если ни один объявленный
|
|
21
|
+
адрес демона не отвечает — красный треугольник (демон недоступен; чисто API-дашборд иначе не
|
|
20
22
|
покажет сломанный демон — встроенный TUI держит прямой путь чтения для этого случая, см.
|
|
21
23
|
[15](15-fleet-api.md)).
|
|
22
24
|
- **Выпадашка:** шапка хоста (`iapeer <версия> · up <аптайм>`) и хартбиты провайдеров памяти
|
|
@@ -34,6 +36,15 @@ SwiftBar.app, если его нет.
|
|
|
34
36
|
(telegram / notifier, `🔒`) не pty-аттачатся** — их строки просто статус. Lifecycle-действия
|
|
35
37
|
(Wake / Stop / Start / New / Interrupt / Refresh / Compact) — в подменю **Manage**, каждое —
|
|
36
38
|
`POST /fleet/v1/peers/<peer>/<cmd>` через `iapeer tray cmd`.
|
|
39
|
+
- **Ожидающие подтверждения (docs/17):** когда очередь непуста, **вверху выпадашки** каждый
|
|
40
|
+
ожидающий запрос показан **раскрытым, без доп. кликов** — заголовок (`<peer> · <tool>`),
|
|
41
|
+
**дословное содержимое действия** (команда / диф / план, моноширинно, усечено для меню; ПОЛНОЕ
|
|
42
|
+
содержимое — в `iapeer approvals`), затем пункты **Allow** / **Deny** прямо под ним. Клик запускает
|
|
43
|
+
`iapeer tray approve|deny <id>` в фоне → брокер резолвит → стрим ре-рендерит (бейдж гаснет). Пир с
|
|
44
|
+
ожидающим запросом **выделен в списке флота** (префикс `⚠`, красный счётчик `🔴N`, строка красная),
|
|
45
|
+
чтобы владелец видел, *какой* пир ждёт. Канал **always-on** — очередь отражается всегда, когда
|
|
46
|
+
непуста, независимо от прочего; пустая очередь — ни секции, ни бейджа. (Настоящая *пульс*-анимация в
|
|
47
|
+
статичном SwiftBar-меню невозможна — highlight + бейдж это её эквивалент.)
|
|
37
48
|
|
|
38
49
|
Список обновляется сам: плагин **streamable** — подписан на поток событий и перечитывает
|
|
39
50
|
снапшот на каждое изменение (пробуждение пира, стоп/старт оператором, смерть), склеивая
|
|
@@ -51,6 +62,7 @@ SwiftBar.app, если его нет.
|
|
|
51
62
|
| `iapeer tray uninstall` | Удалит файл плагина и обновит SwiftBar. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
|
|
52
63
|
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
|
|
53
64
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
|
|
65
|
+
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
|
|
54
66
|
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина. Ничего не чинит. |
|
|
55
67
|
|
|
56
68
|
## Как устроено
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# 17 — Подтверждение человеком
|
|
2
|
+
|
|
3
|
+
Пир работает в одном из двух **режимов подтверждения**. В `yolo` (режим по умолчанию — сегодня так у всего флота) агент действует автономно: рантайм запускается со своим bypass-флагом, и ничто не спрашивает человека перед выполнением инструмента. В `gated` пир запускается **без** bypass, и каждый блокирующий вопрос-подтверждение, который поднял бы рантайм, уходит **человеку** через брокер подтверждений в демоне — кнопкой в Telegram, кликом в трее или командой `iapeer approve`. Решение человека `allow`/`deny` (с причиной) возвращается в рантайм, и инструмент либо исполняется, либо отклоняется.
|
|
4
|
+
|
|
5
|
+
Это **нормативный контракт**. Брокер подтверждений живёт в демоне; fleet-эндпоинты и типы событий `ev` ниже — это поверхность, под которую пишутся операторские клиенты (CLI, трей, telegram-рантайм), а не исходники демона. Факты перехвата (какое событие рантайма фаярит, в какой форме) закреплены за **Claude Code 2.1.201** и **codex-cli 0.142.5** — сняты с живых бинарей и актуальных доков рантаймов.
|
|
6
|
+
|
|
7
|
+
`gated` значит **«спрашивать перед действием»**, а не «в песочнице». Он убирает bypass, чтобы блокирующие вопросы всплывали; он **не** добавляет OS-песочницу (отдельная ось, не в v1). На codex gated явно есть `аппрувы ВКЛ + песочница ВЫКЛ` (см. таблицу тумблера).
|
|
8
|
+
|
|
9
|
+
## Тумблер режима
|
|
10
|
+
|
|
11
|
+
### Поле профиля
|
|
12
|
+
|
|
13
|
+
Foundation-owned lifecycle-поле в `peer-profile.json`, рядом с `wake_policy` / `initial_prompt`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{ "approval_mode": "gated" }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`"yolo" | "gated"`, **дефолт `yolo`**. Персистится только `gated` — запись `yolo` **удаляет** поле (поэтому круг `gated→yolo→gated` байт-в-байт, а весь флот остаётся ровно таким, каким был до появления этой фичи). Единственное место, где живёт дефолт «отсутствует ⇒ yolo», — `approvalModeOf(profile)`; каждый читатель (запуск, супервизор, fleet-снапшот, CLI) резолвит через него и никогда не переопределяет. В fleet-снапшот режим попадает аддитивным полем `approval_mode` (docs/15) чтением локального профиля — pre-approval-демон его опускает, и клиенты ОБЯЗАНЫ трактовать отсутствие как `yolo`.
|
|
20
|
+
|
|
21
|
+
### Поверхности запуска, которых касается флип
|
|
22
|
+
|
|
23
|
+
Bypass — это **аргумент запуска**, у каждого рантайма свой, и режим есть согласованная операция над **всеми** поверхностями, которые этот аргумент подразумевает. iapeer владеет запуском, поэтому полнота списка — за фундаментом.
|
|
24
|
+
|
|
25
|
+
**claude** (вариант D — matcher-free хук `PermissionRequest`; проверено живьём 2.1.201):
|
|
26
|
+
|
|
27
|
+
| # | Поверхность | yolo | gated |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| C1 | argv `--dangerously-skip-permissions` | присутствует | **убран** |
|
|
30
|
+
| C2 | argv permission-mode | (bypass) | `--permission-mode default` (явно — не наследует acceptEdits/bypass defaultMode) |
|
|
31
|
+
| C3 | ready-gate маркер `isInputReady` | нужен баннер `bypass permissions on` | баннера **НЕТ** → маркер mode-aware (composer `❯` + сняты boot-диалоги), иначе пир никогда не станет ready и wake проваливается |
|
|
32
|
+
| C4 | boot-диалог «Bypass Permissions mode» accept | появляется (первый bypass) | не появляется (нет bypass) |
|
|
33
|
+
| C5 | хук `PermissionRequest` в `<cwd>/.claude/settings.json` | **отсутствует** (0 оверхеда, байт-в-байт как сегодня) | **установлен** — matcher-free (см. ниже), `command = iapeer approval-hook` |
|
|
34
|
+
| C6 | allow-rule собственного MCP-инструмента пира (`permissions.allow: ["mcp__iapeer__send_to_peer"]`) | не нужен (bypass) | **нужен** — иначе default-mode гейтит сам IAP-канал пира, и он виснет на `send_to_peer` |
|
|
35
|
+
| C7 | супервизор: circuit-breaker (dangerous-rm, выше permission-слоя) | авто-Yes + аудит-лог | **маршрут человеку** через брокер |
|
|
36
|
+
| C8 | argv `--disallowedTools AskUserQuestion` | остаётся | остаётся в v1 (см. Лимитации) |
|
|
37
|
+
|
|
38
|
+
**Почему matcher-free `PermissionRequest`, а не `PreToolUse`.** `PermissionRequest` фаярит **только когда permission-конфиг рантайма решил спросить** — вызов, не покрытый allow/deny-правилом. Значит политика *что спрашивать* остаётся на 100% у рантайма: юзер настраивает её обычными permission-правилами (инструмент в `permissions.allow` не спрашивается никогда → хук не фаярит → авто-allow). Нет матчера классов, который дрейфует, нет проскакивающего нового инструмента, нет зависания. iapeer лишь сеет хук + одно allow-правило для собственного MCP-инструмента пира.
|
|
39
|
+
|
|
40
|
+
**codex** (хук `PreToolUse` — проверено живьём, codex-cli 0.142.5):
|
|
41
|
+
|
|
42
|
+
| # | Поверхность | yolo | gated |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| X1 | argv `--dangerously-bypass-approvals-and-sandbox` | присутствует | **убран** |
|
|
45
|
+
| X2 | argv approval/sandbox | (bypass) | `-c approval_policy=on-request -c sandbox_mode=danger-full-access` — аппрувы **ВКЛ**, песочница **ВЫКЛ** (чтобы loopback-MCP `send_to_peer` и запись в vault-память вне cwd работали; `workspace-write` отрезал бы сеть и внешние пути). Session-scoped через `-c`, не хост-конфиг. |
|
|
46
|
+
| X3 | хук `PreToolUse` `<cwd>/.codex/hooks.json` + trust-preseed в `~/.codex/config.toml [hooks.state]` | **отсутствует** | **установлен + доверен** (`preSeedCodexHooksTrust`; недоверенный codex-хук молча пропускается) |
|
|
47
|
+
| X4 | codex cwd-trust `[projects."<cwd>"] trust_level="trusted"` | pre-trusted (обе моды) | pre-trusted (при активных аппрувах важнее) |
|
|
48
|
+
| X5 | ready-gate `isInputReady` (composer `›`) | mode-независим | без изменений |
|
|
49
|
+
|
|
50
|
+
codex-хук матчится по регекспу имён инструментов `^(Bash|Shell|shell|local_shell|exec|apply_patch|ApplyPatch)$` — грубый матчер классов живёт на собственном `hooks.json` рантайма (юзер правит как обычный рантайм-конфиг).
|
|
51
|
+
|
|
52
|
+
### Идемпотентность и момент применения
|
|
53
|
+
|
|
54
|
+
- **argv-поверхности** (C1–C2, C8, X1–X2) вычисляются из моды на каждом запуске — идемпотентны структурно, накопления нет.
|
|
55
|
+
- **settings.json / hooks.json** (C5–C6, X3) — add при gated / remove при yolo, no-clobber merge, ключ по стабильному маркеру `approval-hook`: наш блок дописывается детерминированно и удаляется дочиста (опустевший массив `hooks.PermissionRequest` / `PreToolUse` снимается, опустевший объект `hooks` / `permissions` снимается, чужие хуки/правила не трогаются). Круг `gated→yolo→gated` восстанавливает pre-install байты.
|
|
56
|
+
- **codex trust** (X3–X4) переиспользует уже идемпотентные `preSeedCodexHooksTrust` / `removeCodexHooksTrustUnder`.
|
|
57
|
+
|
|
58
|
+
Все поверхности читаются/применяются **на старте сессии**. **Живая сессия держит свою запущенную моду** до следующей фреш-сессии — ровно как агентик-пир лениво подхватывает новую доктрину на следующем wake. Тумблер: (1) персистит `approval_mode`, (2) идемпотентно приводит settings/hooks-поверхности к моде, (3) НЕ трогает живую сессию. Чтобы применить сразу — подними фреш-сессию (`iapeer approval-mode <peer> <mode> --now`, или `iapeer new`/`refresh`).
|
|
59
|
+
|
|
60
|
+
## Механизм перехвата
|
|
61
|
+
|
|
62
|
+
У обоих рантаймов есть Claude-совместимая **hook-система**, которая перехватывает подтверждение программно и возвращает `allow`/`deny` + причину + структурное содержимое действия. Это первичный механизм. **Экранный pty-скрейп — бэкстоп только для одного класса** — claude-circuit-breaker `dangerous-rm`, который живёт *выше* permission-слоя и не виден ни одному хуку.
|
|
63
|
+
|
|
64
|
+
- **claude `PermissionRequest`** — на stdin `{tool_name, tool_input, …}`; хук печатает `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow|deny","message":"…"}}}`. `deny` доносит `message` до модели. Фаярит только на вопрос-достойных вызовах.
|
|
65
|
+
- **codex `PreToolUse`** — тот же shape stdin; печатает `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow|deny","permissionDecisionReason":"…"}}`. `exit 2` (+ stderr) тоже отклоняет. Проверено живьём (0.142.5): codex фаярит `PreToolUse` (не `PermissionRequest`) на tool-вызовах с `tool_name:"Bash"` для shell; `deny` жёстко блокирует тул и причина доходит до модели даже под `sandbox_mode=danger-full-access` — так что под gated-конфигом (danger-full-access + on-request → `permission_mode=bypassPermissions`) хук — ЕДИНСТВЕННЫЙ гейт.
|
|
66
|
+
- **супервизор circuit-breaker** — гард `dangerous-rm`/`rmdir` claude (и стандартный command-approval-промпт, который показывает no-bypass-сессия) — это TUI-селект, невидимый хуку. pty-супервизор читает его со своей авторитетной модели и под gated POSTит брокеру (ниже); под yolo авто-жмёт YES с аудит-строкой.
|
|
67
|
+
|
|
68
|
+
Хук-бинарь — сабкоманда `iapeer approval-hook`: читает hook-JSON рантайма со stdin, резолвит `PEER_PERSONALITY`/`PEER_RUNTIME` из session-env и URL демона из `router.json`, POSTит блокирующий запрос брокеру и печатает рантайм-специфичный JSON решения.
|
|
69
|
+
|
|
70
|
+
## Номенклатура v1 и содержимое на точке перехвата (критерий «что подтверждается»)
|
|
71
|
+
|
|
72
|
+
В любом канале человек видит **конкретное содержимое** действия, а не только имя инструмента.
|
|
73
|
+
|
|
74
|
+
**claude** — `Bash` → полная команда (+ описание); `Edit` → файл + old/new; `Write` → файл + содержимое; `ExitPlanMode` → текст плана; circuit-breaker `dangerous-rm` → полный текст промпта + rm-команда + target (pane-скрейп, не структурно).
|
|
75
|
+
|
|
76
|
+
**codex** — `Bash`/`exec` → команда; `apply_patch` → патч/диф; эскалации (shell/сеть/MCP) через `PermissionRequest` → инструмент + input + человекочитаемое описание.
|
|
77
|
+
|
|
78
|
+
Неизвестный инструмент падает на pretty-print `tool_input`, так что непрозрачного нет ничего.
|
|
79
|
+
|
|
80
|
+
**Лимитации (не замалчиваю):**
|
|
81
|
+
|
|
82
|
+
1. **claude `AskUserQuestion` не перехватывается хуком** — у него нет permission-check и он не событие `PreToolUse`. В v1 остаётся заглушённым через `--disallowedTools AskUserQuestion` (осознанное решение владельца, не дыра к закрытию; возврат — только по его явному запросу).
|
|
83
|
+
2. **codex MCP-elicitation** отдаёт содержимое только на экране, не структурным хуком — редко, в v1 не покрывается.
|
|
84
|
+
3. **у codex нет plan-mode-аппрува** — класс «план» существует только у claude (асимметрия, не отсутствие фичи).
|
|
85
|
+
|
|
86
|
+
## Брокер (единый источник истины)
|
|
87
|
+
|
|
88
|
+
Очередь подтверждений живёт **в демоне** (always-on процесс). Каждый канал, который спрашивает (хук рантайма, breaker супервизора), и каждый, который отвечает (CLI, трей, telegram-рантайм), — интерфейс к этой одной очереди: решение из любого канала гасит запрос везде. In-memory + эфемерна — ожидающие запросы **не** переживают рестарт демона (блокирующее соединение хука рвётся → хук fail-safe в deny); durable `approvals.log` — аудит-след, не хранилище восстановления.
|
|
89
|
+
|
|
90
|
+
Путь: хук gated-пира (или breaker супервизора) блокируется на `POST /fleet/v1/approvals` → брокер кладёт в очередь, эмитит `approval-request` (в `approvals.log` → SSE) и держит соединение → канал отвечает `POST /fleet/v1/approvals/<id>/(approve|deny)` → промис резолвится, демон пишет обратно hook-JSON, инструмент исполняется или блокируется с причиной.
|
|
91
|
+
|
|
92
|
+
**Fail-safety — все направления отказа = `deny`:** брокер недоступен, демон рестартнул при ожидании (соединение рвётся), requester-дисконнект, неизвестный id и таймаут per-request — всё резолвится в deny. Это инженерное основание уборки bypass под gated: без bypass «нет решения» деградирует к собственному permission-промпту рантайма (блокирующий TUI), а **не** к авто-run — сбой перехвата безопасен, никогда не пермиссивен. (Под *сохранённым* bypass хук-таймаут = авто-run — ровно тот вред, ради которого gated существует.)
|
|
93
|
+
|
|
94
|
+
**Таймауты (упорядочены так, чтобы default-deny брокера всегда выигрывал):** брокер default-deny — **300 с** (`IAPEER_APPROVAL_TIMEOUT_MS`); потолок fetch хук-клиента — **600 с**; `timeout` установленного хука рантайма — **900 с**. Так брокер отвечает (или default-денит) первым, клиент абортится вторым, а рантайм убивает хук последним. Таймаут доносится модели как `deny` с `reason="approval timed out (default-deny)"`.
|
|
95
|
+
|
|
96
|
+
## Fleet API
|
|
97
|
+
|
|
98
|
+
Поверхность подтверждений отдаётся на обоих листенерах демона под `/fleet/v1`, с той же авторизацией и обязательствами клиента, что и остальной Fleet API (docs/15). Добавляет четвёртый durable-лог `approvals.log` в SSE-тейл — клиенты ОБЯЗАНЫ игнорить неизвестные `ev` (обязательство 1), ровно так зарезервированы `approval-request` / `approval-resolved`.
|
|
99
|
+
|
|
100
|
+
**События** (компактная logfmt-строка → SSE JSON; полное содержимое НЕ в событии — читай очередь):
|
|
101
|
+
|
|
102
|
+
- `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
|
|
103
|
+
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
|
|
104
|
+
|
|
105
|
+
**Эндпоинты:**
|
|
106
|
+
|
|
107
|
+
| Метод + путь | Назначение |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `GET /fleet/v1/approvals` | список ожидающих (полные item'ы) |
|
|
110
|
+
| `GET /fleet/v1/approvals/<id>` | один ожидающий, полный `content` (многострочный диф / план / команда) |
|
|
111
|
+
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
|
|
112
|
+
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (причина к модели) |
|
|
113
|
+
| `POST /fleet/v1/approvals` | **блокирующий long-poll**, который держит спрашивающий хук/breaker: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
|
|
114
|
+
|
|
115
|
+
`kind` — таксономический тег (`tool` | `plan` | `question` | `circuit-breaker`; терпим к свободной форме). Так бейдж/SSE остаётся лёгким, а полное содержимое доступно в каждом канале через два `GET`.
|
|
116
|
+
|
|
117
|
+
## CLI
|
|
118
|
+
|
|
119
|
+
- `iapeer approvals [--json]` — очередь ожидающих: personality · runtime · kind · **дословная команда/содержимое** · возраст · id.
|
|
120
|
+
- `iapeer approve <id> [--approver <peer>]` — одобрить.
|
|
121
|
+
- `iapeer deny <id> [reason] [--approver <peer>]` — отклонить с причиной (доносится модели).
|
|
122
|
+
- `iapeer approval-mode <peer> [gated|yolo] [--now]` — прочитать текущую моду (опустить моду) или флипнуть: персистит поле, идемпотентно приводит рантайм-поверхности к моде и сообщает момент применения. `--now` дополнительно поднимает фреш-сессию, чтобы мода вступила сразу.
|
|
123
|
+
|
|
124
|
+
`approve` / `deny` / `approvals` достают до in-daemon брокера по Fleet API (как трей), не in-process — очередь живёт в живом демоне.
|
|
125
|
+
|
|
126
|
+
## Совместимость
|
|
127
|
+
|
|
128
|
+
Аддитивно в рамках `/fleet/v1` (docs/15): эндпоинты и `ev` `approval-*` растят поверхность без бампа версии. Pre-approval-демон просто опускает `approval_mode` в снапшоте и не отдаёт эндпоинтов `/approvals`; клиент feature-детектит по наличию поля / `200` от `GET /fleet/v1/approvals`.
|
package/docs/ru/README.md
CHANGED
|
@@ -78,6 +78,7 @@ iapeer send assistant --message "привет, ты на связи?"
|
|
|
78
78
|
- [14 — Конфигурация и журналы](14-конфигурация-и-журналы.md) — переменные окружения, журналы, диагностика
|
|
79
79
|
- [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
|
|
80
80
|
- [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
|
|
81
|
+
- [17 — Подтверждение человеком](17-approval.md) — режим gated/yolo на пира: маршрутизация блокирующих подтверждений рантайма человеку через брокер демона (перехват хуком, брокер, Fleet API + CLI)
|
|
81
82
|
|
|
82
83
|
## Статус
|
|
83
84
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// brokerClient — the shared client transport for the human-approval broker (docs/17). Both the
|
|
2
|
+
// runtime hook (approval/hook.ts, the ASK side of a gated peer's PreToolUse/PermissionRequest) and the
|
|
3
|
+
// supervisor circuit-breaker (supervisor/approvalRoute.ts, Unit 4 — the pty-scrape ASK side above the
|
|
4
|
+
// permission layer) POST a blocking approval request to the daemon's `/fleet/v1/approvals` and await a
|
|
5
|
+
// human decision. Factoring the request/response contract into ONE place (headers, optional H8 bearer,
|
|
6
|
+
// abort-timeout, throw-on-non-200) makes the two callers behave IDENTICALLY by construction — the same
|
|
7
|
+
// parity-by-one-source discipline as buildLaunchInvocation.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-light on purpose: fetch + a router.json path resolver (readFileSync + pluginStateDir). The
|
|
10
|
+
// supervisor daemon dynamic-imports this into its detached @xterm process, so it must never pull the
|
|
11
|
+
// central router / transport graph.
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { pluginStateDir } from '../storage/index.ts'
|
|
16
|
+
|
|
17
|
+
export interface ApprovalDecision {
|
|
18
|
+
decision: 'allow' | 'deny'
|
|
19
|
+
reason?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The request body POST /fleet/v1/approvals accepts (broker.request input; docs/17 §2).
|
|
23
|
+
* `kind` is the taxonomy tag (tool | plan | question | circuit-breaker); the hook sends tool/plan,
|
|
24
|
+
* the supervisor breaker sends circuit-breaker. */
|
|
25
|
+
export interface ApprovalRequestBody {
|
|
26
|
+
personality: string
|
|
27
|
+
runtime: string
|
|
28
|
+
kind: string
|
|
29
|
+
tool: string
|
|
30
|
+
content: string
|
|
31
|
+
summary: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve the daemon fleet base URL (origin-form, no path) from router.json's `tcp` field, else the
|
|
35
|
+
* well-known loopback default. TCP loopback is always served (the daemon dual-listens), so a client
|
|
36
|
+
* never needs the unix socket. */
|
|
37
|
+
export function resolveFleetBase(env: NodeJS.ProcessEnv = process.env): string {
|
|
38
|
+
try {
|
|
39
|
+
const routerJson = join(pluginStateDir('iapeer', { env }), 'router.json')
|
|
40
|
+
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as { tcp?: unknown }
|
|
41
|
+
if (typeof parsed.tcp === 'string' && parsed.tcp) return parsed.tcp.replace(/\/mcp\/?$/, '')
|
|
42
|
+
} catch {
|
|
43
|
+
/* no router.json → default below */
|
|
44
|
+
}
|
|
45
|
+
const port = env.IAPEER_PORT?.trim() || '8765'
|
|
46
|
+
return `http://127.0.0.1:${port}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Client-side hold ceiling — comfortably ABOVE the broker's default-deny timeout (300 s) so the
|
|
50
|
+
* broker's answer always arrives first; if the daemon is dead the fetch aborts and the caller fails
|
|
51
|
+
* safe to deny. */
|
|
52
|
+
export const APPROVAL_FETCH_TIMEOUT_MS = 600_000
|
|
53
|
+
|
|
54
|
+
export interface RequestApprovalDeps {
|
|
55
|
+
env?: NodeJS.ProcessEnv
|
|
56
|
+
/** Injectable fetch (tests). Default global fetch. */
|
|
57
|
+
fetch?: typeof fetch
|
|
58
|
+
/** Injectable client timeout (tests). */
|
|
59
|
+
timeoutMs?: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST a blocking approval request and await the human's decision. Long-poll: the daemon holds the
|
|
64
|
+
* connection until a face answers or the broker's default-deny timeout fires, then returns
|
|
65
|
+
* `{ id, decision, reason? }`. THROWS on any transport failure (daemon unreachable, abort/timeout,
|
|
66
|
+
* non-200) — the CALLER owns the fail-safe (both the hook and the supervisor deny on a throw). A
|
|
67
|
+
* normalized decision comes back: allow carries no reason; deny always carries one.
|
|
68
|
+
*/
|
|
69
|
+
export async function requestApproval(base: string, body: ApprovalRequestBody, deps: RequestApprovalDeps = {}): Promise<ApprovalDecision> {
|
|
70
|
+
const env = deps.env ?? process.env
|
|
71
|
+
const doFetch = deps.fetch ?? fetch
|
|
72
|
+
const timeoutMs = deps.timeoutMs ?? APPROVAL_FETCH_TIMEOUT_MS
|
|
73
|
+
const controller = new AbortController()
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
75
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
76
|
+
try {
|
|
77
|
+
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
78
|
+
const bearer = env.IAPEER_BEARER_TOKEN?.trim()
|
|
79
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`
|
|
80
|
+
const resp = await doFetch(`${base}/fleet/v1/approvals`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers,
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
})
|
|
86
|
+
if (!resp.ok) throw new Error(`daemon returned ${resp.status}`)
|
|
87
|
+
const data = (await resp.json()) as ApprovalDecision
|
|
88
|
+
return data.decision === 'allow' ? { decision: 'allow' } : { decision: 'deny', reason: data.reason || 'denied' }
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timer)
|
|
91
|
+
}
|
|
92
|
+
}
|