@agfpd/iapeer 0.4.68 → 0.4.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ // Supervisor circuit-breaker → human-approval routing (Unit 4, docs/17). The GATED half of the
2
+ // nag-watcher's 'approve' branch: instead of auto-pressing YES (the yolo status-quo), POST the breaker
3
+ // to the daemon approval broker, block on a human's allow/deny, and inject the decided keys into the
4
+ // pty — the AFFIRMATIVE keys on allow, the taxonomy-specific DECLINE keys on deny.
5
+ //
6
+ // FAIL-SAFE by construction: ANY broker failure (daemon unreachable, abort/timeout, non-200) resolves
7
+ // to DENY (inject the decline keys), NEVER auto-Yes — this is the engineering basis of gated=fail-safe
8
+ // (a gated peer under a supervision fault falls back to the safe answer, not the permissive one). The
9
+ // breaker still sits ABOVE the permission layer, so this is the ONLY interceptor for the prompts the
10
+ // runtime hook cannot see (dangerous-rm; an rm from a subprocess/script).
11
+ //
12
+ // IO is injected (fetch, the pty-write, the log, the clock), so the routing + fail-safe + key selection
13
+ // are unit-testable without a live daemon or a real pty.
14
+
15
+ import { requestApproval, resolveFleetBase, type ApprovalDecision } from '../approval/brokerClient.ts'
16
+
17
+ export interface BreakerApprovalInput {
18
+ /** Class tag — 'dangerous-rm' | 'command-approval'. Sent as the broker `tool` + `summary`. */
19
+ taxonomy: string
20
+ /** Best-effort one-line command/target trace parsed off the pane. Sent as the broker `content`. */
21
+ detail: string
22
+ /** Keys to inject on ALLOW — the position-robust affirmative ('1'+Enter). */
23
+ approveBytes: Buffer
24
+ /** Keys to inject on DENY — the taxonomy-specific decline (2.No | 3.No). */
25
+ denyBytes: Buffer
26
+ }
27
+
28
+ export interface BreakerRouteDeps {
29
+ personality: string
30
+ runtime: string
31
+ env: NodeJS.ProcessEnv
32
+ /** Inject keys into the pty (child.terminal.write). */
33
+ write: (bytes: Buffer) => void
34
+ /** Injectable fetch (tests). */
35
+ fetch?: typeof fetch
36
+ /** Injectable client timeout (tests). */
37
+ timeoutMs?: number
38
+ /** Post-hoc audit line (console.warn in prod; a spy in tests). */
39
+ log?: (line: string) => void
40
+ /** Injectable clock (tests). Default Date.now. */
41
+ now?: () => number
42
+ }
43
+
44
+ /**
45
+ * POST the circuit-breaker to the human-approval broker, await the decision, and inject the decided
46
+ * keys into the pty. Returns the decision for the caller's cooldown/in-flight bookkeeping. NEVER
47
+ * throws — a broker failure fails safe to deny (decline keys injected). Every path leaves one audit
48
+ * line: BREAKER-ALLOW / BREAKER-DENY with the taxonomy, the parsed detail, and (on deny) the reason.
49
+ */
50
+ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<ApprovalDecision> {
51
+ const now = deps.now ?? Date.now
52
+ let decision: ApprovalDecision
53
+ try {
54
+ decision = await requestApproval(
55
+ resolveFleetBase(deps.env),
56
+ {
57
+ personality: deps.personality,
58
+ runtime: deps.runtime,
59
+ kind: 'circuit-breaker',
60
+ tool: input.taxonomy,
61
+ content: input.detail,
62
+ summary: input.taxonomy,
63
+ },
64
+ { env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
65
+ )
66
+ } catch (e) {
67
+ // Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe.
68
+ decision = { decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }
69
+ }
70
+ deps.write(decision.decision === 'allow' ? input.approveBytes : input.denyBytes)
71
+ deps.log?.(
72
+ `[supervisor] BREAKER-${decision.decision.toUpperCase()} ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
73
+ (decision.reason ? ` reason=${JSON.stringify(decision.reason)}` : ''),
74
+ )
75
+ return decision
76
+ }
@@ -38,12 +38,14 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
38
38
  * claudeAdapter satisfies both; codex/router omit them → no action. */
39
39
  export interface NagPredicate {
40
40
  nagDismissKeys?(pane: string): string[] | null
41
- blockingConfirm?(pane: string): { keys: string[]; taxonomy: string; detail: string } | null
41
+ blockingConfirm?(pane: string): { keys: string[]; denyKeys: string[]; taxonomy: string; detail: string } | null
42
42
  }
43
43
 
44
44
  export type NagAction =
45
45
  | { kind: 'dismiss'; keys: string[]; bytes: Buffer } // benign upsell — press keys silently
46
- | { kind: 'approve'; keys: string[]; bytes: Buffer; taxonomy: string; detail: string } // circuit-breaker — press keys + LOG
46
+ // circuit-breaker — press `bytes` to AFFIRM (yolo auto-Yes / gated-approved) or `denyBytes` to DECLINE
47
+ // (gated-denied / broker fail-safe); LOG the taxonomy + detail either way.
48
+ | { kind: 'approve'; keys: string[]; bytes: Buffer; denyKeys: string[]; denyBytes: Buffer; taxonomy: string; detail: string }
47
49
  | { kind: 'none' }
48
50
 
49
51
  /**
@@ -59,7 +61,15 @@ export type NagAction =
59
61
  export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
60
62
  const confirm = adapter.blockingConfirm?.(viewport)
61
63
  if (confirm && confirm.keys.length)
62
- return { kind: 'approve', keys: confirm.keys, bytes: keysToBytes(confirm.keys, enc), taxonomy: confirm.taxonomy, detail: confirm.detail }
64
+ return {
65
+ kind: 'approve',
66
+ keys: confirm.keys,
67
+ bytes: keysToBytes(confirm.keys, enc),
68
+ denyKeys: confirm.denyKeys,
69
+ denyBytes: keysToBytes(confirm.denyKeys, enc),
70
+ taxonomy: confirm.taxonomy,
71
+ detail: confirm.detail,
72
+ }
63
73
  const keys = adapter.nagDismissKeys?.(viewport)
64
74
  if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
65
75
  return { kind: 'none' }
@@ -31,6 +31,8 @@ import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPred
31
31
  import { modelToPlainText } from './render.ts'
32
32
  import { codexAdapter } from '../launch/adapters/codex.ts'
33
33
  import { claudeAdapter } from '../launch/adapters/claude.ts'
34
+ import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
35
+ import { routeCircuitBreaker } from './approvalRoute.ts'
34
36
 
35
37
  /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
36
38
  * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
@@ -688,6 +690,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
688
690
  // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
689
691
  const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
690
692
  if (nagAdapter?.nagDismissKeys || nagAdapter?.blockingConfirm) {
693
+ // ── Unit 4 — approval-mode routing for the circuit-breaker ──────────────────────
694
+ // Resolve the peer's approval mode ONCE at watcher setup: the profile is stable for the session (a
695
+ // mid-session flip needs a fresh launch), and re-reading it every tick would hammer the fs. yolo
696
+ // (default) → the breaker AUTO-presses YES as before (restores the pre-2.1.x bypass status quo,
697
+ // owner decision). gated → the breaker ROUTES the confirm to the human-approval broker (blocking
698
+ // POST, inject the human's Yes/No). approvalModeOf is the ONE "absent⇒yolo" default — never
699
+ // re-derived here; an unreadable/malformed profile → yolo (approvalModeOf(null)), the safe backstop
700
+ // (a gated peer only launched WITHOUT bypass because the SAME profile read gated moments earlier).
701
+ let approvalMode: ApprovalMode = 'yolo'
702
+ try {
703
+ approvalMode = approvalModeOf(readPeerProfile(opts.cwd ?? runDir))
704
+ } catch {
705
+ /* unreadable / malformed profile → yolo default */
706
+ }
707
+ // Broker identity: prefer the composed child-env ABI (PEER_PERSONALITY/RUNTIME, invocation.ts), else
708
+ // derive from the session name (`<runtime>-<personality>`) by STRIPPING the runtime prefix — the
709
+ // personality itself may carry a '-', so never split on the first dash.
710
+ const brokerEnv = opts.env ?? process.env
711
+ const brokerPersonality =
712
+ brokerEnv.PEER_PERSONALITY?.trim() || (session.startsWith(`${runtime}-`) ? session.slice(runtime.length + 1) : session)
713
+ const brokerRuntime = brokerEnv.PEER_RUNTIME?.trim() || runtime
714
+ // In-flight guard: the setInterval keeps firing while a gated POST is outstanding (the modal stays up,
715
+ // the pane stays stuck), so gate to EXACTLY ONE broker request per wedged modal.
716
+ let brokerInFlight = false
691
717
  let nagFiredMs = 0
692
718
  // В60 — stuck-gate: a REAL blocked modal freezes the pty entirely, while a live peer merely
693
719
  // RENDERING the modal text (code review / quoted report — the В40 false-fire class) keeps
@@ -712,12 +738,42 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
712
738
  child.terminal.write(action.bytes)
713
739
  nagFiredMs = Date.now()
714
740
  } else if (action.kind === 'approve') {
715
- // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — press the affirmative so a headless peer never
716
- // hangs on it (owner decision: auto-YES restores the pre-2.1.x bypass status quo). Leave a
717
- // post-hoc AUDIT line in the supervisor's own log (console → logPath): WHICH peer (session),
718
- // WHAT (taxonomy + the parsed command/target), WHEN (ts). A rare bad rm is then at least visible
719
- // after the fact — the interim before human-approval (Telegram) lands. Best-effort log, never
720
- // gates the keypress.
741
+ // CIRCUIT-BREAKER confirm (e.g. dangerous-rm) — a headless peer has no human to press it, so it
742
+ // hangs forever. What we press depends on the peer's approval MODE (resolved once above):
743
+ if (approvalMode === 'gated') {
744
+ // GATED — route the confirm to the human-approval broker (docs/17, Unit 4). A blocking POST
745
+ // awaits a human's allow/deny (≤ broker 300s); routeCircuitBreaker injects the affirmative on
746
+ // allow, the taxonomy-specific decline on deny (dangerous-rm=2.No / command-approval=3.No), and
747
+ // fails safe to DENY on any broker fault. The breaker sits ABOVE the permission layer, so this
748
+ // is the ONLY interceptor for prompts the runtime hook cannot see (dangerous-rm; rm from a
749
+ // subprocess). Guarded by brokerInFlight so a re-firing tick never opens a second request.
750
+ if (brokerInFlight) return
751
+ brokerInFlight = true
752
+ void routeCircuitBreaker(
753
+ { taxonomy: action.taxonomy, detail: action.detail, approveBytes: action.bytes, denyBytes: action.denyBytes },
754
+ {
755
+ personality: brokerPersonality,
756
+ runtime: brokerRuntime,
757
+ env: brokerEnv,
758
+ write: b => child.terminal.write(b),
759
+ log: line => {
760
+ try {
761
+ console.warn(line)
762
+ } catch {
763
+ /* logging must never break the conduit */
764
+ }
765
+ },
766
+ },
767
+ ).finally(() => {
768
+ brokerInFlight = false
769
+ nagFiredMs = Date.now() // just answered → let the (now-clearing) modal repaint away before re-check
770
+ })
771
+ return
772
+ }
773
+ // YOLO (default) — auto-press YES so a headless peer never hangs (owner decision: restores the
774
+ // pre-2.1.x bypass status quo). Leave a post-hoc AUDIT line (WHICH peer, WHAT taxonomy + parsed
775
+ // command/target, WHEN) so a rare bad rm is at least visible after the fact. Best-effort log,
776
+ // never gates the keypress.
721
777
  child.terminal.write(action.bytes)
722
778
  nagFiredMs = Date.now()
723
779
  try {