@gotcos/glasses-server 6.51.0 → 6.52.3
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/.env.example +16 -0
- package/CHANGELOG.md +86 -0
- package/README.md +56 -3
- package/managed-runtime-contract.json +1 -0
- package/package.json +1 -1
- package/server/index.ts +58 -9
- package/server/lib/activity-preview.ts +45 -2
- package/server/lib/agent-session-search.ts +3 -1
- package/server/lib/agent-session-store.ts +35 -5
- package/server/lib/agent-session-turns.ts +4 -1
- package/server/lib/claude-bridge.ts +9 -0
- package/server/lib/client-instance-claim.ts +22 -1
- package/server/lib/client-liveness.ts +50 -0
- package/server/lib/codex-bridge.ts +6 -0
- package/server/lib/cursor-bridge.ts +6 -0
- package/server/lib/job-trail.ts +758 -0
- package/server/lib/ollama-bridge.ts +17 -0
- package/server/lib/permission-broker.ts +1156 -0
- package/server/lib/query-job-coordinator.ts +17 -1
- package/server/lib/query-job-feature.ts +10 -0
- package/server/lib/query-job-runtime.ts +9 -1
- package/server/lib/query-job-store.ts +214 -5
- package/server/lib/query-job-types.ts +36 -0
- package/server/lib/session-signal-store.ts +42 -10
- package/server/lib/session-state-derive.ts +14 -4
- package/server/lib/session-stream-events.ts +629 -18
- package/server/lib/session-stream-producer.ts +25 -6
- package/server/lib/session-transcript-watcher.ts +120 -3
- package/server/routes/agent-sessions.ts +12 -1
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +14 -7
- package/server/routes/health.ts +24 -1
- package/server/routes/permission-broker.ts +176 -0
- package/server/routes/query-jobs.ts +84 -13
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
// The permission broker (6.52.0): a Claude session's question card (AskUserQuestion) or
|
|
2
|
+
// tool approval, answered from the COS glasses or the phone while Miles is away from the
|
|
3
|
+
// Mac, and handed straight back to the Mac's own dialog the moment he returns to it.
|
|
4
|
+
//
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// THE MECHANISM (canaries, Claude Code 2.1.278, 2026-09-19)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Claude Code runs a PermissionRequest hook before it shows a permission dialog, and waits
|
|
9
|
+
// for it (the installer gives it 130 s; the script's curl gives up at 125 s). The hook may
|
|
10
|
+
// print a decision:
|
|
11
|
+
// - AskUserQuestion: `allow` with `updatedInput` = the original input plus `answers`, a
|
|
12
|
+
// map from each question's ORIGINAL text to a LIST of labels (Desktop's own shape; an
|
|
13
|
+
// "Other" answer is its own entry, verbatim). The model then reads "The user answered".
|
|
14
|
+
// - any other tool: `allow` runs it; `deny` with a message blocks it (`is_error`).
|
|
15
|
+
// No decision (`{}`, or anything the script does not recognise) means the native dialog,
|
|
16
|
+
// exactly as if no hook existed. Every path here that is not an answer ends in `{}`.
|
|
17
|
+
//
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// WHAT GETS PARKED (Miles's Q3 and Q4, 2026-09-19): away AND wearing
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// The hook has no matcher, so every permission request at an idle desk reaches the route.
|
|
22
|
+
// Everything outside the gate answers `{}` at once (target: under 100 ms). A request is
|
|
23
|
+
// parked only when ALL of these hold:
|
|
24
|
+
// - the broker is on: `COS_PERMISSION_BROKER` is not `0`/`false`/`off`, and the session
|
|
25
|
+
// hooks are on (the rows and the retraction ride them);
|
|
26
|
+
// - no maintenance drain is under way;
|
|
27
|
+
// - it is a Claude envelope: a Cursor payload (`cursor_version`) is never brokered;
|
|
28
|
+
// - the tool is AskUserQuestion, or approvals are on (`COS_PERMISSION_BROKER=questions`
|
|
29
|
+
// turns approvals off; the default brokers both). ExitPlanMode is never brokered: its
|
|
30
|
+
// dialog chooses a permission MODE, and "allow once" is not a plan approval;
|
|
31
|
+
// - a client that can answer asked for the questions in the last 30 s: an authenticated
|
|
32
|
+
// `GET /api/session-questions?client=glasses|phone` (lib/client-liveness). An app without
|
|
33
|
+
// the question UI never calls it, so for it the broker is inert and every request is `{}`;
|
|
34
|
+
// - the desk has been idle `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
|
|
35
|
+
// read here with a bounded `ioreg`, not trusted from the script.
|
|
36
|
+
// The cheap gates run first and the desk read last; the question card or the approval card
|
|
37
|
+
// is built only for a request that will be parked (QA round 1: a 900 KB Write input costs a
|
|
38
|
+
// no_client request nothing).
|
|
39
|
+
//
|
|
40
|
+
// A parked item holds the hook's response open until EXACTLY ONE of these resolves it:
|
|
41
|
+
// an answer from the lens or phone; a hand-back from either ("Leave for the Mac"): `{}`;
|
|
42
|
+
// the desk becoming active (polled every 1.5 s, and two unreadable reads in a row count as
|
|
43
|
+
// active): `{}`; the answering client going quiet for 30 s: `{}`; the deadline
|
|
44
|
+
// (`COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped to 120 so it lands before the
|
|
45
|
+
// hook's 125 s): `{}`; the hook hanging up: `hook_gone`; the session
|
|
46
|
+
// moving on without us (its tool ran, was denied, or the turn ended): `{}`; a drain: `{}`.
|
|
47
|
+
//
|
|
48
|
+
// NEVER: a permission RULE. `updatedPermissions` and the request's suggestions are never
|
|
49
|
+
// returned; the decision objects below are built from nothing but constants and the
|
|
50
|
+
// original input. Question and command text is never logged and never goes on the display
|
|
51
|
+
// bus; the client API is behind the API token. A settled item keeps its codes and what was
|
|
52
|
+
// chosen, never the questions or the card.
|
|
53
|
+
|
|
54
|
+
import { execFile } from 'node:child_process'
|
|
55
|
+
import { randomBytes } from 'node:crypto'
|
|
56
|
+
import { boundForRedaction } from './activity-preview.js'
|
|
57
|
+
import { toolFingerprint, SESSION_ID_RE, type HookEnvelope } from './session-hook-events.js'
|
|
58
|
+
import { ASK_USER_QUESTION_TOOL, type SessionSignalStore } from './session-signal-store.js'
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Configuration
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export type BrokerMode = 'off' | 'questions' | 'all'
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `COS_PERMISSION_BROKER`: `0`/`false`/`off` answers `{}` to everything (the kill switch,
|
|
68
|
+
* no reinstall needed); `questions` brokers AskUserQuestion only; unset or anything else
|
|
69
|
+
* brokers questions AND approvals (Miles's Q4). Off whenever the session hooks are off.
|
|
70
|
+
*/
|
|
71
|
+
export function permissionBrokerMode(env: NodeJS.ProcessEnv, sessionHooksOn: boolean): BrokerMode {
|
|
72
|
+
const raw = (env.COS_PERMISSION_BROKER ?? '').trim().toLowerCase()
|
|
73
|
+
if (raw === '0' || raw === 'false' || raw === 'off') return 'off'
|
|
74
|
+
if (!sessionHooksOn) return 'off'
|
|
75
|
+
return raw === 'questions' ? 'questions' : 'all'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const BROKER_TIMEOUT_DEFAULT_S = 110
|
|
79
|
+
/** The hook script's curl gives up at `--max-time 125`. */
|
|
80
|
+
export const HOOK_CURL_MAX_S = 125
|
|
81
|
+
/** The deadline is never at or above the hook's own wait: five seconds of margin. */
|
|
82
|
+
export const BROKER_TIMEOUT_MAX_S = 120
|
|
83
|
+
export const BROKER_TIMEOUT_MIN_S = 5
|
|
84
|
+
|
|
85
|
+
/** `COS_PERMISSION_BROKER_TIMEOUT_S`, default 110, clamped to [5, 120], in ms. */
|
|
86
|
+
export function permissionBrokerTimeoutMs(env: NodeJS.ProcessEnv): number {
|
|
87
|
+
const raw = Number(env.COS_PERMISSION_BROKER_TIMEOUT_S)
|
|
88
|
+
const seconds = Number.isFinite(raw) && raw > 0 ? raw : BROKER_TIMEOUT_DEFAULT_S
|
|
89
|
+
return Math.round(Math.min(BROKER_TIMEOUT_MAX_S, Math.max(BROKER_TIMEOUT_MIN_S, seconds)) * 1000)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A client must have polled `GET /api/session-questions?client=glasses|phone` this recently,
|
|
94
|
+
* at admission AND while anything is held (QA round 1: 60 s let a phone that locked hold a
|
|
95
|
+
* question nobody could see for a minute). Three polls at the advertised interval.
|
|
96
|
+
*/
|
|
97
|
+
export const CLIENT_LIVE_WINDOW_MS = 30_000
|
|
98
|
+
/** The poll interval the questions route advertises to clients. */
|
|
99
|
+
export const QUESTIONS_POLL_INTERVAL_MS = 10_000
|
|
100
|
+
/** A poll stamp this far AHEAD of the server clock (the clock stepped back) is not trusted. */
|
|
101
|
+
export const POLL_FUTURE_SKEW_MS = 5_000
|
|
102
|
+
/** The `client` values of a questions poll that count as a live answerer. */
|
|
103
|
+
export const ANSWERING_CLIENTS: ReadonlySet<string> = new Set(['glasses', 'phone'])
|
|
104
|
+
/** The questions API's own version, advertised at /api/models. */
|
|
105
|
+
export const SESSION_QUESTIONS_PROTOCOL_VERSION = 1
|
|
106
|
+
/** How often a parked item re-reads the desk. */
|
|
107
|
+
export const DESK_POLL_MS = 1_500
|
|
108
|
+
/** Never hold a request for a desk idle less than this, whatever the setting says. */
|
|
109
|
+
export const DESK_IDLE_MIN_S = 30
|
|
110
|
+
/** Parked items at once; past it, `{}`. Checked again after the desk read. */
|
|
111
|
+
export const MAX_PENDING = 32
|
|
112
|
+
/** A settled item is kept this long so a late answer gets the right code and a retry its replay. */
|
|
113
|
+
export const SETTLED_RETENTION_MS = 10 * 60_000
|
|
114
|
+
export const MAX_SETTLED = 256
|
|
115
|
+
/** Free text for "Other". */
|
|
116
|
+
export const OTHER_TEXT_MAX = 2_000
|
|
117
|
+
/** The approval card's short line: the command (or path, URL, query) as the lens shows it. */
|
|
118
|
+
export const APPROVAL_SUMMARY_MAX = 160
|
|
119
|
+
/** The full command, one page back on the lens. */
|
|
120
|
+
export const APPROVAL_DETAIL_MAX = 4_000
|
|
121
|
+
export const DENY_MESSAGE = 'Denied from COS glasses'
|
|
122
|
+
/** Tools whose dialog is not a yes/no: never brokered. */
|
|
123
|
+
export const UNBROKERED_TOOLS: ReadonlySet<string> = new Set(['ExitPlanMode'])
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// The desk
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Seconds since the last keyboard, mouse or trackpad input on this Mac, or null when it
|
|
131
|
+
* cannot be read. One bounded `ioreg` (about 30 ms; `-r -d 1 -k` keeps the output to a
|
|
132
|
+
* few KB), the same counter the hook script reads.
|
|
133
|
+
*/
|
|
134
|
+
export function readHidIdleSeconds(): Promise<number | null> {
|
|
135
|
+
return new Promise(resolve => {
|
|
136
|
+
try {
|
|
137
|
+
execFile('/usr/sbin/ioreg', ['-r', '-d', '1', '-k', 'HIDIdleTime', '-c', 'IOHIDSystem'], { timeout: 1_000, maxBuffer: 1 << 20 }, (error, stdout) => {
|
|
138
|
+
if (error) return resolve(null)
|
|
139
|
+
const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(String(stdout))
|
|
140
|
+
const ns = match ? Number(match[1]) : Number.NaN
|
|
141
|
+
resolve(Number.isFinite(ns) ? Math.floor(ns / 1_000_000_000) : null)
|
|
142
|
+
})
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(null)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Reading the hook's envelope
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export interface PermissionRequestFacts {
|
|
154
|
+
sessionId: string
|
|
155
|
+
toolName: string
|
|
156
|
+
/** Verbatim. The answer is built on top of this object, never a copy of the display. */
|
|
157
|
+
toolInput: Record<string, unknown>
|
|
158
|
+
/** The reducer's fingerprint of the same request (tool name + canonical input). */
|
|
159
|
+
fingerprint: string
|
|
160
|
+
/** The envelope's `ts`: when the hook started, epoch ms from the same Mac clock. */
|
|
161
|
+
hookStartedAtMs: number | null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type EnvelopeVerdict =
|
|
165
|
+
| { ok: true; facts: PermissionRequestFacts }
|
|
166
|
+
| { ok: false; reason: 'malformed' | 'cursor' }
|
|
167
|
+
|
|
168
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
169
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
type EnvelopeRead =
|
|
173
|
+
| { ok: true; facts: Omit<PermissionRequestFacts, 'fingerprint'> }
|
|
174
|
+
| { ok: false; reason: 'malformed' | 'cursor' }
|
|
175
|
+
|
|
176
|
+
/** The envelope's structure only: no hashing, no regex over the input. */
|
|
177
|
+
function readPermissionRequestEnvelope(body: unknown): EnvelopeRead {
|
|
178
|
+
if (!isRecord(body) || body.event !== 'PermissionRequest' || !isRecord(body.payload)) return { ok: false, reason: 'malformed' }
|
|
179
|
+
const p = body.payload
|
|
180
|
+
if ('cursor_version' in p) return { ok: false, reason: 'cursor' }
|
|
181
|
+
if (typeof p.session_id !== 'string' || !SESSION_ID_RE.test(p.session_id)) return { ok: false, reason: 'malformed' }
|
|
182
|
+
if (typeof p.tool_name !== 'string' || p.tool_name.trim().length === 0) return { ok: false, reason: 'malformed' }
|
|
183
|
+
if (!isRecord(p.tool_input)) return { ok: false, reason: 'malformed' }
|
|
184
|
+
const ts = typeof body.ts === 'number' && Number.isFinite(body.ts) ? body.ts : null
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
facts: { sessionId: p.session_id.toLowerCase(), toolName: p.tool_name, toolInput: p.tool_input, hookStartedAtMs: ts },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The spool envelope the hook posts (`{"ts","ppid","event","payload"}`), read strictly.
|
|
193
|
+
* Cursor stamps every hook payload with `cursor_version` and Claude Code never does, so
|
|
194
|
+
* the key's presence, in any form, is a Cursor request. The broker itself reads the
|
|
195
|
+
* structure first and fingerprints only a request it will park.
|
|
196
|
+
*/
|
|
197
|
+
export function parsePermissionRequestEnvelope(body: unknown): EnvelopeVerdict {
|
|
198
|
+
const read = readPermissionRequestEnvelope(body)
|
|
199
|
+
if (!read.ok) return read
|
|
200
|
+
return { ok: true, facts: { ...read.facts, fingerprint: toolFingerprint(read.facts.toolName, read.facts.toolInput) } }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Questions
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
export interface BrokerQuestionOption { label: string; description: string }
|
|
208
|
+
export interface BrokerQuestion { question: string; header: string; multiSelect: boolean; options: BrokerQuestionOption[] }
|
|
209
|
+
|
|
210
|
+
export const MAX_QUESTIONS = 8
|
|
211
|
+
export const MAX_OPTIONS = 16
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The AskUserQuestion input, read strictly; null when the card cannot be answered from
|
|
215
|
+
* here (no questions, an option without a label, two questions with the same text:
|
|
216
|
+
* answers are keyed by the text, so two alike could never both be answered; or two options
|
|
217
|
+
* of one question with the same label: an answer names an option by its label, so the two
|
|
218
|
+
* could never be told apart, and picking both is refused as a duplicate). Null is the
|
|
219
|
+
* `unsupported_input` fast path: `{}` and the Mac's own dialog, never a held card.
|
|
220
|
+
*/
|
|
221
|
+
export function parseQuestions(toolInput: Record<string, unknown>): BrokerQuestion[] | null {
|
|
222
|
+
const raw = toolInput.questions
|
|
223
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_QUESTIONS) return null
|
|
224
|
+
const seen = new Set<string>()
|
|
225
|
+
const questions: BrokerQuestion[] = []
|
|
226
|
+
for (const q of raw) {
|
|
227
|
+
if (!isRecord(q) || typeof q.question !== 'string' || q.question.trim().length === 0) return null
|
|
228
|
+
if (seen.has(q.question)) return null
|
|
229
|
+
seen.add(q.question)
|
|
230
|
+
if (!Array.isArray(q.options) || q.options.length === 0 || q.options.length > MAX_OPTIONS) return null
|
|
231
|
+
const options: BrokerQuestionOption[] = []
|
|
232
|
+
const labels = new Set<string>()
|
|
233
|
+
for (const o of q.options) {
|
|
234
|
+
if (!isRecord(o) || typeof o.label !== 'string' || o.label.length === 0) return null
|
|
235
|
+
if (labels.has(o.label)) return null
|
|
236
|
+
labels.add(o.label)
|
|
237
|
+
options.push({ label: o.label, description: typeof o.description === 'string' ? o.description : '' })
|
|
238
|
+
}
|
|
239
|
+
questions.push({ question: q.question, header: typeof q.header === 'string' ? q.header : '', multiSelect: q.multiSelect === true, options })
|
|
240
|
+
}
|
|
241
|
+
return questions
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The one change made to "Other" free text, measured (canary, 2026-09-19): Claude Code
|
|
246
|
+
* joins a list answer with commas in the text the model reads, so `["Red","Blue","Purple,
|
|
247
|
+
* but only on weekends"]` reached the model as `Red,Blue,Purple, but only on weekends` and
|
|
248
|
+
* the model dropped "but only on weekends". Free text that contains a comma is wrapped in
|
|
249
|
+
* double quotes, which keeps it one answer in that text. Option labels are never touched.
|
|
250
|
+
*/
|
|
251
|
+
export function quoteFreeTextAnswer(text: string): string {
|
|
252
|
+
return text.includes(',') ? `"${text}"` : text
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export type AnswerValidation =
|
|
256
|
+
| { ok: true; answers: Record<string, string[]> }
|
|
257
|
+
| { ok: false; reason: 'answers_shape' | 'unanswered' | 'unknown_label' | 'duplicate_label' | 'too_many' | 'other_too_long'; index?: number }
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A client's answers, one entry per question in order: `{ labels?: string[], other?: string }`.
|
|
261
|
+
* Every question must be answered. Labels must match one of that question's options
|
|
262
|
+
* EXACTLY (the client shows a sanitized copy and sends the original back). `other` is the
|
|
263
|
+
* free text of the card's "Other" choice, sent as its own entry. Single-select takes
|
|
264
|
+
* exactly one entry. The result is keyed by each question's ORIGINAL text, labels in option
|
|
265
|
+
* order, "Other" last: the shape Claude Desktop sends.
|
|
266
|
+
* Free text goes through `quoteFreeTextAnswer`.
|
|
267
|
+
*/
|
|
268
|
+
export function validateQuestionAnswers(questions: readonly BrokerQuestion[], raw: unknown): AnswerValidation {
|
|
269
|
+
if (!Array.isArray(raw) || raw.length !== questions.length) return { ok: false, reason: 'answers_shape' }
|
|
270
|
+
const answers: Record<string, string[]> = {}
|
|
271
|
+
for (let index = 0; index < questions.length; index++) {
|
|
272
|
+
const q = questions[index]!
|
|
273
|
+
const entry = raw[index]
|
|
274
|
+
if (!isRecord(entry)) return { ok: false, reason: 'answers_shape', index }
|
|
275
|
+
const labels = entry.labels === undefined ? [] : entry.labels
|
|
276
|
+
if (!Array.isArray(labels) || labels.some(l => typeof l !== 'string')) return { ok: false, reason: 'answers_shape', index }
|
|
277
|
+
if (entry.other !== undefined && typeof entry.other !== 'string') return { ok: false, reason: 'answers_shape', index }
|
|
278
|
+
const picked = new Set<string>()
|
|
279
|
+
for (const label of labels as string[]) {
|
|
280
|
+
if (!q.options.some(o => o.label === label)) return { ok: false, reason: 'unknown_label', index }
|
|
281
|
+
if (picked.has(label)) return { ok: false, reason: 'duplicate_label', index }
|
|
282
|
+
picked.add(label)
|
|
283
|
+
}
|
|
284
|
+
// Verbatim: whitespace-only is no answer, anything else is sent exactly as written.
|
|
285
|
+
const other = typeof entry.other === 'string' ? entry.other : ''
|
|
286
|
+
if (other.length > OTHER_TEXT_MAX) return { ok: false, reason: 'other_too_long', index }
|
|
287
|
+
const list: string[] = []
|
|
288
|
+
for (const option of q.options) {
|
|
289
|
+
if (picked.has(option.label) && !list.includes(option.label)) list.push(option.label)
|
|
290
|
+
}
|
|
291
|
+
if (other.trim().length > 0) list.push(quoteFreeTextAnswer(other))
|
|
292
|
+
if (list.length === 0) return { ok: false, reason: 'unanswered', index }
|
|
293
|
+
if (!q.multiSelect && list.length > 1) return { ok: false, reason: 'too_many', index }
|
|
294
|
+
answers[q.question] = list
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, answers }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// What the hook prints
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/** AskUserQuestion answered: allow, with the ORIGINAL input plus `answers`. Nothing else. */
|
|
304
|
+
export function questionHookOutput(toolInput: Record<string, unknown>, answers: Record<string, string[]>): Record<string, unknown> {
|
|
305
|
+
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'allow', updatedInput: { ...toolInput, answers } } } }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A tool approval: allow once, or deny with the one message. Never a rule. */
|
|
309
|
+
export function approvalHookOutput(decision: 'allow' | 'deny'): Record<string, unknown> {
|
|
310
|
+
const body = decision === 'allow' ? { behavior: 'allow' } : { behavior: 'deny', message: DENY_MESSAGE }
|
|
311
|
+
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: body } }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// The approval card
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export interface ApprovalCard {
|
|
319
|
+
tool: string
|
|
320
|
+
/** What will run, as the lens shows it: the command verbatim, the full path, the URL. */
|
|
321
|
+
summary: string
|
|
322
|
+
/** The same, longer (up to APPROVAL_DETAIL_MAX). The lens puts it one page back. */
|
|
323
|
+
detail: string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const SHELL_TOOLS: ReadonlySet<string> = new Set(['bash', 'shell', 'exec', 'exec_command'])
|
|
327
|
+
const FILE_TOOLS: ReadonlySet<string> = new Set(['write', 'edit', 'multiedit', 'notebookedit'])
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Approval text as the lens and phone draw it (6.52.0 QA round 2). Nothing is dropped and
|
|
331
|
+
* nothing is merged: a line break is shown as a literal `\n` between spaces, so a second
|
|
332
|
+
* command can never read as arguments of the first; any other control character is shown as
|
|
333
|
+
* `\xNN`. EVERY other character outside printable ASCII (0x20 to 0x7e) is shown as
|
|
334
|
+
* `\uNNNN`, one per UTF-16 unit (6.52.0 app QA): a bidirectional override or a zero-width
|
|
335
|
+
* character cannot reorder or hide part of a command, and a curly quote, a dash, an ellipsis
|
|
336
|
+
* or an emoji cannot be drawn as the ASCII character a lens font substitutes for it. Curly
|
|
337
|
+
* quotes are not shell quotes, so `echo <curly>; curl ... | bash;<curly>` runs the curl; drawn as
|
|
338
|
+
* straight quotes it would read as a harmless echo. Remaining whitespace runs (spaces and
|
|
339
|
+
* tabs) become one space.
|
|
340
|
+
*/
|
|
341
|
+
export function visibleApprovalText(text: string): string {
|
|
342
|
+
return text
|
|
343
|
+
.replace(/\r\n|\r|\n/g, ' \\n ')
|
|
344
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
345
|
+
.replace(/[^\x00-\x7e]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
|
346
|
+
.replace(/[\t\u00a0\s]+/g, ' ')
|
|
347
|
+
.trim()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
type ApprovalRedaction = 'command' | 'path' | 'url'
|
|
351
|
+
|
|
352
|
+
const PROVIDER_TOKEN_RE = /\b(?:sk-(?:proj-|live-|test-|ant-)?|sk_(?:live|test)_|sess-|pat-|gh[pousr]_|github_pat_|glpat-|npm_|pypi-|shpat_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi
|
|
353
|
+
const AWS_KEY_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g
|
|
354
|
+
const GOOGLE_KEY_RE = /\bAIza[A-Za-z0-9_-]{30,}\b/g
|
|
355
|
+
const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g
|
|
356
|
+
const PEM_MARKER_RE = /-----(?:BEGIN|END) [A-Z0-9 ]{0,40}(?:PRIVATE KEY|CREDENTIALS?)-----/gi
|
|
357
|
+
const SECRET_KEY = '(?:api[_-]?key|access[_-]?key(?:[_-]?id)?|access[_-]?token|refresh[_-]?token|session[_-]?token|token|credential|auth(?:orization)?|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key|database[_-]?url|connection[_-]?string|dsn|session(?:[_-]?id)?|cookie|phpsessid|jsessionid|sid)'
|
|
358
|
+
// Inside ONE piece only. The value stops at a quote, a slash or a comma, so a match can
|
|
359
|
+
// never reach past the piece it sits in (6.52.0 QA round 2: the shared patterns' quoted
|
|
360
|
+
// values ran across `&&`, `;` and `|` and swallowed whole commands).
|
|
361
|
+
const PIECE_ASSIGN_RE = new RegExp(`\\b((?:[a-z0-9]+[_-])*${SECRET_KEY}(?:[_-][a-z0-9]+)*["']?[:=])(["']?)([^"'/,]+)`, 'gi')
|
|
362
|
+
const URL_USERINFO_RE = /([a-z][a-z0-9+.-]{0,31}:\/\/)[^\s/@:]*:[^\s/@]+@/gi
|
|
363
|
+
const URL_QUERY_SECRET_RE = /([?&](?:api[_-]?key|access[_-]?token|token|auth|password|secret)=)[^&#\s]+/gi
|
|
364
|
+
// A flag or scheme word whose NEXT piece is the credential.
|
|
365
|
+
const SECRET_NEXT_PIECE_RE = /^(?:--?(?:api-key|token|access-token|refresh-token|password|passwd|secret|client-secret)|-u|--user|["']?(?:bearer|basic|digest)|["']?(?:set-)?cookie:)$/i
|
|
366
|
+
const BASE64ISH_RE = /^["']?[A-Za-z0-9+/=]{24,}["']?$/
|
|
367
|
+
|
|
368
|
+
function redactProviderTokens(text: string): string {
|
|
369
|
+
return text
|
|
370
|
+
.replace(PROVIDER_TOKEN_RE, '[redacted-token]')
|
|
371
|
+
.replace(AWS_KEY_RE, '[redacted-aws-key]')
|
|
372
|
+
.replace(GOOGLE_KEY_RE, '[redacted-google-key]')
|
|
373
|
+
.replace(JWT_RE, '[redacted-jwt]')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Keep a piece's own quotes and hide what they hold. */
|
|
377
|
+
function redactWholePiece(piece: string): string {
|
|
378
|
+
const m = /^(["']?)(.*?)(["']?)$/.exec(piece)
|
|
379
|
+
return m && m[2] ? `${m[1]}[redacted]${m[3]}` : piece
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Secrets out of approval text, and never anything else (6.52.0 QA round 2). A command is
|
|
384
|
+
* split into pieces at whitespace and at `;`, `&` and `|`, which are kept as written; every
|
|
385
|
+
* redaction happens INSIDE one piece, so it can hide a credential but never a command,
|
|
386
|
+
* a pipe or an argument after it. A path keeps everything but provider tokens. A URL also
|
|
387
|
+
* loses its userinfo and secret query values.
|
|
388
|
+
*/
|
|
389
|
+
export function redactApprovalText(text: string, mode: ApprovalRedaction): string {
|
|
390
|
+
if (mode === 'path') return redactProviderTokens(text)
|
|
391
|
+
if (mode === 'url') return redactProviderTokens(text.replace(URL_USERINFO_RE, '$1[redacted]@').replace(URL_QUERY_SECRET_RE, '$1[redacted]'))
|
|
392
|
+
const sawPem = PEM_MARKER_RE.test(text)
|
|
393
|
+
PEM_MARKER_RE.lastIndex = 0
|
|
394
|
+
const marked = text.replace(PEM_MARKER_RE, '[redacted-private-material]')
|
|
395
|
+
const parts = marked.split(/(\s+|&&|\|\||[;&|])/)
|
|
396
|
+
let previous = ''
|
|
397
|
+
for (let i = 0; i < parts.length; i++) {
|
|
398
|
+
const part = parts[i]!
|
|
399
|
+
if (i % 2 === 1 || part === '') continue
|
|
400
|
+
let piece = part
|
|
401
|
+
if (SECRET_NEXT_PIECE_RE.test(previous)) piece = redactWholePiece(piece)
|
|
402
|
+
else if (sawPem && BASE64ISH_RE.test(piece)) piece = redactWholePiece(piece)
|
|
403
|
+
else {
|
|
404
|
+
piece = piece.replace(URL_USERINFO_RE, '$1[redacted]@')
|
|
405
|
+
piece = piece.replace(PIECE_ASSIGN_RE, (_m, key: string, quote: string) => `${key}${quote}[redacted]`)
|
|
406
|
+
piece = redactProviderTokens(piece)
|
|
407
|
+
}
|
|
408
|
+
parts[i] = piece
|
|
409
|
+
previous = part
|
|
410
|
+
}
|
|
411
|
+
return parts.join('')
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The marker a cut line ends with, naming how much is not shown. */
|
|
415
|
+
export function approvalCutMarker(omitted: number): string {
|
|
416
|
+
return ` ...(+${omitted} chars)`
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* What an approval line says when the call gives it nothing to show (6.52.0 app QA): never
|
|
421
|
+
* an empty line, so a client never has to drop an item it cannot draw.
|
|
422
|
+
*/
|
|
423
|
+
export const APPROVAL_EMPTY_COMMAND = '(empty command)'
|
|
424
|
+
export const APPROVAL_EMPTY_PATH = '(empty path)'
|
|
425
|
+
export const APPROVAL_NO_INPUT = '(no input)'
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* One approval line from raw text: made visible (line breaks and hidden characters shown),
|
|
429
|
+
* then redacted with the approval redactor, then cut at `max` with the marker. Only
|
|
430
|
+
* REDACTION_SCAN_MAX_CHARS reach a regex, ended at a token boundary, so no input can make
|
|
431
|
+
* it slow and no secret straddling that bound is shown half. Nothing to show (an empty or
|
|
432
|
+
* whitespace-only value) is `empty`, never ''.
|
|
433
|
+
*/
|
|
434
|
+
function approvalLine(raw: string, max: number, mode: ApprovalRedaction = 'command', empty: string = APPROVAL_NO_INPUT): string {
|
|
435
|
+
const { head, omitted } = boundForRedaction(raw)
|
|
436
|
+
const text = redactApprovalText(visibleApprovalText(head), mode)
|
|
437
|
+
if (text.length === 0 && omitted === 0) return empty
|
|
438
|
+
if (text.length <= max && omitted === 0) return text
|
|
439
|
+
const shown = text.slice(0, max)
|
|
440
|
+
return shown + approvalCutMarker(text.length - shown.length + omitted)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function lineCount(value: unknown): number {
|
|
444
|
+
if (typeof value !== 'string' || value.length === 0) return 0
|
|
445
|
+
return value.replace(/\n$/, '').split('\n').length
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function plural(n: number, word: string): string {
|
|
449
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** How big a file change is, from the call itself. */
|
|
453
|
+
function fileChangeSize(lower: string, input: Record<string, unknown>): string {
|
|
454
|
+
if (lower === 'write') {
|
|
455
|
+
const content = typeof input.content === 'string' ? input.content : ''
|
|
456
|
+
return `${plural(lineCount(content), 'line')}, ${plural(content.length, 'char')}`
|
|
457
|
+
}
|
|
458
|
+
if (lower === 'edit') {
|
|
459
|
+
const all = input.replace_all === true ? ', every match' : ''
|
|
460
|
+
return `+${lineCount(input.new_string)} -${lineCount(input.old_string)} lines${all}`
|
|
461
|
+
}
|
|
462
|
+
if (lower === 'multiedit') {
|
|
463
|
+
const edits = Array.isArray(input.edits) ? input.edits.length : 0
|
|
464
|
+
return plural(edits, 'edit')
|
|
465
|
+
}
|
|
466
|
+
// NotebookEdit. The mode is the call's own text: shown like the rest of the card (every
|
|
467
|
+
// character outside printable ASCII escaped) and cut short, never drawn raw.
|
|
468
|
+
const mode = typeof input.edit_mode === 'string' && input.edit_mode.length > 0 ? approvalLine(input.edit_mode, 40, 'path') : 'replace'
|
|
469
|
+
return `${mode}, ${plural(lineCount(input.new_source), 'line')}`
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A value as the card shows it: a string as written, anything else as JSON. */
|
|
473
|
+
function valueText(value: unknown): string {
|
|
474
|
+
if (typeof value === 'string') return value
|
|
475
|
+
try {
|
|
476
|
+
return JSON.stringify(value) ?? String(value)
|
|
477
|
+
} catch {
|
|
478
|
+
return String(value)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* What the lens and phone show for an approval (6.52.0 QA round 1).
|
|
484
|
+
*
|
|
485
|
+
* THE RULE: the card never says less than what will run. Round 1 reused the Sessions
|
|
486
|
+
* display shorteners and they misstated it: `git status | tail -3 && git push --force
|
|
487
|
+
* origin main` read "git status", `ls | head -1; curl ... | sh` read "ls| sh", any path of
|
|
488
|
+
* 40+ characters read "[opaque output hidden]", and WebFetch showed its prompt. So:
|
|
489
|
+
* - a shell command: verbatim, whitespace-normalized, every segment, pipe and `&&` part;
|
|
490
|
+
* - Write, Edit, MultiEdit, NotebookEdit: the full path, then the size or line count;
|
|
491
|
+
* - WebFetch: the URL. WebSearch: the query;
|
|
492
|
+
* - anything else: its input keys and values in order (the card's `tool` names it).
|
|
493
|
+
* A call with nothing to show still has a line (6.52.0 app QA): `(empty command)` for an
|
|
494
|
+
* empty or blank command, `(empty path)` for an empty path, `(no input)` otherwise (`{}`).
|
|
495
|
+
* A line longer than its cap is cut there and ends with ` ...(+N chars)`. The only thing
|
|
496
|
+
* taken out is a secret, by `redactApprovalText`, which works inside one piece of the
|
|
497
|
+
* command at a time (round 2: the shared patterns swallowed `&& git push --force`), never
|
|
498
|
+
* the opaque-output heuristic, which hid ordinary paths.
|
|
499
|
+
*/
|
|
500
|
+
export function approvalCard(toolName: string, toolInput: Record<string, unknown>): ApprovalCard {
|
|
501
|
+
const lower = toolName.trim().toLowerCase()
|
|
502
|
+
const str = (key: string): string | null => typeof toolInput[key] === 'string' ? toolInput[key] as string : null
|
|
503
|
+
const both = (text: string, mode: ApprovalRedaction = 'command', empty: string = APPROVAL_NO_INPUT): ApprovalCard => ({
|
|
504
|
+
tool: toolName,
|
|
505
|
+
summary: approvalLine(text, APPROVAL_SUMMARY_MAX, mode, empty),
|
|
506
|
+
detail: approvalLine(text, APPROVAL_DETAIL_MAX, mode, empty),
|
|
507
|
+
})
|
|
508
|
+
if (SHELL_TOOLS.has(lower)) {
|
|
509
|
+
const command = str('command') ?? str('cmd')
|
|
510
|
+
if (command !== null) return both(command, 'command', APPROVAL_EMPTY_COMMAND)
|
|
511
|
+
}
|
|
512
|
+
if (FILE_TOOLS.has(lower)) {
|
|
513
|
+
const path = str('file_path') ?? str('notebook_path') ?? str('path')
|
|
514
|
+
if (path !== null) {
|
|
515
|
+
const size = ` (${fileChangeSize(lower, toolInput)})`
|
|
516
|
+
return {
|
|
517
|
+
tool: toolName,
|
|
518
|
+
summary: approvalLine(path, APPROVAL_SUMMARY_MAX, 'path', APPROVAL_EMPTY_PATH) + size,
|
|
519
|
+
detail: approvalLine(path, APPROVAL_DETAIL_MAX, 'path', APPROVAL_EMPTY_PATH) + size,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (lower === 'webfetch' && str('url') !== null) return both(str('url')!, 'url')
|
|
524
|
+
if (lower === 'websearch' && str('query') !== null) return both(str('query')!)
|
|
525
|
+
// `approvalLine` bounds what reaches a regex, so a huge value costs a join, not a scan.
|
|
526
|
+
return both(Object.entries(toolInput).map(([key, value]) => `${key}=${valueText(value)}`).join(' '))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// The broker
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
export type BrokerItemKind = 'question' | 'approval'
|
|
534
|
+
export type BrokerResolution = 'answered' | 'handed_to_desk' | 'expired' | 'hook_gone' | 'retracted' | 'drained' | 'no_client'
|
|
535
|
+
export type BrokerFastPath =
|
|
536
|
+
| 'broker_off' | 'draining' | 'malformed' | 'cursor' | 'approvals_off' | 'unsupported_tool'
|
|
537
|
+
| 'unsupported_input' | 'no_client' | 'stale_hook' | 'capacity' | 'desk_unknown' | 'desk_active'
|
|
538
|
+
| 'hook_gone_early'
|
|
539
|
+
|
|
540
|
+
/** The held response. */
|
|
541
|
+
export interface HookReplyChannel {
|
|
542
|
+
/** The hook is still listening: its socket can take a reply. */
|
|
543
|
+
writable(): boolean
|
|
544
|
+
/** Write the reply; false when it could not be written. */
|
|
545
|
+
reply(body: Record<string, unknown>): boolean
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Where the rows learn about a parked item (the session signal store, in production). */
|
|
549
|
+
export interface BrokerSignalSink {
|
|
550
|
+
attach(sessionId: string, requestId: string, fingerprint: string): void
|
|
551
|
+
detach(sessionId: string, requestId: string): void
|
|
552
|
+
decided(sessionId: string, requestId: string, fingerprint: string): void
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export interface PermissionBrokerDeps {
|
|
556
|
+
now(): number
|
|
557
|
+
mode(): BrokerMode
|
|
558
|
+
admissionsOpen(): boolean
|
|
559
|
+
/** The newest counting `GET /api/session-questions`, or null (lib/client-liveness). */
|
|
560
|
+
lastQuestionsPollAt(): number | null
|
|
561
|
+
readDeskIdleSeconds(): Promise<number | null>
|
|
562
|
+
deskIdleSeconds(): number
|
|
563
|
+
timeoutMs(): number
|
|
564
|
+
signals?: BrokerSignalSink
|
|
565
|
+
pollMs?: number
|
|
566
|
+
newId?: () => string
|
|
567
|
+
log?: (line: string) => void
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export interface ParkRequest {
|
|
571
|
+
facts: PermissionRequestFacts
|
|
572
|
+
kind: BrokerItemKind
|
|
573
|
+
questions: BrokerQuestion[] | null
|
|
574
|
+
approval: ApprovalCard | null
|
|
575
|
+
deadlineAt: number
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export type BrokerAdmission = { ok: true; request: ParkRequest } | { ok: false; reason: BrokerFastPath }
|
|
579
|
+
|
|
580
|
+
type Chosen = { answers: Record<string, string[]> } | { decision: 'allow' | 'deny' }
|
|
581
|
+
|
|
582
|
+
interface BrokerItem {
|
|
583
|
+
id: string
|
|
584
|
+
sessionId: string
|
|
585
|
+
kind: BrokerItemKind
|
|
586
|
+
toolName: string
|
|
587
|
+
toolInput: Record<string, unknown>
|
|
588
|
+
fingerprint: string
|
|
589
|
+
/** When the hook started (its envelope stamp) or, unstamped, when it was parked. */
|
|
590
|
+
requestedAt: number
|
|
591
|
+
questions: BrokerQuestion[] | null
|
|
592
|
+
approval: ApprovalCard | null
|
|
593
|
+
createdAt: number
|
|
594
|
+
deadlineAt: number
|
|
595
|
+
state: 'pending' | BrokerResolution
|
|
596
|
+
settledAt: number | null
|
|
597
|
+
answer: { clientAnswerId: string; chosen: Chosen } | null
|
|
598
|
+
/** The clientAnswerId of the "Leave for the Mac" that settled it, so its retry replays. */
|
|
599
|
+
handedBackBy: string | null
|
|
600
|
+
channel: HookReplyChannel | null
|
|
601
|
+
timer: ReturnType<typeof setTimeout> | null
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export interface SessionQuestionView {
|
|
605
|
+
id: string
|
|
606
|
+
sessionId: string
|
|
607
|
+
provider: 'claude'
|
|
608
|
+
kind: BrokerItemKind
|
|
609
|
+
tool: string
|
|
610
|
+
createdAt: string
|
|
611
|
+
deadlineAt: string
|
|
612
|
+
questions?: BrokerQuestion[]
|
|
613
|
+
approval?: ApprovalCard
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* `/api/health`'s `permissionBroker`. Counts, the mode and two timestamps: never an id,
|
|
618
|
+
* a question, a command, or how many are held right now (that is on the authenticated
|
|
619
|
+
* questions route). Every key is camelCase; the reason CODES keep their wire spelling.
|
|
620
|
+
*/
|
|
621
|
+
export interface PermissionBrokerHealth {
|
|
622
|
+
enabled: boolean
|
|
623
|
+
mode: BrokerMode
|
|
624
|
+
counters: {
|
|
625
|
+
parked: number
|
|
626
|
+
answered: number
|
|
627
|
+
handedToDesk: number
|
|
628
|
+
/** Of `handedToDesk`, those a client handed back ("Leave for the Mac"). */
|
|
629
|
+
handedBack: number
|
|
630
|
+
expired: number
|
|
631
|
+
hookGone: number
|
|
632
|
+
retracted: number
|
|
633
|
+
drained: number
|
|
634
|
+
noClient: number
|
|
635
|
+
/** An answer refused 400 `invalid_answer`. */
|
|
636
|
+
invalidAnswer: number
|
|
637
|
+
/** A desk read that failed while something was held. */
|
|
638
|
+
deskUnreadable: number
|
|
639
|
+
/** Requests answered `{}` without being parked, keyed by the camelCase of the reason. */
|
|
640
|
+
fastPath: Record<string, number>
|
|
641
|
+
}
|
|
642
|
+
lastFastPath: BrokerFastPath | null
|
|
643
|
+
lastFastPathAt: string | null
|
|
644
|
+
lastQuestionsPollAt: string | null
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
type ResolutionCounter = 'handedToDesk' | 'expired' | 'hookGone' | 'retracted' | 'drained' | 'noClient'
|
|
648
|
+
const RESOLUTION_COUNTER: Record<Exclude<BrokerResolution, 'answered'>, ResolutionCounter> = {
|
|
649
|
+
handed_to_desk: 'handedToDesk',
|
|
650
|
+
expired: 'expired',
|
|
651
|
+
hook_gone: 'hookGone',
|
|
652
|
+
retracted: 'retracted',
|
|
653
|
+
drained: 'drained',
|
|
654
|
+
no_client: 'noClient',
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function camelKey(code: string): string {
|
|
658
|
+
return code.replace(/_([a-z])/g, (_m, c: string) => c.toUpperCase())
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function isoOrNull(ms: number | null): string | null {
|
|
662
|
+
return ms !== null && Number.isFinite(ms) ? new Date(ms).toISOString() : null
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface AnswerResult { status: number; body: Record<string, unknown> }
|
|
666
|
+
|
|
667
|
+
export const BROKER_ID_RE = /^pq_[0-9a-f]{24}$/
|
|
668
|
+
export const CLIENT_ANSWER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
669
|
+
|
|
670
|
+
/** Events that end the turn a request was part of: every held request of the session is moot. */
|
|
671
|
+
const TURN_BOUNDARIES: ReadonlySet<string> = new Set(['Stop', 'StopFailure', 'SessionEnd', 'UserPromptSubmit'])
|
|
672
|
+
|
|
673
|
+
export class PermissionBroker {
|
|
674
|
+
private readonly deps: PermissionBrokerDeps
|
|
675
|
+
private readonly items = new Map<string, BrokerItem>()
|
|
676
|
+
private poll: ReturnType<typeof setInterval> | null = null
|
|
677
|
+
private pollInFlight = false
|
|
678
|
+
/** Failed desk reads in a row while something is held. Two hand everything back. */
|
|
679
|
+
private deskUnreadableStreak = 0
|
|
680
|
+
private readonly counters = { parked: 0, answered: 0, handedToDesk: 0, handedBack: 0, expired: 0, hookGone: 0, retracted: 0, drained: 0, noClient: 0, invalidAnswer: 0, deskUnreadable: 0 }
|
|
681
|
+
private readonly fastPaths: Record<string, number> = {}
|
|
682
|
+
private lastFastPath: BrokerFastPath | null = null
|
|
683
|
+
private lastFastPathAt: number | null = null
|
|
684
|
+
|
|
685
|
+
constructor(deps: PermissionBrokerDeps) {
|
|
686
|
+
this.deps = deps
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private log(line: string): void {
|
|
690
|
+
try { (this.deps.log ?? console.log)(`[permission-broker] ${line}`) } catch { /* logging never breaks a hook */ }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** A request answered `{}` without being parked, counted by why. */
|
|
694
|
+
noteFastPath(reason: BrokerFastPath): { ok: false; reason: BrokerFastPath } {
|
|
695
|
+
const key = camelKey(reason)
|
|
696
|
+
this.fastPaths[key] = (this.fastPaths[key] ?? 0) + 1
|
|
697
|
+
this.lastFastPath = reason
|
|
698
|
+
this.lastFastPathAt = this.deps.now()
|
|
699
|
+
return { ok: false, reason }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* A client that can answer has polled within the window. A stamp more than
|
|
704
|
+
* POLL_FUTURE_SKEW_MS ahead of the clock (the clock stepped back) is stale, not live
|
|
705
|
+
* forever.
|
|
706
|
+
*/
|
|
707
|
+
private clientLive(now: number): boolean {
|
|
708
|
+
const polledAt = this.deps.lastQuestionsPollAt()
|
|
709
|
+
if (polledAt === null || !Number.isFinite(polledAt)) return false
|
|
710
|
+
if (polledAt - now > POLL_FUTURE_SKEW_MS) return false
|
|
711
|
+
return now - polledAt <= CLIENT_LIVE_WINDOW_MS
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The desk-idle threshold, never below DESK_IDLE_MIN_S. */
|
|
715
|
+
private deskIdleThreshold(): number {
|
|
716
|
+
const configured = Number(this.deps.deskIdleSeconds())
|
|
717
|
+
return Math.max(DESK_IDLE_MIN_S, Number.isFinite(configured) ? configured : DESK_IDLE_MIN_S)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
pendingCount(): number {
|
|
721
|
+
let n = 0
|
|
722
|
+
for (const item of this.items.values()) if (item.state === 'pending') n++
|
|
723
|
+
return n
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Every gate, cheapest first, so a request outside them is answered without spawning
|
|
728
|
+
* anything or reading its input: the switch, the drain, the envelope's structure (a
|
|
729
|
+
* Cursor payload), the tool policy, a live client, the hook's own deadline, capacity, and
|
|
730
|
+
* only then the desk (one `ioreg`). The switch, the drain, the approvals policy and
|
|
731
|
+
* capacity are read again after it, since the world may have moved during the read. The
|
|
732
|
+
* question card, the approval card and the fingerprint are built only for a request that
|
|
733
|
+
* will be parked.
|
|
734
|
+
*/
|
|
735
|
+
async admit(body: unknown): Promise<BrokerAdmission> {
|
|
736
|
+
if (this.deps.mode() === 'off') return this.noteFastPath('broker_off')
|
|
737
|
+
if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
|
|
738
|
+
const read = readPermissionRequestEnvelope(body)
|
|
739
|
+
if (!read.ok) return this.noteFastPath(read.reason)
|
|
740
|
+
const { facts } = read
|
|
741
|
+
const isQuestion = facts.toolName === ASK_USER_QUESTION_TOOL
|
|
742
|
+
if (!isQuestion) {
|
|
743
|
+
if (this.deps.mode() !== 'all') return this.noteFastPath('approvals_off')
|
|
744
|
+
if (UNBROKERED_TOOLS.has(facts.toolName)) return this.noteFastPath('unsupported_tool')
|
|
745
|
+
}
|
|
746
|
+
const now = this.deps.now()
|
|
747
|
+
if (!this.clientLive(now)) return this.noteFastPath('no_client')
|
|
748
|
+
// The deadline counts from when the HOOK started, when its stamp is sane: a request the
|
|
749
|
+
// server read late must still be answered before the hook's curl gives up.
|
|
750
|
+
const requestedAt = facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now
|
|
751
|
+
const deadlineAt = requestedAt + this.deps.timeoutMs()
|
|
752
|
+
if (deadlineAt <= now) return this.noteFastPath('stale_hook')
|
|
753
|
+
if (this.pendingCount() >= MAX_PENDING) return this.noteFastPath('capacity')
|
|
754
|
+
let idle: number | null = null
|
|
755
|
+
try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null }
|
|
756
|
+
if (idle === null) return this.noteFastPath('desk_unknown')
|
|
757
|
+
if (idle < this.deskIdleThreshold()) return this.noteFastPath('desk_active')
|
|
758
|
+
const mode = this.deps.mode()
|
|
759
|
+
if (mode === 'off') return this.noteFastPath('broker_off')
|
|
760
|
+
if (!this.deps.admissionsOpen()) return this.noteFastPath('draining')
|
|
761
|
+
if (!isQuestion && mode !== 'all') return this.noteFastPath('approvals_off')
|
|
762
|
+
// Others were admitted during the read: the cap holds at the moment of parking.
|
|
763
|
+
if (this.pendingCount() >= MAX_PENDING) return this.noteFastPath('capacity')
|
|
764
|
+
let kind: BrokerItemKind
|
|
765
|
+
let questions: BrokerQuestion[] | null = null
|
|
766
|
+
let approval: ApprovalCard | null = null
|
|
767
|
+
if (isQuestion) {
|
|
768
|
+
questions = parseQuestions(facts.toolInput)
|
|
769
|
+
if (!questions) return this.noteFastPath('unsupported_input')
|
|
770
|
+
kind = 'question'
|
|
771
|
+
} else {
|
|
772
|
+
kind = 'approval'
|
|
773
|
+
approval = approvalCard(facts.toolName, facts.toolInput)
|
|
774
|
+
}
|
|
775
|
+
const fingerprint = toolFingerprint(facts.toolName, facts.toolInput)
|
|
776
|
+
return { ok: true, request: { facts: { ...facts, fingerprint }, kind, questions, approval, deadlineAt } }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Hold the hook's response until exactly one resolution. Returns the item id. */
|
|
780
|
+
park(request: ParkRequest, channel: HookReplyChannel): string {
|
|
781
|
+
const now = this.deps.now()
|
|
782
|
+
const id = this.deps.newId ? this.deps.newId() : `pq_${randomBytes(12).toString('hex')}`
|
|
783
|
+
const { facts } = request
|
|
784
|
+
const item: BrokerItem = {
|
|
785
|
+
id,
|
|
786
|
+
sessionId: facts.sessionId,
|
|
787
|
+
kind: request.kind,
|
|
788
|
+
toolName: facts.toolName,
|
|
789
|
+
// Only a question needs its input again (the answer is built on it). An approval's
|
|
790
|
+
// input can be a whole file (Write): never kept past the card and the fingerprint.
|
|
791
|
+
toolInput: request.kind === 'question' ? facts.toolInput : {},
|
|
792
|
+
fingerprint: facts.fingerprint,
|
|
793
|
+
requestedAt: facts.hookStartedAtMs !== null && facts.hookStartedAtMs <= now ? facts.hookStartedAtMs : now,
|
|
794
|
+
questions: request.questions,
|
|
795
|
+
approval: request.approval,
|
|
796
|
+
createdAt: now,
|
|
797
|
+
deadlineAt: request.deadlineAt,
|
|
798
|
+
state: 'pending',
|
|
799
|
+
settledAt: null,
|
|
800
|
+
answer: null,
|
|
801
|
+
handedBackBy: null,
|
|
802
|
+
channel,
|
|
803
|
+
timer: null,
|
|
804
|
+
}
|
|
805
|
+
this.items.set(id, item)
|
|
806
|
+
this.counters.parked++
|
|
807
|
+
item.timer = setTimeout(() => { this.settle(item, 'expired') }, Math.max(0, request.deadlineAt - now))
|
|
808
|
+
item.timer.unref?.()
|
|
809
|
+
try { this.deps.signals?.attach(item.sessionId, id, item.fingerprint) } catch { /* rows are advisory */ }
|
|
810
|
+
this.ensurePolling()
|
|
811
|
+
this.log(`parked ${id} kind=${item.kind} session=${item.sessionId.slice(0, 8)} deadline_s=${Math.round((request.deadlineAt - now) / 1000)} pending=${this.pendingCount()}`)
|
|
812
|
+
return id
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The hook's connection closed. A no-op once the item is settled. */
|
|
816
|
+
hookClosed(id: string): void {
|
|
817
|
+
const item = this.items.get(id)
|
|
818
|
+
if (item) this.settle(item, 'hook_gone')
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Every way an item ends except an answer. The hook gets `{}` unless it is gone. */
|
|
822
|
+
private settle(item: BrokerItem, resolution: Exclude<BrokerResolution, 'answered'>): boolean {
|
|
823
|
+
if (item.state !== 'pending') return false
|
|
824
|
+
item.state = resolution
|
|
825
|
+
item.settledAt = this.deps.now()
|
|
826
|
+
if (item.timer) clearTimeout(item.timer)
|
|
827
|
+
item.timer = null
|
|
828
|
+
if (resolution !== 'hook_gone') {
|
|
829
|
+
try { item.channel?.reply({}) } catch { /* the hook is gone anyway */ }
|
|
830
|
+
}
|
|
831
|
+
item.channel = null
|
|
832
|
+
// A settled item is kept for its codes, not its content.
|
|
833
|
+
item.toolInput = {}
|
|
834
|
+
item.questions = null
|
|
835
|
+
item.approval = null
|
|
836
|
+
this.counters[RESOLUTION_COUNTER[resolution]]++
|
|
837
|
+
try { this.deps.signals?.detach(item.sessionId, item.id) } catch { /* rows are advisory */ }
|
|
838
|
+
this.log(`${resolution} ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
|
|
839
|
+
this.prune()
|
|
840
|
+
return true
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The pending items, oldest first, for `GET /api/session-questions`. */
|
|
844
|
+
list(): SessionQuestionView[] {
|
|
845
|
+
const now = this.deps.now()
|
|
846
|
+
const views: SessionQuestionView[] = []
|
|
847
|
+
for (const item of [...this.items.values()]) {
|
|
848
|
+
if (item.state !== 'pending') continue
|
|
849
|
+
// A deadline the timer has not caught yet is still a dead question.
|
|
850
|
+
if (now >= item.deadlineAt) { this.settle(item, 'expired'); continue }
|
|
851
|
+
views.push({
|
|
852
|
+
id: item.id,
|
|
853
|
+
sessionId: item.sessionId,
|
|
854
|
+
provider: 'claude',
|
|
855
|
+
kind: item.kind,
|
|
856
|
+
tool: item.toolName,
|
|
857
|
+
createdAt: new Date(item.createdAt).toISOString(),
|
|
858
|
+
deadlineAt: new Date(item.deadlineAt).toISOString(),
|
|
859
|
+
...(item.questions ? { questions: item.questions } : {}),
|
|
860
|
+
...(item.approval ? { approval: item.approval } : {}),
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
return views.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* `POST /api/session-questions/:id/answer`. Synchronous from the state check to the
|
|
868
|
+
* reply, so two racing answers cannot both win: the first one settles the item and the
|
|
869
|
+
* second reads it settled.
|
|
870
|
+
*
|
|
871
|
+
* `{clientAnswerId, handBack: true}` is "Leave for the Mac" (6.52.0 app QA): the hook gets
|
|
872
|
+
* `{}` at once, so the Mac's own dialog shows now instead of at the deadline. It resolves
|
|
873
|
+
* the item as `handed_to_desk`, and it competes with an answer like any answer: the first
|
|
874
|
+
* action wins. A later answer reads 409 `handed_to_desk`; a hand-back after an answer reads
|
|
875
|
+
* 409 `already_answered` with the recorded answer; the same hand-back retried (same
|
|
876
|
+
* clientAnswerId) replays its 200. `handBack` wins over any answer fields in the same body:
|
|
877
|
+
* it is the safe direction, since it only shows the dialog that would have shown anyway.
|
|
878
|
+
*/
|
|
879
|
+
answer(id: string, body: unknown): AnswerResult {
|
|
880
|
+
const item = BROKER_ID_RE.test(id) ? this.items.get(id) : undefined
|
|
881
|
+
if (!item) return { status: 404, body: { error: 'not_found' } }
|
|
882
|
+
const input = isRecord(body) ? body : {}
|
|
883
|
+
const clientAnswerId = input.clientAnswerId
|
|
884
|
+
if (typeof clientAnswerId !== 'string' || !CLIENT_ANSWER_ID_RE.test(clientAnswerId)) {
|
|
885
|
+
this.counters.invalidAnswer++
|
|
886
|
+
return { status: 400, body: { error: 'invalid_answer', reason: 'client_answer_id' } }
|
|
887
|
+
}
|
|
888
|
+
const handBack = input.handBack === true
|
|
889
|
+
if (item.state !== 'pending') return this.settledReply(item, clientAnswerId, handBack)
|
|
890
|
+
if (this.deps.now() >= item.deadlineAt) {
|
|
891
|
+
this.settle(item, 'expired')
|
|
892
|
+
return this.settledReply(item, clientAnswerId, handBack)
|
|
893
|
+
}
|
|
894
|
+
if (handBack) return this.handBack(item, clientAnswerId)
|
|
895
|
+
let chosen: Chosen
|
|
896
|
+
let output: Record<string, unknown>
|
|
897
|
+
if (item.kind === 'question') {
|
|
898
|
+
const validation = validateQuestionAnswers(item.questions ?? [], input.answers)
|
|
899
|
+
if (!validation.ok) {
|
|
900
|
+
this.counters.invalidAnswer++
|
|
901
|
+
return { status: 400, body: { error: 'invalid_answer', reason: validation.reason, ...(validation.index !== undefined ? { index: validation.index } : {}) } }
|
|
902
|
+
}
|
|
903
|
+
chosen = { answers: validation.answers }
|
|
904
|
+
output = questionHookOutput(item.toolInput, validation.answers)
|
|
905
|
+
} else {
|
|
906
|
+
if (input.decision !== 'allow' && input.decision !== 'deny') {
|
|
907
|
+
this.counters.invalidAnswer++
|
|
908
|
+
return { status: 400, body: { error: 'invalid_answer', reason: 'decision' } }
|
|
909
|
+
}
|
|
910
|
+
chosen = { decision: input.decision }
|
|
911
|
+
output = approvalHookOutput(input.decision)
|
|
912
|
+
}
|
|
913
|
+
// The hook must still be listening BEFORE the answer is accepted. An answer written
|
|
914
|
+
// into a closed socket is one the session never received: it would read as answered
|
|
915
|
+
// here while Claude shows the native dialog.
|
|
916
|
+
if (!item.channel || !item.channel.writable() || !item.channel.reply(output)) {
|
|
917
|
+
this.settle(item, 'hook_gone')
|
|
918
|
+
return this.settledReply(item, clientAnswerId)
|
|
919
|
+
}
|
|
920
|
+
item.state = 'answered'
|
|
921
|
+
item.settledAt = this.deps.now()
|
|
922
|
+
item.answer = { clientAnswerId, chosen }
|
|
923
|
+
item.channel = null
|
|
924
|
+
item.toolInput = {}
|
|
925
|
+
item.questions = null
|
|
926
|
+
item.approval = null
|
|
927
|
+
if (item.timer) clearTimeout(item.timer)
|
|
928
|
+
item.timer = null
|
|
929
|
+
this.counters.answered++
|
|
930
|
+
try { this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint) } catch { /* rows are advisory */ }
|
|
931
|
+
this.log(`answered ${item.id} kind=${item.kind} pending=${this.pendingCount()}`)
|
|
932
|
+
this.prune()
|
|
933
|
+
return { status: 200, body: { ok: true, id: item.id, kind: item.kind, ...chosen } }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* "Leave for the Mac" on a pending item. As for an answer, the hook must still be listening:
|
|
938
|
+
* one that already left is `hook_gone` (the Mac shows its dialog either way).
|
|
939
|
+
*/
|
|
940
|
+
private handBack(item: BrokerItem, clientAnswerId: string): AnswerResult {
|
|
941
|
+
if (!item.channel || !item.channel.writable()) {
|
|
942
|
+
this.settle(item, 'hook_gone')
|
|
943
|
+
return this.settledReply(item, clientAnswerId, true)
|
|
944
|
+
}
|
|
945
|
+
item.handedBackBy = clientAnswerId
|
|
946
|
+
this.counters.handedBack++
|
|
947
|
+
this.settle(item, 'handed_to_desk')
|
|
948
|
+
return { status: 200, body: { ok: true, id: item.id, kind: item.kind, handedBack: true } }
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private settledReply(item: BrokerItem, clientAnswerId: string, handBack = false): AnswerResult {
|
|
952
|
+
switch (item.state) {
|
|
953
|
+
case 'answered': {
|
|
954
|
+
const chosen = item.answer?.chosen ?? {}
|
|
955
|
+
// A hand-back never replays an answer, even under the answer's own id.
|
|
956
|
+
if (handBack) return { status: 409, body: { error: 'already_answered', ...chosen } }
|
|
957
|
+
if (item.answer?.clientAnswerId === clientAnswerId) return { status: 200, body: { ok: true, id: item.id, kind: item.kind, replay: true, ...chosen } }
|
|
958
|
+
return { status: 409, body: { error: 'already_answered', ...chosen } }
|
|
959
|
+
}
|
|
960
|
+
case 'expired':
|
|
961
|
+
return { status: 409, body: { error: 'expired' } }
|
|
962
|
+
case 'hook_gone':
|
|
963
|
+
return { status: 410, body: { error: 'hook_gone' } }
|
|
964
|
+
default:
|
|
965
|
+
// handed_to_desk, retracted, drained, no_client: the Mac has it now. Only the
|
|
966
|
+
// hand-back that did it, retried under its own id, replays; an answer never does.
|
|
967
|
+
if (handBack && item.handedBackBy === clientAnswerId) return { status: 200, body: { ok: true, id: item.id, kind: item.kind, handedBack: true, replay: true } }
|
|
968
|
+
return { status: 409, body: { error: 'handed_to_desk', reason: item.state } }
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* One hook event from the spool, after the signal store applied it (the store's
|
|
974
|
+
* subscriber). Keeps the row's id attached across re-announcements, and retracts a held
|
|
975
|
+
* item the session has moved past without us: its tool ran or was denied (the native
|
|
976
|
+
* dialog was answered), or its turn ended. An event older than the request is history.
|
|
977
|
+
*/
|
|
978
|
+
observeHookEvent(env: HookEnvelope, child: boolean): void {
|
|
979
|
+
if (child) return
|
|
980
|
+
const p = env.payload
|
|
981
|
+
const toolName = typeof p.tool_name === 'string' ? p.tool_name : ''
|
|
982
|
+
const fingerprint = typeof p.tool_fingerprint === 'string' ? p.tool_fingerprint : toolFingerprint(toolName, p.tool_input ?? null)
|
|
983
|
+
for (const item of [...this.items.values()]) {
|
|
984
|
+
if (item.sessionId !== env.sessionId) continue
|
|
985
|
+
if (env.event === 'PermissionRequest' || env.event === 'PreToolUse') {
|
|
986
|
+
if (item.fingerprint !== fingerprint) continue
|
|
987
|
+
try {
|
|
988
|
+
if (item.state === 'pending') this.deps.signals?.attach(item.sessionId, item.id, item.fingerprint)
|
|
989
|
+
// A late copy of a request we already answered (stamped before the answer) must
|
|
990
|
+
// not leave the row waiting; the same question asked AGAIN later is a new wait.
|
|
991
|
+
else if (item.state === 'answered' && env.ts <= (item.settledAt ?? 0)) this.deps.signals?.decided(item.sessionId, item.id, item.fingerprint)
|
|
992
|
+
} catch { /* rows are advisory */ }
|
|
993
|
+
continue
|
|
994
|
+
}
|
|
995
|
+
if (item.state !== 'pending' || env.ts < item.requestedAt) continue
|
|
996
|
+
if (TURN_BOUNDARIES.has(env.event) || (env.event === 'SessionStart' && p.source !== 'compact')) {
|
|
997
|
+
this.settle(item, 'retracted')
|
|
998
|
+
continue
|
|
999
|
+
}
|
|
1000
|
+
if (env.event === 'PostToolUse' || env.event === 'PostToolUseFailure' || env.event === 'PermissionDenied') {
|
|
1001
|
+
const same = item.kind === 'question'
|
|
1002
|
+
? toolName === ASK_USER_QUESTION_TOOL
|
|
1003
|
+
: fingerprint === item.fingerprint || (env.event === 'PermissionDenied' && toolName === item.toolName)
|
|
1004
|
+
if (same) this.settle(item, 'retracted')
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private ensurePolling(): void {
|
|
1010
|
+
if (this.poll || this.pendingCount() === 0) return
|
|
1011
|
+
this.poll = setInterval(() => { void this.tick() }, this.deps.pollMs ?? DESK_POLL_MS)
|
|
1012
|
+
this.poll.unref?.()
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private stopPolling(): void {
|
|
1016
|
+
if (this.poll) clearInterval(this.poll)
|
|
1017
|
+
this.poll = null
|
|
1018
|
+
this.deskUnreadableStreak = 0
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* One check while anything is held. A drain, the answering client going quiet, or
|
|
1023
|
+
* returning to the Mac hands everything back. An unreadable desk counts as the desk only
|
|
1024
|
+
* on the SECOND failed read in a row: one `ioreg` that times out under load must not
|
|
1025
|
+
* throw away a question someone is reading (QA round 1).
|
|
1026
|
+
*/
|
|
1027
|
+
async tick(): Promise<void> {
|
|
1028
|
+
if (this.pendingCount() === 0) { this.stopPolling(); return }
|
|
1029
|
+
if (!this.deps.admissionsOpen()) { this.settleAll('drained'); return }
|
|
1030
|
+
if (!this.clientLive(this.deps.now())) { this.settleAll('no_client'); return }
|
|
1031
|
+
if (this.pollInFlight) return
|
|
1032
|
+
this.pollInFlight = true
|
|
1033
|
+
let idle: number | null = null
|
|
1034
|
+
try { idle = await this.deps.readDeskIdleSeconds() } catch { idle = null } finally { this.pollInFlight = false }
|
|
1035
|
+
if (idle === null) {
|
|
1036
|
+
this.counters.deskUnreadable++
|
|
1037
|
+
this.deskUnreadableStreak++
|
|
1038
|
+
if (this.deskUnreadableStreak >= 2) this.settleAll('handed_to_desk')
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
this.deskUnreadableStreak = 0
|
|
1042
|
+
if (idle >= this.deskIdleThreshold()) return
|
|
1043
|
+
this.settleAll('handed_to_desk')
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
private settleAll(resolution: 'handed_to_desk' | 'drained' | 'no_client'): void {
|
|
1047
|
+
for (const item of [...this.items.values()]) if (item.state === 'pending') this.settle(item, resolution)
|
|
1048
|
+
if (this.pendingCount() === 0) this.stopPolling()
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Settled items are kept for SETTLED_RETENTION_MS (late answers, replays), at most MAX_SETTLED. */
|
|
1052
|
+
private prune(): void {
|
|
1053
|
+
const now = this.deps.now()
|
|
1054
|
+
const settled: BrokerItem[] = []
|
|
1055
|
+
for (const item of this.items.values()) {
|
|
1056
|
+
if (item.state === 'pending') continue
|
|
1057
|
+
if (item.settledAt !== null && now - item.settledAt > SETTLED_RETENTION_MS) this.items.delete(item.id)
|
|
1058
|
+
else settled.push(item)
|
|
1059
|
+
}
|
|
1060
|
+
const excess = settled.length - MAX_SETTLED
|
|
1061
|
+
for (let i = 0; i < excess; i++) this.items.delete(settled[i]!.id)
|
|
1062
|
+
if (this.pendingCount() === 0) this.stopPolling()
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
health(): PermissionBrokerHealth {
|
|
1066
|
+
const mode = this.deps.mode()
|
|
1067
|
+
let polledAt: number | null = null
|
|
1068
|
+
try { polledAt = this.deps.lastQuestionsPollAt() } catch { polledAt = null }
|
|
1069
|
+
return {
|
|
1070
|
+
enabled: mode !== 'off',
|
|
1071
|
+
mode,
|
|
1072
|
+
counters: { ...this.counters, fastPath: { ...this.fastPaths } },
|
|
1073
|
+
lastFastPath: this.lastFastPath,
|
|
1074
|
+
lastFastPathAt: isoOrNull(this.lastFastPathAt),
|
|
1075
|
+
lastQuestionsPollAt: isoOrNull(polledAt),
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Counts, for the AUTHENTICATED questions route only (6.52.0 QA round 2): pending was
|
|
1081
|
+
* derivable from the public counters (parked minus every resolution), so none of them are
|
|
1082
|
+
* on the public `/api/health`.
|
|
1083
|
+
*/
|
|
1084
|
+
stats(): { counters: Record<string, number>; fastPath: Record<string, number> } {
|
|
1085
|
+
return { counters: { ...this.counters }, fastPath: { ...this.fastPaths } }
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Shutdown: every held hook is answered `{}` (the native dialog) before the process goes. */
|
|
1089
|
+
stop(): void {
|
|
1090
|
+
this.settleAll('drained')
|
|
1091
|
+
this.stopPolling()
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
// Wiring to the session signal store, and health
|
|
1097
|
+
// ---------------------------------------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
type SignalStoreSeams = Pick<SessionSignalStore, 'attachPermissionRequestId' | 'detachPermissionRequestId' | 'resolveWaiting' | 'subscribe'>
|
|
1100
|
+
|
|
1101
|
+
/** The rows carry a held item's id; a decision is resolution evidence; anything else drops the id. */
|
|
1102
|
+
export function brokerSignalSink(store: SignalStoreSeams): BrokerSignalSink {
|
|
1103
|
+
return {
|
|
1104
|
+
attach: (sessionId, requestId, fingerprint) => { store.attachPermissionRequestId(sessionId, requestId, fingerprint) },
|
|
1105
|
+
detach: (sessionId, requestId) => { store.detachPermissionRequestId(sessionId, requestId) },
|
|
1106
|
+
decided: (sessionId, requestId, fingerprint) => { store.resolveWaiting(sessionId, { requestId, fingerprint }) },
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** Feed every applied hook event to the broker. Returns the unsubscribe. */
|
|
1111
|
+
export function wirePermissionBrokerToSignals(broker: PermissionBroker, store: SignalStoreSeams): () => void {
|
|
1112
|
+
return store.subscribe((_signal, env, child) => { broker.observeHookEvent(env, child) })
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
let registered: PermissionBroker | null = null
|
|
1116
|
+
|
|
1117
|
+
export function registerPermissionBroker(broker: PermissionBroker | null): void {
|
|
1118
|
+
registered = broker
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** `/api/health`: counts, the mode and timestamps only; never an id, a question or a command. */
|
|
1122
|
+
/** What the PUBLIC `/api/health` may show about the broker: the mode and two timestamps.
|
|
1123
|
+
* No counters (6.52.0 QA round 2): how many are held was derivable from them (parked minus
|
|
1124
|
+
* every resolution). The counts are on the authenticated `/api/session-questions` route. */
|
|
1125
|
+
export type PublicPermissionBrokerHealth = Omit<PermissionBrokerHealth, 'counters'>
|
|
1126
|
+
|
|
1127
|
+
export function permissionBrokerHealthFields(): { permissionBroker: PublicPermissionBrokerHealth | null } {
|
|
1128
|
+
if (!registered) return { permissionBroker: null }
|
|
1129
|
+
const { counters: _counters, ...visible } = registered.health()
|
|
1130
|
+
return { permissionBroker: visible }
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
export interface SessionQuestionsCapability {
|
|
1134
|
+
enabled: boolean
|
|
1135
|
+
mode: BrokerMode
|
|
1136
|
+
protocolVersion: number
|
|
1137
|
+
pollIntervalMs: number
|
|
1138
|
+
liveWindowMs: number
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* `/api/models` `capabilities.sessionQuestions`: whether the questions API is there and
|
|
1143
|
+
* holding, and the client contract (poll `GET /api/session-questions?client=glasses|phone`
|
|
1144
|
+
* every `pollIntervalMs`; a client quiet for `liveWindowMs` hands everything back).
|
|
1145
|
+
*/
|
|
1146
|
+
export function sessionQuestionsCapability(): SessionQuestionsCapability {
|
|
1147
|
+
let mode: BrokerMode = 'off'
|
|
1148
|
+
try { mode = registered ? registered.health().mode : 'off' } catch { mode = 'off' }
|
|
1149
|
+
return {
|
|
1150
|
+
enabled: mode !== 'off',
|
|
1151
|
+
mode,
|
|
1152
|
+
protocolVersion: SESSION_QUESTIONS_PROTOCOL_VERSION,
|
|
1153
|
+
pollIntervalMs: QUESTIONS_POLL_INTERVAL_MS,
|
|
1154
|
+
liveWindowMs: CLIENT_LIVE_WINDOW_MS,
|
|
1155
|
+
}
|
|
1156
|
+
}
|