@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Approval broker — the in-daemon queue that is the SINGLE SOURCE OF TRUTH for
|
|
2
|
+
// human-approval requests (docs/17). Every channel (the runtime hook that ASKS, and
|
|
3
|
+
// the CLI / tray / telegram faces that ANSWER) is an interface to THIS one queue: a
|
|
4
|
+
// decision from any face resolves the request everywhere (the owner's invariant).
|
|
5
|
+
//
|
|
6
|
+
// Flow: a gated peer's PreToolUse hook blocks and POSTs /fleet/v1/approvals → the fleet
|
|
7
|
+
// handler calls broker.request(...) and awaits the returned promise (long-poll) → the
|
|
8
|
+
// broker enqueues, emits `approval-request` (approvals.log → SSE), and holds the promise
|
|
9
|
+
// → a face calls broker.resolve(id, …) (via POST /fleet/v1/approvals/<id>/approve|deny)
|
|
10
|
+
// → the promise resolves, the fleet handler writes the runtime hook JSON back, the tool
|
|
11
|
+
// proceeds or is blocked with the reason. A per-request timeout resolves default-DENY so
|
|
12
|
+
// a peer never hangs. Every failure direction is deny (fail-safe): timeout, requester
|
|
13
|
+
// disconnect, unknown id.
|
|
14
|
+
//
|
|
15
|
+
// In-memory + ephemeral: pending requests do NOT survive a daemon restart (the blocking
|
|
16
|
+
// hook connection breaks on restart → the hook fails safe to deny). The durable
|
|
17
|
+
// approvals.log is the audit/observability trace, not a recovery store.
|
|
18
|
+
|
|
19
|
+
import { appendApprovalEvent } from './approvalslog.ts'
|
|
20
|
+
|
|
21
|
+
/** v1 taxonomy of intercepted blocking requests (docs/17 §2). Free-form-tolerant. */
|
|
22
|
+
export type ApprovalKind = 'tool' | 'plan' | 'question' | 'circuit-breaker'
|
|
23
|
+
|
|
24
|
+
export interface ApprovalRequestInput {
|
|
25
|
+
personality: string
|
|
26
|
+
runtime: string
|
|
27
|
+
/** Taxonomy tag (tool | plan | question | circuit-breaker | …). */
|
|
28
|
+
kind: ApprovalKind | string
|
|
29
|
+
/** The specific tool / breaker name, e.g. `Bash`, `Edit`, `dangerous-rm`. */
|
|
30
|
+
tool: string
|
|
31
|
+
/** FULL human-readable content of the action — the command, the diff, the plan text,
|
|
32
|
+
* the circuit-breaker prompt. Shown to the human in every channel (criterion #7). */
|
|
33
|
+
content: string
|
|
34
|
+
/** One-line summary for the SSE event / badge (default: the first line of content). */
|
|
35
|
+
summary?: string
|
|
36
|
+
/** Short title, e.g. "boris · Bash" (default: "<personality> · <tool>"). */
|
|
37
|
+
title?: string
|
|
38
|
+
/** nature-peers who may answer (informational in v1 — same-uid faces are trusted). */
|
|
39
|
+
approvers?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PendingApproval extends ApprovalRequestInput {
|
|
43
|
+
id: string
|
|
44
|
+
summary: string
|
|
45
|
+
title: string
|
|
46
|
+
approvers: string[]
|
|
47
|
+
createdMs: number
|
|
48
|
+
expiresMs: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ApprovalDecision {
|
|
52
|
+
decision: 'allow' | 'deny'
|
|
53
|
+
reason?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolveMeta {
|
|
57
|
+
/** The approver who answered (a nature-peer personality). */
|
|
58
|
+
by?: string
|
|
59
|
+
/** The channel that answered: cli | tray | telegram | timeout | disconnect. */
|
|
60
|
+
via?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Entry {
|
|
64
|
+
item: PendingApproval
|
|
65
|
+
resolve: (d: ApprovalDecision) => void
|
|
66
|
+
timer: ReturnType<typeof setTimeout>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ApprovalBrokerOptions {
|
|
70
|
+
/** cfg.eventLogDir — approvals.log lives here (falsy → no durable log, hermetic). */
|
|
71
|
+
logDir?: string
|
|
72
|
+
env?: NodeJS.ProcessEnv
|
|
73
|
+
/** Per-request default-deny timeout. Default IAPEER_APPROVAL_TIMEOUT_MS or 300 s.
|
|
74
|
+
* MUST stay below the runtime hook's own timeout (claude/codex default 600 s) so the
|
|
75
|
+
* broker's default-deny wins before the runtime kills the hook (fail-safe ordering). */
|
|
76
|
+
timeoutMs?: number
|
|
77
|
+
/** Injectable clock (tests). Default Date.now. */
|
|
78
|
+
now?: () => number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_TIMEOUT_MS = 300_000
|
|
82
|
+
|
|
83
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
84
|
+
const n = parseInt(raw ?? '', 10)
|
|
85
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function firstLine(s: string): string {
|
|
89
|
+
const nl = s.indexOf('\n')
|
|
90
|
+
return (nl >= 0 ? s.slice(0, nl) : s).trim()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class ApprovalBroker {
|
|
94
|
+
private readonly pending = new Map<string, Entry>()
|
|
95
|
+
private counter = 0
|
|
96
|
+
private readonly logDir?: string
|
|
97
|
+
private readonly env: NodeJS.ProcessEnv
|
|
98
|
+
private readonly timeoutMs: number
|
|
99
|
+
private readonly now: () => number
|
|
100
|
+
|
|
101
|
+
constructor(opts: ApprovalBrokerOptions = {}) {
|
|
102
|
+
this.logDir = opts.logDir
|
|
103
|
+
this.env = opts.env ?? process.env
|
|
104
|
+
this.timeoutMs = opts.timeoutMs ?? envPosInt(this.env.IAPEER_APPROVAL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
|
105
|
+
this.now = opts.now ?? Date.now
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Enqueue a request and return its id + a promise that resolves when a face answers
|
|
109
|
+
* or the timeout fires (default-deny). The caller (fleet handler) awaits `decision`
|
|
110
|
+
* and MUST call `cancel(id)` if its transport dies before the promise settles. */
|
|
111
|
+
request(input: ApprovalRequestInput): { id: string; decision: Promise<ApprovalDecision> } {
|
|
112
|
+
const id = `a${++this.counter}`
|
|
113
|
+
const createdMs = this.now()
|
|
114
|
+
const expiresMs = createdMs + this.timeoutMs
|
|
115
|
+
const summary = (input.summary?.trim() || firstLine(input.content) || input.tool).slice(0, 240)
|
|
116
|
+
const title = input.title?.trim() || `${input.personality} · ${input.tool}`
|
|
117
|
+
const approvers = input.approvers ?? []
|
|
118
|
+
const item: PendingApproval = { ...input, id, summary, title, approvers, createdMs, expiresMs }
|
|
119
|
+
let resolve!: (d: ApprovalDecision) => void
|
|
120
|
+
const decision = new Promise<ApprovalDecision>(res => {
|
|
121
|
+
resolve = res
|
|
122
|
+
})
|
|
123
|
+
const timer = setTimeout(
|
|
124
|
+
() => this.settle(id, { decision: 'deny', reason: 'approval timed out (default-deny)' }, { via: 'timeout' }),
|
|
125
|
+
this.timeoutMs,
|
|
126
|
+
)
|
|
127
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
128
|
+
this.pending.set(id, { item, resolve, timer })
|
|
129
|
+
appendApprovalEvent(
|
|
130
|
+
this.logDir,
|
|
131
|
+
{
|
|
132
|
+
ev: 'approval-request',
|
|
133
|
+
id,
|
|
134
|
+
personality: input.personality,
|
|
135
|
+
runtime: input.runtime,
|
|
136
|
+
kind: input.kind,
|
|
137
|
+
tool: input.tool,
|
|
138
|
+
summary,
|
|
139
|
+
created: createdMs,
|
|
140
|
+
expires: expiresMs,
|
|
141
|
+
approvers: approvers.join(',') || undefined,
|
|
142
|
+
},
|
|
143
|
+
{ env: this.env, nowMs: createdMs },
|
|
144
|
+
)
|
|
145
|
+
return { id, decision }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** All pending requests, oldest first (GET /fleet/v1/approvals + `iapeer approvals`). */
|
|
149
|
+
list(): PendingApproval[] {
|
|
150
|
+
return [...this.pending.values()].map(e => e.item).sort((a, b) => a.createdMs - b.createdMs)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One pending request by id (GET /fleet/v1/approvals/<id> — full content). */
|
|
154
|
+
get(id: string): PendingApproval | undefined {
|
|
155
|
+
return this.pending.get(id)?.item
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Answer a request from a face. Returns false iff the id is unknown (already resolved,
|
|
159
|
+
* expired, or never existed) — the caller reports 404. */
|
|
160
|
+
resolve(id: string, decision: ApprovalDecision, meta: ResolveMeta = {}): boolean {
|
|
161
|
+
if (!this.pending.has(id)) return false
|
|
162
|
+
this.settle(id, decision, meta)
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Requester transport died before an answer — settle default-deny + drop from the
|
|
167
|
+
* queue so it does not linger (the asking hook is gone; the tool call is aborted). */
|
|
168
|
+
cancel(id: string, reason = 'requester disconnected'): boolean {
|
|
169
|
+
if (!this.pending.has(id)) return false
|
|
170
|
+
this.settle(id, { decision: 'deny', reason }, { via: 'disconnect' })
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Pending count (badge/metrics). */
|
|
175
|
+
size(): number {
|
|
176
|
+
return this.pending.size
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private settle(id: string, decision: ApprovalDecision, meta: ResolveMeta): void {
|
|
180
|
+
const entry = this.pending.get(id)
|
|
181
|
+
if (!entry) return
|
|
182
|
+
clearTimeout(entry.timer)
|
|
183
|
+
this.pending.delete(id)
|
|
184
|
+
const nowMs = this.now()
|
|
185
|
+
appendApprovalEvent(
|
|
186
|
+
this.logDir,
|
|
187
|
+
{
|
|
188
|
+
ev: 'approval-resolved',
|
|
189
|
+
id,
|
|
190
|
+
personality: entry.item.personality,
|
|
191
|
+
runtime: entry.item.runtime,
|
|
192
|
+
decision: decision.decision,
|
|
193
|
+
reason: decision.reason,
|
|
194
|
+
by: meta.by,
|
|
195
|
+
via: meta.via,
|
|
196
|
+
latencyMs: nowMs - entry.item.createdMs,
|
|
197
|
+
},
|
|
198
|
+
{ env: this.env, nowMs },
|
|
199
|
+
)
|
|
200
|
+
entry.resolve(decision)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Approval-broker durable log — the daemon's DURABLE trace of every human-approval
|
|
2
|
+
// request it brokers (docs/17). Sibling to lifecycle.log / delivery.log / exits.log
|
|
3
|
+
// under cfg.eventLogDir; tailed by the fleet-API SSE stream (fleetEventFiles), so the
|
|
4
|
+
// `approval-request` / `approval-resolved` events reach every operator client (CLI,
|
|
5
|
+
// tray, telegram) with at-least-once semantics — and a postmortem can reconstruct who
|
|
6
|
+
// asked, what, and how it resolved after the in-memory queue is gone.
|
|
7
|
+
//
|
|
8
|
+
// What is logged: approval METADATA + a one-line `summary` of the action — NOT the full
|
|
9
|
+
// multi-line content (a Write's whole body / a long diff would bloat the log). The full
|
|
10
|
+
// human-readable content lives in the in-memory queue and is served by GET
|
|
11
|
+
// /fleet/v1/approvals/<id>; the log carries enough to correlate + render a badge/line.
|
|
12
|
+
//
|
|
13
|
+
// Same rotation class as its siblings: written by OUR code → self-rotates via the shared
|
|
14
|
+
// storage/rotatelog primitive. The dir is passed IN by the composition point, never
|
|
15
|
+
// re-resolved from env — a falsy dir → no-op (library/test daemons stay hermetic).
|
|
16
|
+
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import { DEFAULT_LOG_KEEP, DEFAULT_LOG_MAX_BYTES, appendRotatedEvent } from '../storage/rotatelog.ts'
|
|
19
|
+
|
|
20
|
+
/** The approval-broker log inside `logDir` (sibling to lifecycle.log). */
|
|
21
|
+
export function approvalsLogPath(logDir: string): string {
|
|
22
|
+
return join(logDir, 'approvals.log')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
26
|
+
const n = parseInt(raw ?? '', 10)
|
|
27
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AppendApprovalOptions {
|
|
31
|
+
/** Reads the rotation knobs IAPEER_APPROVALS_LOG_MAX_BYTES / _KEEP. */
|
|
32
|
+
env?: NodeJS.ProcessEnv
|
|
33
|
+
/** Stamp the line with this epoch-ms. Default Date.now(). */
|
|
34
|
+
nowMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Append one approval event line into `logDir`/approvals.log. A falsy `logDir` is a
|
|
39
|
+
* no-op (the default for library/test daemons). Fully best-effort — never throws;
|
|
40
|
+
* logging must never fail an approval round-trip.
|
|
41
|
+
*/
|
|
42
|
+
export function appendApprovalEvent(
|
|
43
|
+
logDir: string | undefined,
|
|
44
|
+
fields: Record<string, string | number | undefined>,
|
|
45
|
+
opts: AppendApprovalOptions = {},
|
|
46
|
+
): void {
|
|
47
|
+
if (!logDir) return
|
|
48
|
+
const env = opts.env ?? process.env
|
|
49
|
+
appendRotatedEvent(approvalsLogPath(logDir), fields, {
|
|
50
|
+
nowMs: opts.nowMs,
|
|
51
|
+
maxBytes: envPosInt(env.IAPEER_APPROVALS_LOG_MAX_BYTES, DEFAULT_LOG_MAX_BYTES),
|
|
52
|
+
keep: envPosInt(env.IAPEER_APPROVALS_LOG_KEEP, DEFAULT_LOG_KEEP),
|
|
53
|
+
})
|
|
54
|
+
}
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
isEphemeralPeer,
|
|
43
43
|
isLaunchdManaged,
|
|
44
44
|
loadLifecycleConfig,
|
|
45
|
+
peerApprovalMode,
|
|
45
46
|
wakeOrSpawn,
|
|
46
47
|
type LifecycleConfig,
|
|
47
48
|
} from '../lifecycle/index.ts'
|
|
@@ -59,6 +60,8 @@ import {
|
|
|
59
60
|
} from '../storage/rotatelog.ts'
|
|
60
61
|
import { heartbeatAgeSecs, probeFullDiskAccess, readMemoryProvider, readVoiceProvider } from '../status/index.ts'
|
|
61
62
|
import { deliveryLogPath } from './deliverylog.ts'
|
|
63
|
+
import { approvalsLogPath } from './approvalslog.ts'
|
|
64
|
+
import { ApprovalBroker } from './approvals.ts'
|
|
62
65
|
import { callTool, type CallToolDeps } from './index.ts'
|
|
63
66
|
|
|
64
67
|
/** The API generation baked into every snapshot (additive evolution within v1 —
|
|
@@ -93,6 +96,9 @@ export interface FleetPeer {
|
|
|
93
96
|
wake_policy: 'warm' | 'ephemeral'
|
|
94
97
|
/** Ephemeral peers only: pending serial-queue tasks across declared runtimes. */
|
|
95
98
|
queue_depth?: number
|
|
99
|
+
/** Human-approval mode (docs/17-approval): `yolo` (default — bypass + auto-confirm
|
|
100
|
+
* circuit-breakers) or `gated` (blocking runtime approvals routed to a human). */
|
|
101
|
+
approval_mode: 'yolo' | 'gated'
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
export interface FleetHost {
|
|
@@ -105,12 +111,32 @@ export interface FleetHost {
|
|
|
105
111
|
fda: boolean | null
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/** A pending human-approval request as the snapshot exposes it (docs/17). The SAME items
|
|
115
|
+
* `GET /fleet/v1/approvals` returns (broker.list()) — carried in the snapshot so a client
|
|
116
|
+
* that already fetches `/snapshot` on every event renders the queue with no extra call. The
|
|
117
|
+
* compact `summary` fuels a badge/one-liner; the verbatim `content` (criterion #7 — the full
|
|
118
|
+
* command / diff / plan) rides along for inline display, capped by the client if it wishes. */
|
|
119
|
+
export interface FleetApproval {
|
|
120
|
+
id: string
|
|
121
|
+
personality: string
|
|
122
|
+
runtime: string
|
|
123
|
+
kind: string
|
|
124
|
+
tool: string
|
|
125
|
+
summary: string
|
|
126
|
+
content: string
|
|
127
|
+
createdMs: number
|
|
128
|
+
expiresMs: number
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
export interface FleetSnapshot {
|
|
109
132
|
api: typeof FLEET_API_VERSION
|
|
110
133
|
version: string
|
|
111
134
|
ts: string
|
|
112
135
|
host: FleetHost
|
|
113
136
|
peers: FleetPeer[]
|
|
137
|
+
/** Pending human-approval requests (docs/17). ADDITIVE + omitted when the queue is empty —
|
|
138
|
+
* a client MUST treat its absence as an empty queue (same rule as `approval_mode`). */
|
|
139
|
+
approvals?: FleetApproval[]
|
|
114
140
|
}
|
|
115
141
|
|
|
116
142
|
// The CLI op functions, injectable for tests. Defaults LAZY-load cli/index.ts at
|
|
@@ -146,6 +172,7 @@ export function buildFleetSnapshot(
|
|
|
146
172
|
ops: Pick<FleetOps, 'listPeers'>,
|
|
147
173
|
startedAtMs: number,
|
|
148
174
|
nowMs: number = Date.now(),
|
|
175
|
+
approvals: FleetApproval[] = [],
|
|
149
176
|
): FleetSnapshot {
|
|
150
177
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
151
178
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
@@ -163,6 +190,9 @@ export function buildFleetSnapshot(
|
|
|
163
190
|
if (ephemeral) {
|
|
164
191
|
for (const s of r.runtimes) queueDepth += ephemeralQueueDepth(cfg, buildProcessAddress(s.runtime, r.personality))
|
|
165
192
|
}
|
|
193
|
+
// Approval mode from the LOCAL profile (same source as ephemeral); a read hiccup →
|
|
194
|
+
// the safe `yolo` default (peerApprovalMode swallows it).
|
|
195
|
+
const approvalMode = peerApprovalMode(r.cwd)
|
|
166
196
|
return {
|
|
167
197
|
personality: r.personality,
|
|
168
198
|
description: r.description,
|
|
@@ -176,6 +206,7 @@ export function buildFleetSnapshot(
|
|
|
176
206
|
launchd_managed: isLaunchdManaged(r.personality, env),
|
|
177
207
|
wake_policy: ephemeral ? ('ephemeral' as const) : ('warm' as const),
|
|
178
208
|
...(ephemeral ? { queue_depth: queueDepth } : {}),
|
|
209
|
+
approval_mode: approvalMode,
|
|
179
210
|
}
|
|
180
211
|
})
|
|
181
212
|
const mem = readMemoryProvider(env)
|
|
@@ -198,6 +229,10 @@ export function buildFleetSnapshot(
|
|
|
198
229
|
fda: probeFullDiskAccess(env),
|
|
199
230
|
},
|
|
200
231
|
peers,
|
|
232
|
+
// ADDITIVE + omitted when empty (absence ⇒ empty queue, docs/15). broker.list() is a
|
|
233
|
+
// superset of FleetApproval — the named fields are the contract; extras ride along and
|
|
234
|
+
// clients ignore them (obligation 2).
|
|
235
|
+
...(approvals.length ? { approvals } : {}),
|
|
201
236
|
}
|
|
202
237
|
}
|
|
203
238
|
|
|
@@ -217,6 +252,9 @@ export function fleetEventFiles(cfg: LifecycleConfig): Array<{ path: string; src
|
|
|
217
252
|
{ path: lifecycleLogPath(cfg.eventLogDir), src: 'lifecycle' },
|
|
218
253
|
{ path: deliveryLogPath(cfg.eventLogDir), src: 'delivery' },
|
|
219
254
|
{ path: exitLogPath(cfg.eventLogDir), src: 'exits' },
|
|
255
|
+
// Human-approval broker events (docs/17): approval-request / approval-resolved.
|
|
256
|
+
// A pre-approval daemon simply never writes this file (readLogTail tolerates absence).
|
|
257
|
+
{ path: approvalsLogPath(cfg.eventLogDir), src: 'approvals' },
|
|
220
258
|
]
|
|
221
259
|
}
|
|
222
260
|
|
|
@@ -432,6 +470,8 @@ export interface FleetHandlerOptions {
|
|
|
432
470
|
ops?: FleetOps
|
|
433
471
|
/** Injectable tailer poll interval (tests). */
|
|
434
472
|
pollMs?: number
|
|
473
|
+
/** Injectable approval broker (tests — e.g. a short timeout). Default: a fresh one. */
|
|
474
|
+
broker?: ApprovalBroker
|
|
435
475
|
}
|
|
436
476
|
|
|
437
477
|
export type FleetHandler = (
|
|
@@ -451,6 +491,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
451
491
|
const startedAtMs = Date.now()
|
|
452
492
|
const files = fleetEventFiles(cfg)
|
|
453
493
|
const tailer = new FleetEventTailer(files, opts.pollMs)
|
|
494
|
+
// The approval broker — one per daemon; the ASK side (POST /approvals long-poll) and
|
|
495
|
+
// the ANSWER side (GET/POST /approvals/*) are all interfaces to this one queue.
|
|
496
|
+
const broker = opts.broker ?? new ApprovalBroker({ logDir: cfg.eventLogDir, env })
|
|
454
497
|
let opsPromise: Promise<FleetOps> | undefined
|
|
455
498
|
const getOps = (): Promise<FleetOps> => {
|
|
456
499
|
if (opts.ops) return Promise.resolve(opts.ops)
|
|
@@ -466,7 +509,9 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
466
509
|
// ── reads ────────────────────────────────────────────────────────────
|
|
467
510
|
if (path === '/fleet/v1/snapshot') {
|
|
468
511
|
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/snapshot' })
|
|
469
|
-
|
|
512
|
+
// broker.list() is PendingApproval[] — structurally a superset of FleetApproval, so it
|
|
513
|
+
// satisfies the snapshot contract (extra fields serialize + are ignored by clients).
|
|
514
|
+
return sendJson(res, 200, buildFleetSnapshot(env, cfg, await getOps(), startedAtMs, undefined, broker.list()))
|
|
470
515
|
}
|
|
471
516
|
if (path === '/fleet/v1/events') {
|
|
472
517
|
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/events' })
|
|
@@ -533,6 +578,72 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
533
578
|
if (result.isError) return sendJson(res, 502, { error: text })
|
|
534
579
|
return sendJson(res, 200, { from: caller.address, result: safeParse(text) })
|
|
535
580
|
}
|
|
581
|
+
// ── approvals — the human-approval broker surface (docs/17) ────────────
|
|
582
|
+
if (path === '/fleet/v1/approvals') {
|
|
583
|
+
if (method === 'GET') {
|
|
584
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approvals: broker.list() })
|
|
585
|
+
}
|
|
586
|
+
if (method === 'POST') {
|
|
587
|
+
// The gated peer's hook BLOCKS here (long-poll): enqueue + await a decision.
|
|
588
|
+
let body: Record<string, unknown>
|
|
589
|
+
try {
|
|
590
|
+
body = await readBody(req)
|
|
591
|
+
} catch (e) {
|
|
592
|
+
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
593
|
+
}
|
|
594
|
+
if (typeof body.personality !== 'string' || !body.personality || typeof body.tool !== 'string' || typeof body.content !== 'string') {
|
|
595
|
+
return sendJson(res, 400, { error: 'approval request needs {personality, runtime, tool, content} (+ optional kind/summary/title/approvers)' })
|
|
596
|
+
}
|
|
597
|
+
const { id, decision } = broker.request({
|
|
598
|
+
personality: body.personality,
|
|
599
|
+
runtime: typeof body.runtime === 'string' ? body.runtime : 'claude',
|
|
600
|
+
kind: typeof body.kind === 'string' ? body.kind : 'tool',
|
|
601
|
+
tool: body.tool,
|
|
602
|
+
content: body.content,
|
|
603
|
+
summary: typeof body.summary === 'string' ? body.summary : undefined,
|
|
604
|
+
title: typeof body.title === 'string' ? body.title : undefined,
|
|
605
|
+
approvers: Array.isArray(body.approvers) ? body.approvers.filter((a): a is string => typeof a === 'string') : undefined,
|
|
606
|
+
})
|
|
607
|
+
// No idle-socket kill during the (≤ broker-timeout) hold; cancel default-deny
|
|
608
|
+
// if the asking hook's transport dies before an answer (drop it from the queue).
|
|
609
|
+
res.setTimeout?.(0)
|
|
610
|
+
let settled = false
|
|
611
|
+
const onClose = (): void => {
|
|
612
|
+
if (!settled) broker.cancel(id)
|
|
613
|
+
}
|
|
614
|
+
req.on('close', onClose)
|
|
615
|
+
const d = await decision
|
|
616
|
+
settled = true
|
|
617
|
+
return sendJson(res, 200, { id, ...d })
|
|
618
|
+
}
|
|
619
|
+
return sendJson(res, 405, { error: 'method not allowed — GET|POST /fleet/v1/approvals' })
|
|
620
|
+
}
|
|
621
|
+
const apMatch = /^\/fleet\/v1\/approvals\/([^/]+)(?:\/(approve|deny))?$/.exec(path)
|
|
622
|
+
if (apMatch) {
|
|
623
|
+
const id = decodeURIComponent(apMatch[1]!)
|
|
624
|
+
const action = apMatch[2]
|
|
625
|
+
if (!action) {
|
|
626
|
+
if (method !== 'GET') return sendJson(res, 405, { error: 'method not allowed — GET /fleet/v1/approvals/<id>' })
|
|
627
|
+
const item = broker.get(id)
|
|
628
|
+
if (!item) return sendJson(res, 404, { error: `no pending approval "${id}"` })
|
|
629
|
+
return sendJson(res, 200, { api: FLEET_API_VERSION, version: IAPEER_VERSION, ts: new Date().toISOString(), approval: item })
|
|
630
|
+
}
|
|
631
|
+
if (method !== 'POST') return sendJson(res, 405, { error: 'method not allowed — POST /fleet/v1/approvals/<id>/(approve|deny)' })
|
|
632
|
+
let body: Record<string, unknown> = {}
|
|
633
|
+
try {
|
|
634
|
+
body = await readBody(req)
|
|
635
|
+
} catch {
|
|
636
|
+
/* empty/invalid body is fine — approve/deny carry only optional fields */
|
|
637
|
+
}
|
|
638
|
+
const by = typeof body.approver === 'string' ? body.approver : undefined
|
|
639
|
+
const via = typeof body.via === 'string' ? body.via : 'cli'
|
|
640
|
+
const ok =
|
|
641
|
+
action === 'approve'
|
|
642
|
+
? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
|
|
643
|
+
: broker.resolve(id, { decision: 'deny', reason: typeof body.reason === 'string' ? body.reason : 'denied by human' }, { by, via })
|
|
644
|
+
if (!ok) return sendJson(res, 404, { error: `no pending approval "${id}" (already resolved, expired, or unknown)` })
|
|
645
|
+
return sendJson(res, 200, { id, action, ok: true })
|
|
646
|
+
}
|
|
536
647
|
return sendJson(res, 404, {
|
|
537
648
|
error: `unknown fleet endpoint ${method} ${path}`,
|
|
538
649
|
endpoints: [
|
|
@@ -541,6 +652,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
541
652
|
'GET /fleet/v1/events?replay=N',
|
|
542
653
|
'POST /fleet/v1/peers/<peer>/(wake|stop|start|new|refresh|interrupt|compact)',
|
|
543
654
|
'POST /fleet/v1/send',
|
|
655
|
+
'GET /fleet/v1/approvals',
|
|
656
|
+
'POST /fleet/v1/approvals (hook long-poll)',
|
|
657
|
+
'GET /fleet/v1/approvals/<id>',
|
|
658
|
+
'POST /fleet/v1/approvals/<id>/(approve|deny)',
|
|
544
659
|
],
|
|
545
660
|
})
|
|
546
661
|
} catch (e) {
|
package/src/identity/index.ts
CHANGED
|
@@ -56,6 +56,14 @@ import {
|
|
|
56
56
|
* Enum (not bool) to leave room for future policies. */
|
|
57
57
|
export type WakePolicy = 'ephemeral'
|
|
58
58
|
|
|
59
|
+
/** Per-peer human-approval mode (foundation lifecycle-owned; docs/17-approval).
|
|
60
|
+
* `yolo` (default, absent) = current fleet behavior — runtime bypass flag + the
|
|
61
|
+
* supervisor auto-confirms circuit-breakers. `gated` = launch WITHOUT bypass, the
|
|
62
|
+
* runtime's blocking approval requests are routed to a human through the daemon
|
|
63
|
+
* approval broker. Only `gated` is persisted (yolo = field absent, byte-identical to
|
|
64
|
+
* a never-toggled profile — mirrors wake_policy). */
|
|
65
|
+
export type ApprovalMode = 'yolo' | 'gated'
|
|
66
|
+
|
|
59
67
|
export interface PeerProfile {
|
|
60
68
|
personality: string
|
|
61
69
|
runtime: Runtime
|
|
@@ -69,6 +77,9 @@ export interface PeerProfile {
|
|
|
69
77
|
interfaces?: PeerInterfaces
|
|
70
78
|
/** Per-peer wake policy (lifecycle-owned). Absent = normal warm-on-demand. */
|
|
71
79
|
wake_policy?: WakePolicy
|
|
80
|
+
/** Per-peer human-approval mode (lifecycle-owned). Absent = `yolo` (default). Only
|
|
81
|
+
* `gated` is persisted; see ApprovalMode. */
|
|
82
|
+
approval_mode?: ApprovalMode
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
// Write shape: intelligence/description optional so a caller can write a profile
|
|
@@ -241,9 +252,19 @@ export function readPeerProfile(cwd: string = process.cwd()): PeerProfile | null
|
|
|
241
252
|
// Wake policy — only the known enum value is honored; anything else → omitted
|
|
242
253
|
// (treated as normal warm-on-demand, never throws on an unknown future value).
|
|
243
254
|
...(obj.wake_policy === 'ephemeral' ? { wake_policy: 'ephemeral' as const } : {}),
|
|
255
|
+
// Approval mode — only `gated` is persisted/honored; absent / `yolo` / unknown →
|
|
256
|
+
// omitted (treated as the default `yolo`, never throws on an unknown future value).
|
|
257
|
+
...(obj.approval_mode === 'gated' ? { approval_mode: 'gated' as const } : {}),
|
|
244
258
|
}
|
|
245
259
|
}
|
|
246
260
|
|
|
261
|
+
/** The effective approval mode of a profile: `gated` iff persisted, else the default
|
|
262
|
+
* `yolo`. The ONE place the "absent ⇒ yolo" default lives — every reader (launch,
|
|
263
|
+
* supervisor, fleet snapshot, CLI) resolves the mode through this, never re-derives it. */
|
|
264
|
+
export function approvalModeOf(profile: PeerProfile | null | undefined): ApprovalMode {
|
|
265
|
+
return profile?.approval_mode === 'gated' ? 'gated' : 'yolo'
|
|
266
|
+
}
|
|
267
|
+
|
|
247
268
|
/**
|
|
248
269
|
* Atomically write peer-profile.json, preserving fields the foundation does not
|
|
249
270
|
* own (e.g. persistent-peer's initial_prompt/aliases section) via a raw read-
|
|
@@ -375,6 +396,13 @@ function writePeerProfileMerged(path: string, profile: PeerProfileWrite): void {
|
|
|
375
396
|
if (typeof profile.initial_prompt === 'string' && profile.initial_prompt) {
|
|
376
397
|
merged.initial_prompt = profile.initial_prompt
|
|
377
398
|
}
|
|
399
|
+
// Approval mode (lifecycle-owned toggle). `gated` persists the field; `yolo` REMOVES
|
|
400
|
+
// it (default = absence, so a gated→yolo flip restores a byte-identical pristine
|
|
401
|
+
// profile — the toggle idempotency invariant). An ABSENT approval_mode in the write
|
|
402
|
+
// (any non-toggle writer: verify --fix, rename, re-provision) preserves the existing
|
|
403
|
+
// value verbatim via `...existing` — a caller that does not own the toggle never flips it.
|
|
404
|
+
if (profile.approval_mode === 'gated') merged.approval_mode = 'gated'
|
|
405
|
+
else if (profile.approval_mode === 'yolo') delete merged.approval_mode
|
|
378
406
|
writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}\n`)
|
|
379
407
|
}
|
|
380
408
|
|
|
@@ -115,6 +115,9 @@ export function validateProfileStandard(raw: unknown, cwd: string): ProfileIssue
|
|
|
115
115
|
if (obj.wake_policy !== undefined && obj.wake_policy !== 'ephemeral') {
|
|
116
116
|
issues.push({ severity: 'warn', field: 'wake_policy', message: `unknown value "${String(obj.wake_policy)}" — only 'ephemeral' is honored` })
|
|
117
117
|
}
|
|
118
|
+
if (obj.approval_mode !== undefined && obj.approval_mode !== 'gated' && obj.approval_mode !== 'yolo') {
|
|
119
|
+
issues.push({ severity: 'warn', field: 'approval_mode', message: `unknown value "${String(obj.approval_mode)}" — only 'gated'|'yolo' honored (absent = yolo default)` })
|
|
120
|
+
}
|
|
118
121
|
|
|
119
122
|
// Owner sections — well-typed only; foundation never restructures them.
|
|
120
123
|
if (obj.interfaces !== undefined && (typeof obj.interfaces !== 'object' || obj.interfaces === null || Array.isArray(obj.interfaces))) {
|