switchroom 0.10.0 → 0.11.0
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/README.md +5 -4
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +201 -24
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +4407 -2252
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-command.ts +121 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +876 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +127 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +16 -18
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -115,3 +115,130 @@ export async function tryHostdDispatch(
|
|
|
115
115
|
export function hostdRequestId(prefix: string): string {
|
|
116
116
|
return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Poll hostd's `get_status` verb until the target request reaches a
|
|
121
|
+
* terminal state (`completed` / `error` / `denied`) or the caller's
|
|
122
|
+
* timeout elapses.
|
|
123
|
+
*
|
|
124
|
+
* Motivation: the long-running mutating verbs (`update_apply`, `apply`)
|
|
125
|
+
* respond `result: "started"` immediately and run the work in a
|
|
126
|
+
* detached child on the daemon side. Without polling, callers that
|
|
127
|
+
* acked "started" to the operator have no way to surface a *fail
|
|
128
|
+
* before recreate* (image-pull error, scaffold regeneration crash,
|
|
129
|
+
* etc.) — the gateway dies if recreate succeeds, but stays alive and
|
|
130
|
+
* silent if it fails. Polling closes that observability hole.
|
|
131
|
+
*
|
|
132
|
+
* Behaviour:
|
|
133
|
+
* - Polls every {@link opts.intervalMs} ms (default 2000 per RFC C §5.3).
|
|
134
|
+
* - Bails out after {@link opts.timeoutMs} with a synthesized
|
|
135
|
+
* `result: "error"` response describing the timeout. Caller should
|
|
136
|
+
* treat that as inconclusive — for `update_apply` specifically,
|
|
137
|
+
* a timeout often means the recreate succeeded and killed the
|
|
138
|
+
* gateway; the *new* gateway's post-restart greeting card is the
|
|
139
|
+
* true success signal.
|
|
140
|
+
* - Any terminal state from the daemon (`completed`/`error`/`denied`)
|
|
141
|
+
* bails immediately and returns that response. Wire errors are
|
|
142
|
+
* synthesized by {@link tryHostdDispatch} as `result: "error"`,
|
|
143
|
+
* which also bails — there's no separate retry on transient wire
|
|
144
|
+
* failures because (a) the daemon doesn't actually go down except
|
|
145
|
+
* during a recreate that kills us anyway, and (b) waiting until
|
|
146
|
+
* timeout to surface a clear error is worse UX than surfacing it
|
|
147
|
+
* immediately.
|
|
148
|
+
* - Returns immediately if hostd is unconfigured (treats as
|
|
149
|
+
* `not-configured`, same as {@link tryHostdDispatch}).
|
|
150
|
+
*/
|
|
151
|
+
export async function pollHostdStatus(
|
|
152
|
+
agentName: string,
|
|
153
|
+
targetRequestId: string,
|
|
154
|
+
opts: {
|
|
155
|
+
/** Hard cap. update_apply: 60_000; apply: 30_000. */
|
|
156
|
+
timeoutMs: number;
|
|
157
|
+
/** Default 2000. */
|
|
158
|
+
intervalMs?: number;
|
|
159
|
+
/** Test seam — defaults to `Date.now`. */
|
|
160
|
+
now?: () => number;
|
|
161
|
+
/** Test seam — defaults to `setTimeout`. */
|
|
162
|
+
sleep?: (ms: number) => Promise<void>;
|
|
163
|
+
},
|
|
164
|
+
): Promise<HostdResponse | "not-configured"> {
|
|
165
|
+
if (!isHostdEnabled()) return "not-configured";
|
|
166
|
+
const sockPath = hostdSocketPath(agentName);
|
|
167
|
+
if (!existsSync(sockPath)) return "not-configured";
|
|
168
|
+
const now = opts.now ?? Date.now;
|
|
169
|
+
const sleep =
|
|
170
|
+
opts.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
171
|
+
const intervalMs = opts.intervalMs ?? 2000;
|
|
172
|
+
const deadline = now() + opts.timeoutMs;
|
|
173
|
+
// Initial wait — the caller just sent the kick-off request. Give the
|
|
174
|
+
// daemon a tick to begin work before the first poll.
|
|
175
|
+
await sleep(intervalMs);
|
|
176
|
+
while (now() < deadline) {
|
|
177
|
+
const pollId = hostdRequestId("gw-poll");
|
|
178
|
+
const resp = await tryHostdDispatch(agentName, {
|
|
179
|
+
v: 1,
|
|
180
|
+
op: "get_status",
|
|
181
|
+
request_id: pollId,
|
|
182
|
+
args: { target_request_id: targetRequestId },
|
|
183
|
+
});
|
|
184
|
+
if (resp === "not-configured") {
|
|
185
|
+
// Socket disappeared mid-poll — daemon was stopped. Surface that
|
|
186
|
+
// distinctly from a target-request error so callers can decide
|
|
187
|
+
// whether to retry or bail.
|
|
188
|
+
return resp;
|
|
189
|
+
}
|
|
190
|
+
// get_status returns the StatusEntry's result, which IS the target
|
|
191
|
+
// request's result. Any terminal state (completed/error/denied) is
|
|
192
|
+
// the target's final answer — bail with it. The previous draft of
|
|
193
|
+
// this helper retried on `error`/`denied` in case the daemon was
|
|
194
|
+
// transiently busy; that policy masked real errors as
|
|
195
|
+
// "still polling" until the 60s cap, then synthesized a misleading
|
|
196
|
+
// "timeout" response. Bailing immediately surfaces the daemon's
|
|
197
|
+
// audit-log truth directly to the operator.
|
|
198
|
+
if (
|
|
199
|
+
resp.result === "completed" ||
|
|
200
|
+
resp.result === "error" ||
|
|
201
|
+
resp.result === "denied"
|
|
202
|
+
) {
|
|
203
|
+
return resp;
|
|
204
|
+
}
|
|
205
|
+
// result: "started" — get_status reflects the latest StatusEntry,
|
|
206
|
+
// which is still `started` until the daemon's mutation finishes.
|
|
207
|
+
// Keep polling.
|
|
208
|
+
await sleep(intervalMs);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
v: 1,
|
|
212
|
+
request_id: hostdRequestId("gw-poll-timeout"),
|
|
213
|
+
result: "error",
|
|
214
|
+
exit_code: null,
|
|
215
|
+
duration_ms: opts.timeoutMs,
|
|
216
|
+
error:
|
|
217
|
+
`hostd poll timeout after ${opts.timeoutMs}ms waiting for ` +
|
|
218
|
+
`target_request_id=${targetRequestId}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Emit a one-line operator-visible deprecation warning when a verb that
|
|
224
|
+
* hostd supports is being dispatched via the legacy spawn path. Quiet
|
|
225
|
+
* by design — operators see it once per verb per process in journald,
|
|
226
|
+
* never in chat. RFC C §7 Phase 2 → Phase 3.
|
|
227
|
+
*/
|
|
228
|
+
const _deprecationSeen = new Set<string>();
|
|
229
|
+
export function warnLegacySpawnIfHostdDisabled(verb: string): void {
|
|
230
|
+
if (isHostdEnabled()) return;
|
|
231
|
+
if (_deprecationSeen.has(verb)) return;
|
|
232
|
+
_deprecationSeen.add(verb);
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`telegram gateway: spawnSwitchroomDetached(${verb}) — set ` +
|
|
235
|
+
`host_control.enabled: true and run \`switchroom hostd install\` ` +
|
|
236
|
+
`to route through audited hostd. Legacy path scheduled for ` +
|
|
237
|
+
`removal in v0.10 (RFC C Phase 3).\n`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** @internal Reset both caches so tests can re-assert behaviour. */
|
|
242
|
+
export function _resetDeprecationSeen(): void {
|
|
243
|
+
_deprecationSeen.clear();
|
|
244
|
+
}
|
|
@@ -59,12 +59,47 @@ export interface ScheduleRestartResult {
|
|
|
59
59
|
error?: string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* RFC E §4.2 Cut 2 — sent by the gateway to acknowledge that a
|
|
64
|
+
* Drive-write approval card has been posted (or that posting
|
|
65
|
+
* failed). The Drive-write PreToolUse hook (a separate process)
|
|
66
|
+
* uses the `request_id` to poll the kernel's `approval_lookup` for
|
|
67
|
+
* the verdict; if posting fails, the hook fails closed.
|
|
68
|
+
*
|
|
69
|
+
* Why response-shaped: the hook is synchronous from Claude Code's
|
|
70
|
+
* perspective (PreToolUse blocks the tool call). The hook can't
|
|
71
|
+
* return its `decision: "approve" | "block"` until either the
|
|
72
|
+
* card has been posted (so the user can decide) OR posting failed
|
|
73
|
+
* (so the hook can return block immediately). A response message
|
|
74
|
+
* is the cleanest way to surface that.
|
|
75
|
+
*/
|
|
76
|
+
export interface DriveApprovalPostedEvent {
|
|
77
|
+
type: "drive_approval_posted";
|
|
78
|
+
/** Same correlation_id the client sent on the request. */
|
|
79
|
+
correlationId: string;
|
|
80
|
+
ok: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Kernel request_id the hook will pass to `approval_lookup` once
|
|
83
|
+
* it starts polling. Only present when `ok: true`.
|
|
84
|
+
*/
|
|
85
|
+
requestId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Unix-ms expiry of the kernel request, mirrors the ttl_ms the
|
|
88
|
+
* gateway used. Hook uses this as its polling deadline. Only
|
|
89
|
+
* present when `ok: true`.
|
|
90
|
+
*/
|
|
91
|
+
expiresAtMs?: number;
|
|
92
|
+
/** Diagnostic detail on failure. */
|
|
93
|
+
reason?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
export type GatewayToClient =
|
|
63
97
|
| InboundMessage
|
|
64
98
|
| PermissionEvent
|
|
65
99
|
| StatusEvent
|
|
66
100
|
| ToolCallResult
|
|
67
|
-
| ScheduleRestartResult
|
|
101
|
+
| ScheduleRestartResult
|
|
102
|
+
| DriveApprovalPostedEvent;
|
|
68
103
|
|
|
69
104
|
// === Bridge (Client) -> Gateway messages ===
|
|
70
105
|
|
|
@@ -189,6 +224,51 @@ export interface InjectInboundMessage {
|
|
|
189
224
|
inbound: InboundMessage;
|
|
190
225
|
}
|
|
191
226
|
|
|
227
|
+
/**
|
|
228
|
+
* RFC E §4.2 Cut 2 — sent by the Drive-write PreToolUse hook to
|
|
229
|
+
* the gateway to register a diff-preview approval card with the
|
|
230
|
+
* kernel + post it to Telegram. The hook waits on the
|
|
231
|
+
* corresponding `drive_approval_posted` reply (matching
|
|
232
|
+
* `correlationId`), then polls `approval_lookup` for the verdict.
|
|
233
|
+
*
|
|
234
|
+
* The `preview` payload is shaped like
|
|
235
|
+
* `src/drive/diff-preview.ts:DiffPreviewInput`. We don't restate
|
|
236
|
+
* the full shape on the wire — the IPC validator does a structural
|
|
237
|
+
* check (required fields present, types right) and the gateway-side
|
|
238
|
+
* consumer feeds it straight to `buildDiffPreview()` which is
|
|
239
|
+
* already defensive against malformed inputs.
|
|
240
|
+
*
|
|
241
|
+
* Trust model: same as `inject_inbound` — the gateway socket lives
|
|
242
|
+
* inside the agent container, only that-UID processes can connect,
|
|
243
|
+
* so the hook is as trusted as anything else in the container.
|
|
244
|
+
*/
|
|
245
|
+
export interface RequestDriveApprovalMessage {
|
|
246
|
+
type: "request_drive_approval";
|
|
247
|
+
/**
|
|
248
|
+
* Hook-generated correlation id (any unique string ≤ 64 chars).
|
|
249
|
+
* Echoed back in `drive_approval_posted` so the hook can match
|
|
250
|
+
* the response if multiple Drive-write taps are in flight.
|
|
251
|
+
*/
|
|
252
|
+
correlationId: string;
|
|
253
|
+
/**
|
|
254
|
+
* Target agent the gateway serves. Defense in depth — the gateway
|
|
255
|
+
* verifies this matches its own SWITCHROOM_AGENT_NAME and refuses
|
|
256
|
+
* cross-agent requests.
|
|
257
|
+
*/
|
|
258
|
+
agentName: string;
|
|
259
|
+
/**
|
|
260
|
+
* DiffPreviewInput payload — see `src/drive/diff-preview.ts`.
|
|
261
|
+
* Carried as an opaque object on the wire; the gateway
|
|
262
|
+
* deserialises it via `buildDiffPreview()`.
|
|
263
|
+
*/
|
|
264
|
+
preview: Record<string, unknown>;
|
|
265
|
+
/**
|
|
266
|
+
* TTL for the kernel approval request, in ms. Hook typically
|
|
267
|
+
* passes 5 min; gateway clamps to a sensible range.
|
|
268
|
+
*/
|
|
269
|
+
ttlMs?: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
192
272
|
export type ClientToGateway =
|
|
193
273
|
| RegisterMessage
|
|
194
274
|
| ToolCallMessage
|
|
@@ -199,4 +279,5 @@ export type ClientToGateway =
|
|
|
199
279
|
| OperatorEventForward
|
|
200
280
|
| PtyPartialForward
|
|
201
281
|
| UpdatePlaceholderMessage
|
|
202
|
-
| InjectInboundMessage
|
|
282
|
+
| InjectInboundMessage
|
|
283
|
+
| RequestDriveApprovalMessage;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
PermissionRequestForward,
|
|
9
9
|
PtyPartialForward,
|
|
10
10
|
RegisterMessage,
|
|
11
|
+
RequestDriveApprovalMessage,
|
|
11
12
|
ScheduleRestartMessage,
|
|
12
13
|
SessionEventForward,
|
|
13
14
|
ToolCallMessage,
|
|
@@ -40,6 +41,18 @@ export interface IpcServerOptions {
|
|
|
40
41
|
* inline scheduler simply ignore inject_inbound messages.
|
|
41
42
|
*/
|
|
42
43
|
onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
|
|
44
|
+
/**
|
|
45
|
+
* RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
|
|
46
|
+
* to register a kernel approval request + post a diff-preview
|
|
47
|
+
* card to Telegram. Handler is expected to send a
|
|
48
|
+
* `drive_approval_posted` event back over the same connection
|
|
49
|
+
* (`client.send(...)`). Optional: gateways without the hook
|
|
50
|
+
* configured ignore these messages.
|
|
51
|
+
*/
|
|
52
|
+
onRequestDriveApproval?: (
|
|
53
|
+
client: IpcClient,
|
|
54
|
+
msg: RequestDriveApprovalMessage,
|
|
55
|
+
) => Promise<void>;
|
|
43
56
|
log?: (msg: string) => void;
|
|
44
57
|
/**
|
|
45
58
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -192,6 +205,23 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
192
205
|
&& typeof inb.meta === "object"
|
|
193
206
|
&& inb.meta !== null;
|
|
194
207
|
}
|
|
208
|
+
case "request_drive_approval": {
|
|
209
|
+
// RFC E §4.2 Cut 2. Validate the wire-shaped fields the
|
|
210
|
+
// gateway will route on; the inner `preview` is treated as
|
|
211
|
+
// an opaque object and gets defensively re-validated by
|
|
212
|
+
// `buildDiffPreview()` downstream.
|
|
213
|
+
if (typeof m.correlationId !== "string"
|
|
214
|
+
|| (m.correlationId as string).length === 0
|
|
215
|
+
|| (m.correlationId as string).length > 64) return false;
|
|
216
|
+
if (typeof m.agentName !== "string"
|
|
217
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
218
|
+
if (typeof m.preview !== "object" || m.preview === null) return false;
|
|
219
|
+
if (m.ttlMs !== undefined
|
|
220
|
+
&& (typeof m.ttlMs !== "number"
|
|
221
|
+
|| !Number.isFinite(m.ttlMs)
|
|
222
|
+
|| (m.ttlMs as number) < 0)) return false;
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
195
225
|
default:
|
|
196
226
|
return false;
|
|
197
227
|
}
|
|
@@ -210,6 +240,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
210
240
|
onOperatorEvent,
|
|
211
241
|
onPtyPartial,
|
|
212
242
|
onInjectInbound,
|
|
243
|
+
onRequestDriveApproval,
|
|
213
244
|
log = () => {},
|
|
214
245
|
heartbeatTimeoutMs = 30_000,
|
|
215
246
|
} = options;
|
|
@@ -298,6 +329,44 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
298
329
|
case "inject_inbound":
|
|
299
330
|
if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
|
|
300
331
|
break;
|
|
332
|
+
case "request_drive_approval":
|
|
333
|
+
if (onRequestDriveApproval) {
|
|
334
|
+
// Handler is async — fire-and-forget here; the handler
|
|
335
|
+
// is responsible for sending its `drive_approval_posted`
|
|
336
|
+
// response (success or failure) back to the client.
|
|
337
|
+
onRequestDriveApproval(client, msg as RequestDriveApprovalMessage).catch(
|
|
338
|
+
(err) => {
|
|
339
|
+
log(
|
|
340
|
+
`request_drive_approval handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
341
|
+
);
|
|
342
|
+
try {
|
|
343
|
+
client.send({
|
|
344
|
+
type: "drive_approval_posted",
|
|
345
|
+
correlationId: (msg as RequestDriveApprovalMessage).correlationId,
|
|
346
|
+
ok: false,
|
|
347
|
+
reason: `gateway handler error: ${(err as Error).message}`,
|
|
348
|
+
});
|
|
349
|
+
} catch {
|
|
350
|
+
/* best effort */
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
// No handler wired — fail closed and tell the hook so it
|
|
356
|
+
// can fall back to blocking the tool. Better than leaving
|
|
357
|
+
// the hook timing out.
|
|
358
|
+
try {
|
|
359
|
+
client.send({
|
|
360
|
+
type: "drive_approval_posted",
|
|
361
|
+
correlationId: (msg as RequestDriveApprovalMessage).correlationId,
|
|
362
|
+
ok: false,
|
|
363
|
+
reason: "gateway not configured for Drive-write approval",
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
/* best effort */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
301
370
|
case "update_placeholder":
|
|
302
371
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
303
372
|
// Soft-accepted so recall.py keeps working without modifying
|
|
@@ -90,6 +90,85 @@ function emitContext(text) {
|
|
|
90
90
|
process.stdout.write(JSON.stringify(payload) + '\n')
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* #1303: classify a tool_response as a failure. Only failures can have
|
|
95
|
+
* hit a kernel sandbox boundary. Pre-fix the hook stringified the whole
|
|
96
|
+
* tool_response and pattern-matched against it — that meant a SUCCESSFUL
|
|
97
|
+
* Read/Edit/Bash whose payload merely MENTIONED "EROFS" or "Read-only
|
|
98
|
+
* file system" (e.g. file content, code comments, grep results, the hook
|
|
99
|
+
* source itself) tripped the advisory. Verified live during #1291/#1292
|
|
100
|
+
* PR work: every `Read` on a file talking about the sandbox model
|
|
101
|
+
* produced a false positive; every `Edit` adding a comment that
|
|
102
|
+
* mentioned read-only-fs did too.
|
|
103
|
+
*
|
|
104
|
+
* Recognise failure across the three observed tool_response shapes:
|
|
105
|
+
* - Edit / Write / NotebookEdit / MCP: `{ is_error: true, ... }`
|
|
106
|
+
* - Bash: `{ exit_code: <non-zero>, stdout, stderr, ... }`
|
|
107
|
+
* - Free-form string body: assume failure if the string parses; the
|
|
108
|
+
* pattern match downstream still gates the advisory text.
|
|
109
|
+
*
|
|
110
|
+
* Also exported as `legacy.error` style for forward-compat: any
|
|
111
|
+
* non-null `tool_response.error` field is treated as failure.
|
|
112
|
+
*
|
|
113
|
+
* If no failure signal is found we have no kernel error to advise on,
|
|
114
|
+
* and the hook stays silent.
|
|
115
|
+
*/
|
|
116
|
+
function classifyFailure(toolResponse) {
|
|
117
|
+
if (toolResponse == null) return null
|
|
118
|
+
if (typeof toolResponse === 'string') {
|
|
119
|
+
// Bare string body — no structured failure marker. Treat as a
|
|
120
|
+
// candidate; the pattern match decides.
|
|
121
|
+
return { kind: 'bare-string', body: toolResponse }
|
|
122
|
+
}
|
|
123
|
+
if (typeof toolResponse !== 'object') return null
|
|
124
|
+
const isError =
|
|
125
|
+
toolResponse.is_error === true
|
|
126
|
+
|| toolResponse.success === false
|
|
127
|
+
|| toolResponse.error != null
|
|
128
|
+
|| (typeof toolResponse.exit_code === 'number'
|
|
129
|
+
&& toolResponse.exit_code !== 0)
|
|
130
|
+
if (!isError) return null
|
|
131
|
+
// Extract error-bearing fields only — never the full response. For a
|
|
132
|
+
// failed Bash, stdout may carry the relevant kernel message alongside
|
|
133
|
+
// stderr (some commands write errors to stdout), so include stdout
|
|
134
|
+
// when there's a non-zero exit code.
|
|
135
|
+
const parts = []
|
|
136
|
+
if (typeof toolResponse.error === 'string') parts.push(toolResponse.error)
|
|
137
|
+
if (typeof toolResponse.stderr === 'string') parts.push(toolResponse.stderr)
|
|
138
|
+
if (toolResponse.exit_code != null && toolResponse.exit_code !== 0
|
|
139
|
+
&& typeof toolResponse.stdout === 'string') {
|
|
140
|
+
parts.push(toolResponse.stdout)
|
|
141
|
+
}
|
|
142
|
+
// Fallback: failure was signalled but no error-bearing field
|
|
143
|
+
// surfaced — stringify the structured response so we don't miss an
|
|
144
|
+
// unusual tool that puts the kernel error in an unexpected key.
|
|
145
|
+
// Bounded by the 64 KiB cap downstream.
|
|
146
|
+
if (parts.length === 0) {
|
|
147
|
+
try { parts.push(JSON.stringify(toolResponse)) } catch { /* unprintable */ }
|
|
148
|
+
}
|
|
149
|
+
return { kind: 'structured-failure', body: parts.join('\n') }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* #1303 secondary defence: only write-capable tools can hit a kernel
|
|
154
|
+
* sandbox boundary. Read/Grep/Glob/WebFetch/etc. cannot EROFS — even if
|
|
155
|
+
* settings.json wires this hook with matcher ".*", we gate at the
|
|
156
|
+
* script level so a future scaffold change can't re-introduce the
|
|
157
|
+
* false-positive class. Bash is included because it's the canonical
|
|
158
|
+
* write surface (mkdir, rm, install, apt, etc.). MCP tools that may
|
|
159
|
+
* proxy writes are included by an `mcp__` prefix check.
|
|
160
|
+
*/
|
|
161
|
+
const WRITE_CAPABLE_TOOLS = new Set([
|
|
162
|
+
'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash',
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
function isWriteCapableTool(toolName) {
|
|
166
|
+
if (typeof toolName !== 'string') return false
|
|
167
|
+
if (WRITE_CAPABLE_TOOLS.has(toolName)) return true
|
|
168
|
+
if (toolName.startsWith('mcp__')) return true
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
93
172
|
function main() {
|
|
94
173
|
const raw = readStdin()
|
|
95
174
|
if (!raw) return
|
|
@@ -101,18 +180,18 @@ function main() {
|
|
|
101
180
|
return
|
|
102
181
|
}
|
|
103
182
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
183
|
+
if (!isWriteCapableTool(evt.tool_name)) return
|
|
184
|
+
|
|
185
|
+
// #1303 primary fix: classify success vs failure FIRST. A successful
|
|
186
|
+
// tool can't have hit a kernel sandbox boundary by definition — its
|
|
187
|
+
// payload may mention EROFS / read-only-fs in benign content but
|
|
188
|
+
// that's not a kernel error.
|
|
189
|
+
const failure = classifyFailure(evt.tool_response)
|
|
190
|
+
if (failure == null) return
|
|
191
|
+
|
|
192
|
+
let body = failure.body
|
|
193
|
+
if (typeof body !== 'string') return
|
|
194
|
+
if (body.length === 0) return
|
|
116
195
|
if (body.length > 64 * 1024) body = body.slice(0, 64 * 1024)
|
|
117
196
|
|
|
118
197
|
for (const [pattern, key] of PATTERNS) {
|
|
@@ -123,6 +202,18 @@ function main() {
|
|
|
123
202
|
}
|
|
124
203
|
}
|
|
125
204
|
|
|
205
|
+
// Test-only export hooks. Node ESM doesn't expose internal symbols
|
|
206
|
+
// without a named export; tests import `__internals` and assert against
|
|
207
|
+
// `classifyFailure` / `isWriteCapableTool` directly. Production paths
|
|
208
|
+
// use `main()` and never touch this object.
|
|
209
|
+
export const __internals = {
|
|
210
|
+
classifyFailure,
|
|
211
|
+
isWriteCapableTool,
|
|
212
|
+
WRITE_CAPABLE_TOOLS,
|
|
213
|
+
PATTERNS,
|
|
214
|
+
buildHint,
|
|
215
|
+
}
|
|
216
|
+
|
|
126
217
|
try {
|
|
127
218
|
main()
|
|
128
219
|
} catch {
|
|
@@ -216,20 +216,21 @@ export interface FormatCardOptions {
|
|
|
216
216
|
slot?: string | null
|
|
217
217
|
/** Anchor for relative-time formatting. Tests pin this; prod omits it. */
|
|
218
218
|
now?: Date
|
|
219
|
+
/**
|
|
220
|
+
* True when the gateway has concurrently fired
|
|
221
|
+
* `fireFleetAutoFallback` for this event. Switches the card body
|
|
222
|
+
* from "What to try" (manual commands) to "Auto-failover in
|
|
223
|
+
* progress" so the user doesn't manually `/auth use` while a
|
|
224
|
+
* fleet swap is mid-flight. Caller MUST pass this when invoking
|
|
225
|
+
* the dispatcher in parallel — otherwise the card lies.
|
|
226
|
+
*/
|
|
227
|
+
autoFallbackInFlight?: boolean
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
/**
|
|
222
231
|
* Render the actionable ⚠️ card for a detected model-unavailable event.
|
|
223
232
|
* HTML-formatted for Telegram. Stable shape so snapshot tests remain
|
|
224
233
|
* meaningful when the suggestion list shifts.
|
|
225
|
-
*
|
|
226
|
-
* ⚠️ <b>Model unavailable</b> on agent <b>name</b>
|
|
227
|
-
* Reason: quota exhausted (resets in 5h)
|
|
228
|
-
*
|
|
229
|
-
* <b>What to try</b>
|
|
230
|
-
* • <code>/authfallback</code> — switch to the next account slot
|
|
231
|
-
* • <code>/auth add</code> — attach another subscription
|
|
232
|
-
* • <code>/usage</code> — show quota breakdown
|
|
233
234
|
*/
|
|
234
235
|
export function formatModelUnavailableCard(
|
|
235
236
|
detection: ModelUnavailableDetection,
|
|
@@ -243,11 +244,26 @@ export function formatModelUnavailableCard(
|
|
|
243
244
|
`⚠️ <b>Model unavailable</b> on agent <b>${escHtml(agent)}</b>${slotPart}`,
|
|
244
245
|
`Reason: ${reason}`,
|
|
245
246
|
'',
|
|
246
|
-
'<b>What to try</b>',
|
|
247
|
-
'• <code>/authfallback</code> — switch to the next account slot',
|
|
248
|
-
'• <code>/auth add</code> — attach another subscription',
|
|
249
|
-
'• <code>/usage</code> — show quota breakdown',
|
|
250
247
|
]
|
|
248
|
+
if (opts.autoFallbackInFlight) {
|
|
249
|
+
// Quiet variant — the gateway already kicked off a fleet-wide
|
|
250
|
+
// swap; a follow-up announcement (causal-shape) will land within
|
|
251
|
+
// ~1s. Mention it explicitly so the user knows not to react.
|
|
252
|
+
lines.push(
|
|
253
|
+
'<i>Auto-failover in progress — see the announcement below.</i>',
|
|
254
|
+
)
|
|
255
|
+
} else {
|
|
256
|
+
// Default — kinds where auto-fallback can't help (network)
|
|
257
|
+
// or pre-Format-2 callers. Also: `/authfallback` is no longer
|
|
258
|
+
// a verb (post-RFC-H); `/auth use <label>` is the canonical
|
|
259
|
+
// fleet-wide swap.
|
|
260
|
+
lines.push(
|
|
261
|
+
'<b>What to try</b>',
|
|
262
|
+
'• <code>/auth use <label></code> — switch the fleet to a healthy account',
|
|
263
|
+
'• <code>/auth add</code> — attach another subscription',
|
|
264
|
+
'• <code>/usage</code> — show quota breakdown',
|
|
265
|
+
)
|
|
266
|
+
}
|
|
251
267
|
return lines.join('\n')
|
|
252
268
|
}
|
|
253
269
|
|