zigmini 0.8.0-beta.7 → 0.9.0-beta.2

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.
Files changed (50) hide show
  1. package/README.ko.md +27 -5
  2. package/README.md +27 -5
  3. package/assets/agent/gateway-request-rules.md +3 -1
  4. package/assets/android/zigmini.apk +0 -0
  5. package/assets/android/zigmini.apk.json +3 -3
  6. package/assets/build.json +1 -1
  7. package/docs/manual.ko.md +57 -4
  8. package/docs/manual.md +55 -4
  9. package/host/omo-bridge/ascii-json.ts +5 -0
  10. package/host/omo-bridge/bridge.ts +50 -44
  11. package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
  12. package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
  13. package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
  14. package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
  15. package/host/omo-check/patches/omo-5.1.48-compaction-port.patch +2629 -0
  16. package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +355 -0
  17. package/host/omo-check/patches/omo-5.1.48-provider-port.patch +440 -0
  18. package/host/omo-check/patches/omo-5.1.48-rpc-port.patch +799 -0
  19. package/host/omo-check/patches/omo-5.1.48-runtime-compat.patch +31 -0
  20. package/package.json +1 -1
  21. package/src/cli/setup.ts +6 -2
  22. package/src/decisions.ts +32 -13
  23. package/src/engine/compat.json +4 -4
  24. package/src/engine/install.ts +68 -20
  25. package/src/engine/rpc-socket.ts +36 -0
  26. package/src/engine/schema-guard.ts +34 -2
  27. package/src/hub.ts +57 -31
  28. package/src/main.ts +22 -7
  29. package/src/notification-history.ts +56 -0
  30. package/src/omo/client.ts +16 -3
  31. package/src/omo/frame-socket.ts +330 -0
  32. package/src/protocol.ts +2 -1
  33. package/src/security-allow.ts +126 -0
  34. package/src/security-check.ts +355 -37
  35. package/src/store.ts +165 -5
  36. package/src/update/handoff-system.ts +22 -0
  37. package/src/update/handoff.ts +10 -2
  38. package/src/update/helper.ts +11 -1
  39. package/src/update/request.ts +15 -2
  40. package/src/work.ts +21 -7
  41. package/web/DESIGN.md +39 -0
  42. package/web/app.js +198 -69
  43. package/web/decisions.d.ts +1 -1
  44. package/web/decisions.js +3 -0
  45. package/web/diagram.d.ts +28 -0
  46. package/web/diagram.js +228 -0
  47. package/web/index.html +13 -0
  48. package/web/security-inbox.d.ts +35 -0
  49. package/web/security-inbox.js +237 -0
  50. package/web/style.css +31 -1
package/src/main.ts CHANGED
@@ -4,7 +4,7 @@ import { join, normalize } from "node:path";
4
4
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
5
5
  import { createSttProvider } from "./adapters/voice/stt.ts";
6
6
  import { createTtsProvider, speakableText, splitForTts } from "./adapters/voice/tts.ts";
7
- import { handleHeld, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
7
+ import { handleHeld, handleSecurityAllowedValues, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
8
8
  import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
9
9
  import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
10
10
  import { applyVoiceAgents } from "./voice/agents.ts";
@@ -150,9 +150,17 @@ let publish: (envelope: Envelope) => void = () => {};
150
150
  const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
151
151
  if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
152
152
  });
153
+ let securityDecisionRefresh = () => {};
154
+ const securityEvent = (type: string, data: unknown) => publish({
155
+ v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type, data,
156
+ });
153
157
  const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
154
158
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
155
- }));
159
+ }), { store, state: (state) => {
160
+ securityEvent("security-check.state", state);
161
+ if (state.status === "sent" || state.status === "cancelled") securityEvent("security-check.resolved", { id: state.id, outcome: state.status === "sent" ? "sent" : "dropped" });
162
+ }, allowedUpdated: (revision) => securityEvent("security-check.allowed-values.updated", { revision }),
163
+ decisionUpdated: () => securityDecisionRefresh() });
156
164
  const hub = new Hub(
157
165
  store,
158
166
  dialogs,
@@ -321,6 +329,7 @@ const decisions = new DecisionService(store, {
321
329
  v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
322
330
  conv: data.decision.ownerConv, type: "decision.updated", data,
323
331
  }), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
332
+ securityDecisionRefresh = () => decisions.refresh();
324
333
  hub.attachDecisions(decisions);
325
334
  const summarizer = new SpeechSummarizer(omo, {
326
335
  sessionDir: config.omoSessionDir,
@@ -476,6 +485,8 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
476
485
  "owner",
477
486
  Array.isArray(msg.data.images) ? msg.data.images.filter((n): n is string => typeof n === "string") : [],
478
487
  msg.data.references,
488
+ false,
489
+ msg.data.submissionId,
479
490
  ),
480
491
  );
481
492
  return;
@@ -652,9 +663,11 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
652
663
  if (response) return response;
653
664
  }
654
665
  if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
655
- const response = await handleHeld(req, url, messageGuard, (id, outcome) => publish({
656
- v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.resolved", data: { id, outcome },
657
- }));
666
+ const response = await handleHeld(req, url, messageGuard, () => {}, { client: trusted ? "web" : "android", deviceId });
667
+ if (response) return response;
668
+ }
669
+ if (url.pathname === SECURITY_ALLOWED_PATH || url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) {
670
+ const response = await handleSecurityAllowedValues(req, url, store, (revision) => securityEvent("security-check.allowed-values.updated", { revision }));
658
671
  if (response) return response;
659
672
  }
660
673
  if (url.pathname === SECURITY_CHECK_PATH) {
@@ -878,8 +891,9 @@ const lanServer = config.lanPort
878
891
  : null;
879
892
 
880
893
  publish = (e) => {
881
- if (!omoCheckNotifications.allow(e)) return;
882
- questionNotifications.onEnvelope(e);
894
+ const sensitiveHeld = e.type === "security-check.held";
895
+ if (!sensitiveHeld && !omoCheckNotifications.allow(e)) return;
896
+ if (!sensitiveHeld) questionNotifications.onEnvelope(e);
883
897
  const text = JSON.stringify(e);
884
898
  const topic = topicOf(e.type);
885
899
  trustedServer.publish(topic, text);
@@ -926,6 +940,7 @@ function shutdown(): void {
926
940
  updateChecker.stop();
927
941
  clearInterval(updateTick);
928
942
  decisions.stop();
943
+ messageGuard.stop();
929
944
  watcher.stop();
930
945
  work.stop();
931
946
  omo.stop();
@@ -0,0 +1,56 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { nativeReplyEntry, type NativeReplyEntry } from "./reply-notifications.ts";
3
+
4
+ /**
5
+ * Read only the owned native JSONL, never the disposable resident blobs or an opened native session.
6
+ * The byte snapshot is finite; memory is bounded by a chunk plus the largest JSONL record, not the history.
7
+ */
8
+ export async function notificationHistory(
9
+ path: string,
10
+ visit: (entry: NativeReplyEntry) => void,
11
+ baseline = false,
12
+ ): Promise<void> {
13
+ let file;
14
+ try { file = await open(path, "r"); }
15
+ catch (error) {
16
+ // Native buffers setup-only sessions until the first user/assistant message.
17
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
18
+ throw error;
19
+ }
20
+ try {
21
+ const snapshot = await file.stat();
22
+ const chunk = Buffer.alloc(64 * 1024);
23
+ const decoder = new TextDecoder("utf-8", { fatal: true });
24
+ let offset = 0;
25
+ let tail = "";
26
+ let header = false;
27
+ while (offset < snapshot.size) {
28
+ const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, snapshot.size - offset), offset);
29
+ if (bytesRead === 0) throw new Error("reply_history_truncated");
30
+ offset += bytesRead;
31
+ tail += decoder.decode(chunk.subarray(0, bytesRead), { stream: true });
32
+ let newline: number;
33
+ while ((newline = tail.indexOf("\n")) !== -1) {
34
+ const line = tail.slice(0, newline);
35
+ tail = tail.slice(newline + 1);
36
+ const value: unknown = JSON.parse(line);
37
+ const entry = nativeReplyEntry(value);
38
+ if (!entry) throw new Error("reply_history_invalid_entry");
39
+ if (!header) {
40
+ if (entry.type !== "session") throw new Error("reply_history_invalid_header");
41
+ header = true;
42
+ } else {
43
+ if (entry.type === "session") throw new Error("reply_history_duplicate_header");
44
+ visit(baseline ? { id: entry.id, type: entry.type } : entry);
45
+ }
46
+ }
47
+ }
48
+ tail += decoder.decode();
49
+ if (!header || tail.length !== 0) throw new Error("reply_history_incomplete");
50
+ const current = await stat(path);
51
+ if (current.dev !== snapshot.dev || current.ino !== snapshot.ino || current.size < snapshot.size ||
52
+ (current.size === snapshot.size && current.mtimeMs !== snapshot.mtimeMs)) throw new Error("reply_history_replaced");
53
+ } finally {
54
+ await file.close();
55
+ }
56
+ }
package/src/omo/client.ts CHANGED
@@ -1,9 +1,20 @@
1
+ import { FrameSocket } from "./frame-socket.ts";
1
2
  import { NativeLifecycleLog, type NativeLifecycleRecord } from "./lifecycle.ts";
2
3
  import { RecoveryReceiptLog } from "./recovery-receipt.ts";
3
4
  import type { RecoveryOpenContext, RecoveryReceiptRecord } from "./recovery-receipt-types.ts";
4
5
 
5
6
  export type OmoStatus = "connecting" | "ready" | "disconnected";
6
7
 
8
+ interface OmoSocket {
9
+ readonly readyState: number;
10
+ onopen: (() => void) | null;
11
+ onmessage: ((event: { readonly data: string }) => void) | null;
12
+ onclose: ((event: { readonly code: number; readonly reason: string; readonly wasClean: boolean }) => void) | null;
13
+ onerror: ((event: { readonly message?: string }) => void) | null;
14
+ send(text: string): void;
15
+ close(): void;
16
+ }
17
+
7
18
  export interface OmoEvent {
8
19
  type: string;
9
20
  sessionId?: string;
@@ -79,7 +90,7 @@ type Pending = {
79
90
  const STABLE_READY_MS = 10_000;
80
91
 
81
92
  export class OmoClient {
82
- private ws: WebSocket | null = null;
93
+ private ws: OmoSocket | null = null;
83
94
  private nextId = 1;
84
95
  private readonly instanceId = crypto.randomUUID();
85
96
  private readonly pending = new Map<string, Pending>();
@@ -157,10 +168,12 @@ export class OmoClient {
157
168
  this.setStatus("connecting");
158
169
  this.recovery?.connecting();
159
170
  if (this.stopped) return;
160
- const ws = new WebSocket(this.url, { headers: {
171
+ const headers: Record<string, string> = {
161
172
  Authorization: `Bearer ${this.token}`,
162
173
  ...(this.connectionId ? { "x-zgw-connection-id": this.connectionId } : {}),
163
- } } as unknown as string[]);
174
+ };
175
+ const ws: OmoSocket = this.url.startsWith("ws://") ? new FrameSocket(this.url, headers)
176
+ : new WebSocket(this.url, { headers } as unknown as string[]) as unknown as OmoSocket;
164
177
  this.ws = ws;
165
178
  ws.onopen = () => {
166
179
  let continuityLoss: string | undefined;
@@ -0,0 +1,330 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { Socket } from "bun";
3
+
4
+ // Bug 221: Bun's WebSocket client misreads valid bridge frames in long sessions (1002/1011 closes on a byte stream
5
+ // that parses cleanly). This client speaks RFC 6455 over plain TCP for ws:// bridge URLs, so frame parsing is ours.
6
+ export const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
7
+ export const MAX_MESSAGE_BYTES = 64 * 1024 * 1024;
8
+ const CLOSE_WAIT_MS = 1_000;
9
+ export const HANDSHAKE_LIMIT = 16_384;
10
+
11
+ export type FrameSocketState = 0 | 1 | 2 | 3;
12
+ export interface FrameSocketClose { readonly code: number; readonly reason: string; readonly wasClean: boolean }
13
+
14
+ export function acceptFor(key: string): string {
15
+ return createHash("sha1").update(`${key}${WS_GUID}`).digest("base64");
16
+ }
17
+
18
+ export function encodeFrame(opcode: number, payload: Uint8Array, mask: Uint8Array): Uint8Array {
19
+ if (mask.length !== 4) throw new Error("frame_mask_length");
20
+ const length = payload.length;
21
+ const extra = length < 126 ? 0 : length <= 0xffff ? 2 : 8;
22
+ const frame = new Uint8Array(2 + extra + 4 + length);
23
+ frame[0] = 0x80 | opcode;
24
+ if (extra === 0) frame[1] = 0x80 | length;
25
+ else if (extra === 2) {
26
+ frame[1] = 0x80 | 126;
27
+ frame[2] = length >>> 8;
28
+ frame[3] = length & 0xff;
29
+ } else {
30
+ frame[1] = 0x80 | 127;
31
+ new DataView(frame.buffer).setBigUint64(2, BigInt(length));
32
+ }
33
+ const at = 2 + extra;
34
+ frame.set(mask, at);
35
+ payload.forEach((byte, i) => { frame[at + 4 + i] = byte ^ mask[i & 3]!; });
36
+ return frame;
37
+ }
38
+
39
+ export type HandshakeHead =
40
+ | { readonly kind: "need" }
41
+ | { readonly kind: "too_large" }
42
+ | { readonly kind: "head"; readonly head: string; readonly rest: string };
43
+
44
+ export function handshakeHead(text: string): HandshakeHead {
45
+ const end = text.indexOf("\r\n\r\n");
46
+ if (end === -1) return text.length > HANDSHAKE_LIMIT ? { kind: "too_large" } : { kind: "need" };
47
+ if (end > HANDSHAKE_LIMIT) return { kind: "too_large" };
48
+ return { kind: "head", head: text.slice(0, end), rest: text.slice(end + 4) };
49
+ }
50
+
51
+ export interface WriteSink { write(bytes: Uint8Array): number }
52
+
53
+ export class WriteQueue {
54
+ private readonly pending: Uint8Array[] = [];
55
+
56
+ get size(): number {
57
+ return this.pending.length;
58
+ }
59
+
60
+ write(sink: WriteSink, bytes: Uint8Array): void {
61
+ if (this.pending.length > 0) {
62
+ this.pending.push(bytes);
63
+ return;
64
+ }
65
+ const written = sink.write(bytes);
66
+ if (written < bytes.length) this.pending.push(bytes.subarray(Math.max(0, written)));
67
+ }
68
+
69
+ flush(sink: WriteSink): void {
70
+ while (this.pending.length > 0) {
71
+ const head = this.pending[0]!;
72
+ const written = sink.write(head);
73
+ if (written < head.length) {
74
+ this.pending[0] = head.subarray(Math.max(0, written));
75
+ return;
76
+ }
77
+ this.pending.shift();
78
+ }
79
+ }
80
+ }
81
+
82
+ export interface ParsedFrame { readonly fin: boolean; readonly opcode: number; readonly payload: Uint8Array }
83
+ export type ParseResult =
84
+ | { readonly kind: "frame"; readonly frame: ParsedFrame; readonly used: number }
85
+ | { readonly kind: "need"; readonly bytes: number }
86
+ | { readonly kind: "error"; readonly code: number; readonly reason: string };
87
+
88
+ export function parseFrame(buffer: Uint8Array, maxBytes = MAX_MESSAGE_BYTES): ParseResult {
89
+ if (buffer.length < 2) return { kind: "need", bytes: 2 };
90
+ const first = buffer[0]!;
91
+ const second = buffer[1]!;
92
+ if ((first & 0x70) !== 0) return { kind: "error", code: 1002, reason: "reserved_bits_set" };
93
+ if ((second & 0x80) !== 0) return { kind: "error", code: 1002, reason: "masked_server_frame" };
94
+ const fin = (first & 0x80) !== 0;
95
+ const opcode = first & 0x0f;
96
+ if (![0, 1, 2, 8, 9, 10].includes(opcode)) return { kind: "error", code: 1002, reason: "unknown_opcode" };
97
+ const control = opcode >= 8;
98
+ let length = second & 0x7f;
99
+ let header = 2;
100
+ if (length === 126) {
101
+ if (buffer.length < 4) return { kind: "need", bytes: 4 };
102
+ length = (buffer[2]! << 8) | buffer[3]!;
103
+ header = 4;
104
+ } else if (length === 127) {
105
+ if (buffer.length < 10) return { kind: "need", bytes: 10 };
106
+ length = Number(new DataView(buffer.buffer, buffer.byteOffset + 2, 8).getBigUint64(0));
107
+ header = 10;
108
+ }
109
+ if (control && (!fin || length > 125)) return { kind: "error", code: 1002, reason: "invalid_control_frame" };
110
+ if (length > maxBytes) return { kind: "error", code: 1009, reason: "message_too_big" };
111
+ if (buffer.length < header + length) return { kind: "need", bytes: header + length };
112
+ return { kind: "frame", frame: { fin, opcode, payload: buffer.subarray(header, header + length) }, used: header + length };
113
+ }
114
+
115
+ export class FrameSocket {
116
+ static readonly OPEN = 1;
117
+ readyState: FrameSocketState = 0;
118
+ onopen: (() => void) | null = null;
119
+ onmessage: ((event: { readonly data: string }) => void) | null = null;
120
+ onclose: ((event: FrameSocketClose) => void) | null = null;
121
+ onerror: ((event: { readonly message?: string }) => void) | null = null;
122
+
123
+ private socket: Socket<undefined> | null = null;
124
+ private readonly key = randomBytes(16).toString("base64");
125
+ private handshake = "";
126
+ private input: Uint8Array[] = [];
127
+ private inputBytes = 0;
128
+ private inputNeeded = 2;
129
+ private readonly output = new WriteQueue();
130
+ private fragments: Uint8Array[] = [];
131
+ private fragmentBytes = 0;
132
+ private fragmentOpcode = 0;
133
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
134
+ private closed = false;
135
+ private readonly decoder = new TextDecoder();
136
+
137
+ constructor(url: string, private readonly headers: Readonly<Record<string, string>> = {}, private readonly maxBytes = MAX_MESSAGE_BYTES) {
138
+ const target = new URL(url);
139
+ if (target.protocol !== "ws:") throw new Error(`frame_socket_unsupported_url:${target.protocol}`);
140
+ for (const [name, value] of Object.entries(headers)) {
141
+ if (/[\r\n:]/.test(name) || /[\r\n]/.test(value)) throw new Error("frame_socket_invalid_header");
142
+ }
143
+ const port = Number(target.port || 80);
144
+ const path = `${target.pathname || "/"}${target.search}`;
145
+ const host = target.port ? `${target.hostname}:${target.port}` : target.hostname;
146
+ Bun.connect({
147
+ hostname: target.hostname.replace(/^\[|\]$/g, ""),
148
+ port,
149
+ socket: {
150
+ open: (socket) => {
151
+ this.socket = socket;
152
+ if (this.closed) {
153
+ socket.end();
154
+ return;
155
+ }
156
+ const lines = [`GET ${path} HTTP/1.1`, `Host: ${host}`, "Connection: Upgrade", "Upgrade: websocket",
157
+ "Sec-WebSocket-Version: 13", `Sec-WebSocket-Key: ${this.key}`,
158
+ ...Object.entries(this.headers).map(([name, value]) => `${name}: ${value}`)];
159
+ this.write(new TextEncoder().encode(`${lines.join("\r\n")}\r\n\r\n`));
160
+ },
161
+ data: (_socket, chunk) => this.receive(chunk),
162
+ drain: () => this.flush(),
163
+ close: () => this.finish(1006, "", false),
164
+ error: (_socket, error) => this.fail(1006, error.message),
165
+ connectError: (_socket, error) => this.fail(1006, error.message),
166
+ },
167
+ }).catch((error: Error) => this.fail(1006, error.message));
168
+ }
169
+
170
+ send(text: string): void {
171
+ if (this.readyState !== FrameSocket.OPEN) throw new Error("frame_socket_not_open");
172
+ this.write(encodeFrame(1, new TextEncoder().encode(text), randomBytes(4)));
173
+ }
174
+
175
+ close(code = 1000, reason = ""): void {
176
+ if (this.readyState >= 2) return;
177
+ if (this.readyState === 0) {
178
+ this.finish(1006, reason, false);
179
+ return;
180
+ }
181
+ this.readyState = 2;
182
+ this.sendClose(code, reason);
183
+ this.closeTimer = setTimeout(() => this.finish(1006, reason, false), CLOSE_WAIT_MS);
184
+ }
185
+
186
+ private sendClose(code: number, reason: string): void {
187
+ const text = new TextEncoder().encode(reason);
188
+ const payload = new Uint8Array(2 + text.length);
189
+ payload[0] = code >>> 8;
190
+ payload[1] = code & 0xff;
191
+ payload.set(text, 2);
192
+ this.write(encodeFrame(8, payload, randomBytes(4)));
193
+ }
194
+
195
+ private write(bytes: Uint8Array): void {
196
+ if (this.socket) this.output.write(this.socket, bytes);
197
+ }
198
+
199
+ private flush(): void {
200
+ if (this.socket) this.output.flush(this.socket);
201
+ }
202
+
203
+ private receive(chunk: Uint8Array): void {
204
+ if (this.closed) return;
205
+ if (this.readyState === 0) {
206
+ this.handshake += Buffer.from(chunk).toString("latin1");
207
+ const parsed = handshakeHead(this.handshake);
208
+ if (parsed.kind === "need") return;
209
+ if (parsed.kind === "too_large") {
210
+ this.fail(1006, "handshake_too_large");
211
+ return;
212
+ }
213
+ this.handshake = "";
214
+ const rest = Buffer.from(parsed.rest, "latin1");
215
+ const [status, ...fields] = parsed.head.split("\r\n");
216
+ const accept = fields.find((line) => /^sec-websocket-accept:/i.test(line))?.split(":")[1]?.trim();
217
+ if (!/^HTTP\/1\.1 101 /.test(status ?? "")) {
218
+ this.fail(1006, `handshake_status:${status ?? ""}`);
219
+ return;
220
+ }
221
+ if (accept !== acceptFor(this.key)) {
222
+ this.fail(1006, "handshake_accept_mismatch");
223
+ return;
224
+ }
225
+ this.readyState = 1;
226
+ this.dispatch(() => this.onopen?.());
227
+ this.receive(rest);
228
+ return;
229
+ }
230
+ this.input.push(chunk.slice());
231
+ this.inputBytes += chunk.length;
232
+ if (this.inputBytes < this.inputNeeded) return;
233
+ const merged = new Uint8Array(this.inputBytes);
234
+ let at = 0;
235
+ for (const part of this.input) {
236
+ merged.set(part, at);
237
+ at += part.length;
238
+ }
239
+ let offset = 0;
240
+ while (!this.closed) {
241
+ const parsed = parseFrame(merged.subarray(offset), this.maxBytes);
242
+ if (parsed.kind === "need") {
243
+ this.inputNeeded = parsed.bytes;
244
+ break;
245
+ }
246
+ if (parsed.kind === "error") {
247
+ this.protocolError(parsed.code, parsed.reason);
248
+ return;
249
+ }
250
+ offset += parsed.used;
251
+ this.handle(parsed.frame);
252
+ }
253
+ const rest = merged.slice(offset);
254
+ this.input = [rest];
255
+ this.inputBytes = rest.length;
256
+ }
257
+
258
+ private handle(frame: ParsedFrame): void {
259
+ const { opcode, payload, fin } = frame;
260
+ if (opcode === 9) {
261
+ if (this.readyState === 1) this.write(encodeFrame(10, payload, randomBytes(4)));
262
+ return;
263
+ }
264
+ if (opcode === 10) return;
265
+ if (opcode === 8) {
266
+ const code = payload.length >= 2 ? (payload[0]! << 8) | payload[1]! : 1005;
267
+ const reason = this.decoder.decode(payload.subarray(2));
268
+ if (this.readyState === 1) this.sendClose(code === 1005 ? 1000 : code, "");
269
+ this.finish(code, reason, true);
270
+ return;
271
+ }
272
+ if (opcode === 0 ? this.fragmentOpcode === 0 : this.fragmentOpcode !== 0) {
273
+ this.protocolError(1002, "unexpected_continuation");
274
+ return;
275
+ }
276
+ if (opcode !== 0) this.fragmentOpcode = opcode;
277
+ this.fragmentBytes += payload.length;
278
+ if (this.fragmentBytes > this.maxBytes) {
279
+ this.protocolError(1009, "message_too_big");
280
+ return;
281
+ }
282
+ this.fragments.push(payload.slice());
283
+ if (!fin) return;
284
+ const whole = new Uint8Array(this.fragmentBytes);
285
+ let at = 0;
286
+ for (const part of this.fragments) {
287
+ whole.set(part, at);
288
+ at += part.length;
289
+ }
290
+ this.fragments = [];
291
+ this.fragmentBytes = 0;
292
+ this.fragmentOpcode = 0;
293
+ if (this.readyState === 1) {
294
+ const data = this.decoder.decode(whole);
295
+ this.dispatch(() => this.onmessage?.({ data }));
296
+ }
297
+ }
298
+
299
+ private protocolError(code: number, reason: string): void {
300
+ if (this.readyState === 1) this.sendClose(code, reason);
301
+ this.dispatch(() => this.onerror?.({ message: `frame_socket_protocol_error:${reason}` }));
302
+ this.finish(code, reason, false);
303
+ }
304
+
305
+ private dispatch(handler: () => void): void {
306
+ try {
307
+ handler();
308
+ } catch (error) {
309
+ queueMicrotask(() => {
310
+ throw error;
311
+ });
312
+ }
313
+ }
314
+
315
+ private fail(code: number, message: string): void {
316
+ if (this.closed) return;
317
+ this.dispatch(() => this.onerror?.({ message }));
318
+ this.finish(code, message, false);
319
+ }
320
+
321
+ private finish(code: number, reason: string, wasClean: boolean): void {
322
+ if (this.closed) return;
323
+ this.closed = true;
324
+ this.readyState = 3;
325
+ if (this.closeTimer) clearTimeout(this.closeTimer);
326
+ this.flush();
327
+ this.socket?.end();
328
+ this.dispatch(() => this.onclose?.({ code, reason, wasClean }));
329
+ }
330
+ }
package/src/protocol.ts CHANGED
@@ -85,6 +85,7 @@ export type DecisionAnswerRequest = { readonly expectedRevision: number; readonl
85
85
 
86
86
  /** HTTP/event DTOs are separate from storage records; payload retains the existing action-card structure. */
87
87
  export interface DecisionInfo {
88
+ readonly security?: import("./security-allow.ts").HeldState;
88
89
  readonly id: string;
89
90
  readonly revision: number;
90
91
  readonly ownerConv: string;
@@ -151,7 +152,7 @@ export type ClientMessage =
151
152
  | { type: "client.presence"; data: { visible: boolean } }
152
153
  | { type: "history.get"; conv: string; before?: number; after?: number; limit?: number }
153
154
  | { type: "conversation.create"; data: { title: string } }
154
- | { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[] } }
155
+ | { type: "input.text"; conv: string; data: { text: string; images?: string[]; references?: number[]; submissionId?: string } }
155
156
  | { type: "input.action"; conv: string; data: ActionAnswerData }
156
157
  | { type: "input.interrupt"; conv: string; data: Record<string, never> }
157
158
  | { type: "conversation.rotate"; conv: string }
@@ -0,0 +1,126 @@
1
+ /** Runtime-only contract. Repository and release scanners never import this module. */
2
+ export type HeldStatus = "pending" | "processing" | "sent" | "cancelled" | "failed" | "unconfirmed";
3
+ export type SecurityErrorCode = "allowlist_persist_failed" | "state_persist_failed" | "payload_lost" | "dispatch_failed" | "dispatch_unknown" | "original_decision_conflict";
4
+ export interface HeldState {
5
+ readonly id: string;
6
+ readonly decisionId: string;
7
+ readonly revision: number;
8
+ readonly conv: string;
9
+ readonly source: "message" | "answer";
10
+ readonly createdAt: string;
11
+ readonly originalDecisionId: string | null;
12
+ readonly status: HeldStatus;
13
+ readonly submissionId: string | null;
14
+ readonly remembered: boolean;
15
+ readonly allowlistRevision: number;
16
+ readonly errorCode: SecurityErrorCode | null;
17
+ }
18
+ export interface SecurityApprover {
19
+ readonly client: "web" | "android";
20
+ readonly deviceId: string | null;
21
+ }
22
+ export interface AllowedValue {
23
+ readonly id: string;
24
+ readonly value: string;
25
+ readonly kind: string;
26
+ readonly label: string;
27
+ readonly approvedAt: string;
28
+ readonly approvedBy: SecurityApprover;
29
+ readonly heldId: string;
30
+ }
31
+ export interface SecurityIntent {
32
+ readonly send: boolean;
33
+ readonly remember: boolean;
34
+ readonly expectedRevision: number | null;
35
+ readonly submissionId: string | null;
36
+ }
37
+ export type SecurityReceipt =
38
+ | { readonly kind: "message"; readonly conv: string; readonly messageSeq: number }
39
+ | { readonly kind: "answer"; readonly decisionId: string; readonly submissionId: string };
40
+ export interface SecurityHoldRecord {
41
+ readonly state: HeldState;
42
+ readonly key: string | null;
43
+ readonly intent: SecurityIntent | null;
44
+ readonly dispatchReserved: boolean;
45
+ readonly receipt: SecurityReceipt | null;
46
+ }
47
+ export class SecurityRuntimeError extends Error {
48
+ constructor(
49
+ readonly code: "held_conflict" | "allowlist_conflict" | SecurityErrorCode,
50
+ readonly held?: HeldState,
51
+ readonly revision?: number,
52
+ ) { super(code); }
53
+ }
54
+ export const securityDecision = (decision: { readonly payload: unknown }): boolean =>
55
+ typeof decision.payload === "object" && decision.payload !== null &&
56
+ "kind" in decision.payload && decision.payload.kind === "security-check";
57
+
58
+ /** A delimiter is meaningful only if its matching quote can be proved. No decoding or normalization. */
59
+ function quotedEnd(text: string, start: number, quote: string): number | null {
60
+ for (let i = start; i < text.length; i++) {
61
+ if (text[i] === "\\") { i++; continue; }
62
+ if (text[i] === quote) return i;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** Expand an existing detector hit; this function never decides whether a hit exists. */
68
+ export function canonicalSecurityValue(text: string, rule: string, start: number, end: number): string {
69
+ const lineStart = text.lastIndexOf("\n", start - 1) + 1;
70
+ const nextLine = text.indexOf("\n", end);
71
+ const lineEnd = nextLine < 0 ? text.length : nextLine;
72
+ // A PEM or SSH record owns every nested hit, including an address in its comment.
73
+ for (const match of text.matchAll(/-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----/g)) {
74
+ const begin = match.index;
75
+ const closing = `-----END ${match[1]}-----`;
76
+ const at = text.indexOf(closing, begin + match[0].length);
77
+ const finish = at < 0 ? text.length : at + closing.length;
78
+ if (start >= begin && start < finish) return text.slice(begin, finish);
79
+ }
80
+ const line = text.slice(lineStart, lineEnd).replace(/\r$/, "");
81
+ if (/(?:ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-[A-Za-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)[ \t]+[A-Za-z0-9+/]{20,}/.test(line)) return line;
82
+ // Locate quoted units and keep their original escape bytes.
83
+ let enclosing: { start: number; end: number | null } | undefined;
84
+ for (let i = lineStart; i < start; i++) {
85
+ const ch = text[i];
86
+ if (ch !== "'" && ch !== '"' && ch !== "`") continue;
87
+ const finish = quotedEnd(text, i + 1, ch);
88
+ if (finish === null || finish >= end) { enclosing = { start: i + 1, end: finish }; break; }
89
+ i = finish;
90
+ }
91
+ if (enclosing?.end === null) return text.slice(enclosing.start);
92
+ const header = /\b(?:authorization["']?\s*[:=]\s*["']?(?:Bearer|Basic)\s+|(?:set-cookie|cookie)["']?\s*[:=]\s*["']?)/i.exec(line);
93
+ if (header) {
94
+ const valueStart = lineStart + header.index + header[0].length;
95
+ if (start >= valueStart) return text.slice(valueStart, enclosing?.end ?? lineEnd).replace(/\r$/, "");
96
+ }
97
+ if (enclosing && enclosing.end !== null && /[:=]\s*$/.test(text.slice(lineStart, enclosing.start - 1))) {
98
+ return text.slice(enclosing.start, enclosing.end);
99
+ }
100
+ // URLs take precedence over addresses, token fragments and assignment captures inside them.
101
+ for (const match of text.matchAll(/[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]+/g)) {
102
+ if (start >= match.index && start < match.index + match[0].length) {
103
+ if (enclosing && enclosing.end !== null) return text.slice(match.index, enclosing.end);
104
+ return match[0];
105
+ }
106
+ }
107
+ // A path owns nested host/token captures. Quoted paths may contain spaces.
108
+ for (const match of text.matchAll(/\/(?:Users|home)\/[^\s"'`<>]*/g)) {
109
+ const finish = enclosing && enclosing.end !== null && match.index >= enclosing.start ?
110
+ enclosing.end : match.index + match[0].length;
111
+ if (start >= match.index && start < finish) return text.slice(match.index, finish);
112
+ }
113
+ if (["high-entropy-assignment", "credential-assignment", "device-serial", "authorization", "cookie-header"].includes(rule)) {
114
+ if (enclosing && enclosing.end !== null) return text.slice(start, enclosing.end);
115
+ if (rule === "authorization" || rule === "cookie-header") return text.slice(start, lineEnd).replace(/\r$/, "");
116
+ let finish = end;
117
+ while (finish < text.length && !/[\s,;}]/.test(text[finish] ?? "")) finish++;
118
+ return text.slice(start, finish);
119
+ }
120
+ if (["github-token", "npm-token", "anthropic-key", "openai-key", "notion-token", "slack-token", "aws-access-key", "gcp-api-key", "tailscale-key", "jwt"].includes(rule)) {
121
+ let finish = end;
122
+ while (finish < text.length && /[A-Za-z0-9_+/.=-]/.test(text[finish] ?? "")) finish++;
123
+ return text.slice(start, finish);
124
+ }
125
+ return text.slice(start, end);
126
+ }