zigmini 0.8.0-beta.6 → 0.9.0-beta.1
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.ko.md +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +69 -1
- package/docs/manual.md +67 -1
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
package/src/main.ts
CHANGED
|
@@ -4,6 +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, handleSecurityAllowedValues, handleSecurityCheckSetting, MessageGuard, SECURITY_CHECK_PATH, SECURITY_HELD_PATH, SECURITY_ALLOWED_PATH, SecurityCheckError, defaultChecker } from "./security-check.ts";
|
|
7
8
|
import { handleVoiceInstall, handleVoiceSetting, routeVoiceApi, VOICE_INSTALL_PATH, VOICE_SETTING_PATH, voiceGate, voiceUpdated } from "./voice-api.ts";
|
|
8
9
|
import { readVoiceSetting, VoiceSetting } from "./voice-setting.ts";
|
|
9
10
|
import { applyVoiceAgents } from "./voice/agents.ts";
|
|
@@ -46,6 +47,7 @@ import { RECOVERY_RECEIPT_PREFIX } from "./omo/recovery-receipt.ts";
|
|
|
46
47
|
import { fileScheduleSource, notionRoutineSource, ReminderLoop } from "./schedule/reminders.ts";
|
|
47
48
|
import { type ClientMessage, type Envelope, PROTOCOL_VERSION, ROTATION_HELD_MESSAGE, type Source } from "./protocol.ts";
|
|
48
49
|
import { QuestionNotifications } from "./question-notifications.ts";
|
|
50
|
+
import { NotificationSettingsApi, ReplyNotifications } from "./reply-notifications.ts";
|
|
49
51
|
import { DecisionStoreError, Store } from "./store.ts";
|
|
50
52
|
import { TranscriptLog } from "./transcript.ts";
|
|
51
53
|
import { WorkRouter } from "./work.ts";
|
|
@@ -145,11 +147,26 @@ const updateApi: UpdateApiDeps = {
|
|
|
145
147
|
};
|
|
146
148
|
|
|
147
149
|
let publish: (envelope: Envelope) => void = () => {};
|
|
150
|
+
const replyNotifications = new ReplyNotifications<ServerWebSocket<Conn>>(store, (ws, notification) => {
|
|
151
|
+
if (ws.data.deviceId && store.getDevice(ws.data.deviceId)?.status === "approved") ws.send(envelope("reply.notification", notification, notification.conv));
|
|
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
|
+
});
|
|
157
|
+
const messageGuard = new MessageGuard(defaultChecker(), runtimePaths().securityCheckFile, (held) => publish({
|
|
158
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: held.conv, type: "security-check.held", data: held,
|
|
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() });
|
|
148
164
|
const hub = new Hub(
|
|
149
165
|
store,
|
|
150
166
|
dialogs,
|
|
151
167
|
stt,
|
|
152
168
|
{
|
|
169
|
+
replies: replyNotifications,
|
|
153
170
|
cwd: config.omoCwd,
|
|
154
171
|
sessionDir: config.omoSessionDir,
|
|
155
172
|
permissionPreset: config.omoPermissionPreset,
|
|
@@ -164,6 +181,7 @@ const hub = new Hub(
|
|
|
164
181
|
build: process.env.ZGW_BUILD_SHA,
|
|
165
182
|
}),
|
|
166
183
|
usageReport: createUsageReport({ dir: agentDir(), inContainer: existsSync("/.dockerenv") }),
|
|
184
|
+
guard: messageGuard,
|
|
167
185
|
},
|
|
168
186
|
(e) => publish(e),
|
|
169
187
|
);
|
|
@@ -205,7 +223,7 @@ const omo = new GenerationRouter(startup.endpoints, startup.active, {
|
|
|
205
223
|
onEvent: (e) => {
|
|
206
224
|
if (work.onEvent(e)) return;
|
|
207
225
|
summarizer.onEvent(e, hub.ownsSession(e.sessionId));
|
|
208
|
-
hub.onEvent(e);
|
|
226
|
+
void Promise.resolve(hub.onEvent(e)).catch((error) => console.error("native event:", error));
|
|
209
227
|
},
|
|
210
228
|
onStatus: (s, d) => {
|
|
211
229
|
const generation = ++connectionGeneration;
|
|
@@ -249,10 +267,12 @@ function releaseRecovery(): void {
|
|
|
249
267
|
})().finally(() => { releaseRunning = null; });
|
|
250
268
|
}
|
|
251
269
|
const work = new WorkRouter(store, omo, {
|
|
270
|
+
replies: replyNotifications,
|
|
252
271
|
sessionDir: config.omoSessionDir,
|
|
253
272
|
permissionPreset: config.omoPermissionPreset,
|
|
254
273
|
onPublished(event) {
|
|
255
274
|
hub.publishStored(event);
|
|
275
|
+
replyNotifications.deliver();
|
|
256
276
|
// A published work report raises that work's `reported` in the work status.
|
|
257
277
|
workStatus.schedule();
|
|
258
278
|
},
|
|
@@ -285,6 +305,12 @@ const work = new WorkRouter(store, omo, {
|
|
|
285
305
|
else hub.handleScopedUiRequest(owner, event);
|
|
286
306
|
},
|
|
287
307
|
});
|
|
308
|
+
const notificationSettingsApi = new NotificationSettingsApi(replyNotifications, async () => {
|
|
309
|
+
await hub.notificationBaseline();
|
|
310
|
+
await work.notificationBaseline();
|
|
311
|
+
}, (data) => publish({
|
|
312
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "notifications.updated", data,
|
|
313
|
+
}));
|
|
288
314
|
hub.attachOmo(omo);
|
|
289
315
|
const omoCheck = new OmoCheckScheduler(store, {
|
|
290
316
|
cwd: process.env.ZGW_OMO_CHECK_CWD ?? "",
|
|
@@ -302,7 +328,8 @@ const decisions = new DecisionService(store, {
|
|
|
302
328
|
}, (data) => publish({
|
|
303
329
|
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(),
|
|
304
330
|
conv: data.decision.ownerConv, type: "decision.updated", data,
|
|
305
|
-
}), { onHistory: (event) => hub.publishStored(event) });
|
|
331
|
+
}), { onHistory: (event) => hub.publishStored(event), guard: messageGuard });
|
|
332
|
+
securityDecisionRefresh = () => decisions.refresh();
|
|
306
333
|
hub.attachDecisions(decisions);
|
|
307
334
|
const summarizer = new SpeechSummarizer(omo, {
|
|
308
335
|
sessionDir: config.omoSessionDir,
|
|
@@ -373,6 +400,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
373
400
|
ws.data.notifyOnly &&
|
|
374
401
|
msg.type !== "hello" &&
|
|
375
402
|
msg.type !== "reminder.ack" &&
|
|
403
|
+
msg.type !== "reply.notification.ack" &&
|
|
376
404
|
msg.type !== "question.notification.ack"
|
|
377
405
|
) return;
|
|
378
406
|
if (
|
|
@@ -403,6 +431,7 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
403
431
|
if (state.omo === "ready") questionNotifications.sync(snapshot.approvals);
|
|
404
432
|
if (ws.data.notifyOnly) {
|
|
405
433
|
if (ws.data.deviceId) questionNotifications.connectDevice(ws, ws.data.deviceId);
|
|
434
|
+
if (ws.data.deviceId) replyNotifications.connectDevice(ws, ws.data.deviceId);
|
|
406
435
|
for (const r of reminders.pending(Date.now())) ws.send(envelope("reminder", r));
|
|
407
436
|
return;
|
|
408
437
|
}
|
|
@@ -423,6 +452,10 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
423
452
|
case "reminder.ack":
|
|
424
453
|
if (typeof msg.data?.id === "string") reminders.ack(msg.data.id, Date.now());
|
|
425
454
|
return;
|
|
455
|
+
case "reply.notification.ack":
|
|
456
|
+
if (ws.data.notifyOnly && ws.data.deviceId && typeof msg.data?.id === "string" &&
|
|
457
|
+
store.getDevice(ws.data.deviceId)?.status === "approved") replyNotifications.ack(ws, msg.data.id);
|
|
458
|
+
return;
|
|
426
459
|
case "question.notification.ack": {
|
|
427
460
|
const id = ws.data.deviceId;
|
|
428
461
|
if (
|
|
@@ -452,6 +485,8 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
452
485
|
"owner",
|
|
453
486
|
Array.isArray(msg.data.images) ? msg.data.images.filter((n): n is string => typeof n === "string") : [],
|
|
454
487
|
msg.data.references,
|
|
488
|
+
false,
|
|
489
|
+
msg.data.submissionId,
|
|
455
490
|
),
|
|
456
491
|
);
|
|
457
492
|
return;
|
|
@@ -461,6 +496,9 @@ async function handleClient(ws: ServerWebSocket<Conn>, raw: string): Promise<voi
|
|
|
461
496
|
ws.send(envelope("error", { message: "approval_not_pending", actionId: msg.data.actionId }, msg.conv));
|
|
462
497
|
}
|
|
463
498
|
} catch (error) {
|
|
499
|
+
if (error instanceof SecurityCheckError) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
464
502
|
if (!(error instanceof DecisionStoreError)) throw error;
|
|
465
503
|
ws.send(envelope("error", {
|
|
466
504
|
message: error.code, actionId: msg.data.actionId,
|
|
@@ -610,6 +648,7 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
610
648
|
return srv.upgrade(req, { data: { trusted, deviceId, notifyOnly, webClient: false } }) ? undefined : new Response("upgrade failed", { status: 400 });
|
|
611
649
|
}
|
|
612
650
|
// Voice off answers 503 before native admission and before any body is read.
|
|
651
|
+
if (url.pathname === "/api/settings/notifications") return notificationSettingsApi.handle(req);
|
|
613
652
|
const voiceDisabled = voiceGate(req, url, voiceSetting);
|
|
614
653
|
if (voiceDisabled) return voiceDisabled;
|
|
615
654
|
if (url.pathname === VOICE_INSTALL_PATH) {
|
|
@@ -623,6 +662,20 @@ async function handle(req: Request, srv: Server<Conn>, trusted: boolean): Promis
|
|
|
623
662
|
});
|
|
624
663
|
if (response) return response;
|
|
625
664
|
}
|
|
665
|
+
if (url.pathname === SECURITY_HELD_PATH || url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) {
|
|
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 }));
|
|
671
|
+
if (response) return response;
|
|
672
|
+
}
|
|
673
|
+
if (url.pathname === SECURITY_CHECK_PATH) {
|
|
674
|
+
const response = await handleSecurityCheckSetting(req, trusted, messageGuard, (enabled) => publish({
|
|
675
|
+
v: PROTOCOL_VERSION, id: crypto.randomUUID(), ts: new Date().toISOString(), conv: "*", type: "security-check.updated", data: { enabled },
|
|
676
|
+
}));
|
|
677
|
+
if (response) return response;
|
|
678
|
+
}
|
|
626
679
|
if (url.pathname === PROFILE_ICON_PATH) {
|
|
627
680
|
const response = await handleProfileIcon(req, url, trusted, { icon: profileIcon, profile: () => readProfile(store), publish: (e) => publish(e) });
|
|
628
681
|
if (response) return response;
|
|
@@ -827,6 +880,7 @@ const websocket: WebSocketHandler<Conn> = {
|
|
|
827
880
|
close(ws) {
|
|
828
881
|
if (ws.data.deviceId) deviceSockets.get(ws.data.deviceId)?.delete(ws);
|
|
829
882
|
questionNotifications.disconnectDevice(ws);
|
|
883
|
+
replyNotifications.disconnectDevice(ws);
|
|
830
884
|
questionNotifications.disconnectWeb(ws);
|
|
831
885
|
},
|
|
832
886
|
};
|
|
@@ -837,8 +891,9 @@ const lanServer = config.lanPort
|
|
|
837
891
|
: null;
|
|
838
892
|
|
|
839
893
|
publish = (e) => {
|
|
840
|
-
|
|
841
|
-
|
|
894
|
+
const sensitiveHeld = e.type === "security-check.held";
|
|
895
|
+
if (!sensitiveHeld && !omoCheckNotifications.allow(e)) return;
|
|
896
|
+
if (!sensitiveHeld) questionNotifications.onEnvelope(e);
|
|
842
897
|
const text = JSON.stringify(e);
|
|
843
898
|
const topic = topicOf(e.type);
|
|
844
899
|
trustedServer.publish(topic, text);
|
|
@@ -885,6 +940,7 @@ function shutdown(): void {
|
|
|
885
940
|
updateChecker.stop();
|
|
886
941
|
clearInterval(updateTick);
|
|
887
942
|
decisions.stop();
|
|
943
|
+
messageGuard.stop();
|
|
888
944
|
watcher.stop();
|
|
889
945
|
work.stop();
|
|
890
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
|
@@ -10,6 +10,34 @@ export interface OmoEvent {
|
|
|
10
10
|
[key: string]: unknown;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Public S3 terminal categories. Native diagnostics never cross the UI boundary. */
|
|
14
|
+
export const COMPACTION_FAILURE_KINDS = [
|
|
15
|
+
"invalid_model", "auth_missing", "auth_expired", "quota", "org_denied", "model_unavailable",
|
|
16
|
+
"refused", "timeout", "incomplete", "empty", "context_budget", "stale", "provider",
|
|
17
|
+
] as const;
|
|
18
|
+
export type CompactionFailureKind = typeof COMPACTION_FAILURE_KINDS[number];
|
|
19
|
+
export type CompactionOutcome =
|
|
20
|
+
| { readonly status: "failed"; readonly failureKind: CompactionFailureKind }
|
|
21
|
+
| { readonly status: "cancelled" | "completed" };
|
|
22
|
+
|
|
23
|
+
export function blockingCompaction(event: OmoEvent): boolean {
|
|
24
|
+
return event.blocking !== false && event.reason !== "speculative" && event.reason !== "idle";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Old engines have no structured failure; do not classify their raw error strings. */
|
|
28
|
+
export function compactionOutcome(event: OmoEvent): CompactionOutcome {
|
|
29
|
+
const failure = event.failure;
|
|
30
|
+
const structured = typeof failure === "object" && failure !== null;
|
|
31
|
+
if (event.aborted === true && !structured && event.rejectionCause !== "cancelled-by-extension") return { status: "cancelled" };
|
|
32
|
+
const result = typeof event.result === "object" && event.result !== null && !Array.isArray(event.result);
|
|
33
|
+
const failed = structured || typeof event.errorMessage === "string" || event.accepted === false ||
|
|
34
|
+
(event.accepted !== true && !result);
|
|
35
|
+
if (!failed) return { status: "completed" };
|
|
36
|
+
const kind = structured && "kind" in failure ? failure.kind : undefined;
|
|
37
|
+
for (const known of COMPACTION_FAILURE_KINDS) if (kind === known) return { status: "failed", failureKind: known };
|
|
38
|
+
return { status: "failed", failureKind: "provider" };
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
export interface OmoResponse {
|
|
14
42
|
type: "response";
|
|
15
43
|
id: string;
|
package/src/protocol.ts
CHANGED
|
@@ -19,6 +19,22 @@ export interface MessageReference {
|
|
|
19
19
|
readonly images?: readonly string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Measured across one native response turn, including its model calls around tools. */
|
|
23
|
+
export interface TurnStats {
|
|
24
|
+
readonly tokensPerSecond?: number;
|
|
25
|
+
/** Sum of assistant/model processing intervals, not tool or total wall time. */
|
|
26
|
+
readonly modelDurationMs?: number;
|
|
27
|
+
/** Missing cache usage is unknown, not zero. */
|
|
28
|
+
readonly cacheHitPercent?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MessageStatsData {
|
|
32
|
+
readonly itemId: string;
|
|
33
|
+
readonly messageSeq: number;
|
|
34
|
+
readonly turnId: string;
|
|
35
|
+
readonly stats: TurnStats;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
export interface Envelope<T extends string = string, D = unknown> {
|
|
23
39
|
v: typeof PROTOCOL_VERSION;
|
|
24
40
|
id: string;
|
|
@@ -69,6 +85,7 @@ export type DecisionAnswerRequest = { readonly expectedRevision: number; readonl
|
|
|
69
85
|
|
|
70
86
|
/** HTTP/event DTOs are separate from storage records; payload retains the existing action-card structure. */
|
|
71
87
|
export interface DecisionInfo {
|
|
88
|
+
readonly security?: import("./security-allow.ts").HeldState;
|
|
72
89
|
readonly id: string;
|
|
73
90
|
readonly revision: number;
|
|
74
91
|
readonly ownerConv: string;
|
|
@@ -118,14 +135,27 @@ export interface DecisionUpdatedData {
|
|
|
118
135
|
readonly counters: DecisionCountersInfo;
|
|
119
136
|
}
|
|
120
137
|
|
|
138
|
+
export type NotificationMode = "inbox-only" | "all-replies";
|
|
139
|
+
export interface NotificationSettings {
|
|
140
|
+
readonly mode: NotificationMode;
|
|
141
|
+
}
|
|
142
|
+
export interface ReplyNotification {
|
|
143
|
+
readonly id: string;
|
|
144
|
+
readonly conv: string;
|
|
145
|
+
readonly title: string;
|
|
146
|
+
readonly text: string;
|
|
147
|
+
readonly workId?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
export type ClientMessage =
|
|
122
151
|
| { type: "hello"; data: { client: string; version: string } }
|
|
123
152
|
| { type: "client.presence"; data: { visible: boolean } }
|
|
124
153
|
| { type: "history.get"; conv: string; before?: number; after?: number; limit?: number }
|
|
125
154
|
| { type: "conversation.create"; data: { title: string } }
|
|
126
|
-
| { 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 } }
|
|
127
156
|
| { type: "input.action"; conv: string; data: ActionAnswerData }
|
|
128
157
|
| { type: "input.interrupt"; conv: string; data: Record<string, never> }
|
|
129
158
|
| { type: "conversation.rotate"; conv: string }
|
|
130
159
|
| { type: "reminder.ack"; data: { id: string } }
|
|
131
|
-
| { type: "question.notification.ack"; data: { id: string } }
|
|
160
|
+
| { type: "question.notification.ack"; data: { id: string } }
|
|
161
|
+
| { type: "reply.notification.ack"; data: { id: string } };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { NotificationSettings, ReplyNotification } from "./protocol.ts";
|
|
2
|
+
import type { Store } from "./store.ts";
|
|
3
|
+
|
|
4
|
+
export interface NativeReplySource {
|
|
5
|
+
readonly sessionPath: string;
|
|
6
|
+
readonly conv: string;
|
|
7
|
+
readonly workId?: string;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NativeReplyEntry {
|
|
12
|
+
readonly id?: string;
|
|
13
|
+
readonly type?: string;
|
|
14
|
+
readonly message?: {
|
|
15
|
+
readonly role?: string;
|
|
16
|
+
readonly content?: unknown;
|
|
17
|
+
readonly stopReason?: string;
|
|
18
|
+
readonly errorMessage?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** entry_appended carries a persisted entry, unlike message_end's identity-free message object. */
|
|
23
|
+
export function nativeReplyEntry(value: unknown): NativeReplyEntry | null {
|
|
24
|
+
if (typeof value !== "object" || value === null || !("id" in value) || typeof value.id !== "string" || !value.id ||
|
|
25
|
+
!("type" in value) || typeof value.type !== "string") return null;
|
|
26
|
+
const message = "message" in value ? value.message : undefined;
|
|
27
|
+
if (typeof message !== "object" || message === null) return { id: value.id, type: value.type };
|
|
28
|
+
return {
|
|
29
|
+
id: value.id, type: value.type,
|
|
30
|
+
message: {
|
|
31
|
+
role: "role" in message && typeof message.role === "string" ? message.role : undefined,
|
|
32
|
+
stopReason: "stopReason" in message && typeof message.stopReason === "string" ? message.stopReason : undefined,
|
|
33
|
+
errorMessage: "errorMessage" in message && typeof message.errorMessage === "string" ? message.errorMessage : undefined,
|
|
34
|
+
content: "content" in message ? message.content : undefined,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replyText(entry: NativeReplyEntry): string | null {
|
|
40
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
41
|
+
// Only native completed assistant messages qualify, including intermediate tool-use messages.
|
|
42
|
+
if (message?.role !== "assistant" || !["stop", "toolUse", "length"].includes(message.stopReason ?? "") || message.errorMessage) return null;
|
|
43
|
+
const content = message.content;
|
|
44
|
+
const text = typeof content === "string" ? content : Array.isArray(content)
|
|
45
|
+
? content.flatMap((part: unknown) => typeof part === "object" && part !== null &&
|
|
46
|
+
"type" in part && part.type === "text" && "text" in part && typeof part.text === "string" ? [part.text] : []).join("")
|
|
47
|
+
: "";
|
|
48
|
+
return text.trim() ? text : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class ReplyNotifications<Connection extends object> {
|
|
52
|
+
private readonly devices = new Map<Connection, { deviceId: string; sent: Set<string> }>();
|
|
53
|
+
|
|
54
|
+
constructor(private readonly store: Store, private readonly send: (connection: Connection, notification: ReplyNotification) => void) {}
|
|
55
|
+
|
|
56
|
+
settings(): NotificationSettings {
|
|
57
|
+
return { mode: this.store.getSetting("notifications.mode") === "all-replies" ? "all-replies" : "inbox-only" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
revision(): string {
|
|
61
|
+
return this.store.getSetting("notifications.revision") ?? "0";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The read's revision is captured before RPC, so a toggle cannot admit an older in-flight read. */
|
|
65
|
+
observe(source: NativeReplySource, entries: readonly NativeReplyEntry[], revision: string): void {
|
|
66
|
+
if (revision !== this.revision()) return;
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (typeof entry.id !== "string" || !entry.id) throw new Error("reply_history_entry_without_id");
|
|
69
|
+
const text = replyText(entry);
|
|
70
|
+
const notification = this.settings().mode === "all-replies" && text !== null ? {
|
|
71
|
+
id: crypto.randomUUID(), conv: source.conv, title: source.title, text,
|
|
72
|
+
...(source.workId ? { workId: source.workId } : {}),
|
|
73
|
+
} : null;
|
|
74
|
+
this.store.recordReplyNotification(source.sessionPath, entry.id, notification);
|
|
75
|
+
}
|
|
76
|
+
this.deliver();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setMode(mode: NotificationSettings["mode"]): void {
|
|
80
|
+
if (mode === this.settings().mode) return;
|
|
81
|
+
this.store.setNotificationMode(mode);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
connectDevice(connection: Connection, deviceId: string): void {
|
|
85
|
+
if (this.devices.get(connection)?.deviceId === deviceId) return;
|
|
86
|
+
this.devices.set(connection, { deviceId, sent: new Set() });
|
|
87
|
+
this.deliver();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
disconnectDevice(connection: Connection): void {
|
|
91
|
+
this.devices.delete(connection);
|
|
92
|
+
this.deliver();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ack(connection: Connection, id: string): boolean {
|
|
96
|
+
const device = this.devices.get(connection);
|
|
97
|
+
if (!device?.sent.has(id)) return false;
|
|
98
|
+
this.store.ackReplyNotification(id, device.deviceId);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Flushes committed occurrences, including explicitly published work replies. */
|
|
103
|
+
deliver(): void {
|
|
104
|
+
if (this.settings().mode !== "all-replies") return;
|
|
105
|
+
for (const [connection, device] of this.devices) {
|
|
106
|
+
for (const notification of this.store.pendingReplyNotifications(device.deviceId)) {
|
|
107
|
+
if ([...this.devices.values()].some((other) => other.deviceId === device.deviceId && other.sent.has(notification.id))) continue;
|
|
108
|
+
device.sent.add(notification.id);
|
|
109
|
+
this.send(connection, notification);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Native owners use this narrow seam; notification transport never observes synthetic chat publication. */
|
|
116
|
+
export type NativeReplyObserver = Pick<ReplyNotifications<object>, "observe" | "revision" | "settings">;
|
|
117
|
+
|
|
118
|
+
/** Writes serialize across awaited activation snapshots; rejected requests never reach the queue. */
|
|
119
|
+
export class NotificationSettingsApi {
|
|
120
|
+
private writes: Promise<unknown> = Promise.resolve();
|
|
121
|
+
|
|
122
|
+
constructor(private readonly notifications: Pick<ReplyNotifications<object>, "settings" | "setMode">,
|
|
123
|
+
private readonly baseline: () => Promise<void>, private readonly publish: (settings: NotificationSettings) => void) {}
|
|
124
|
+
|
|
125
|
+
async handle(req: Request): Promise<Response> {
|
|
126
|
+
if (req.method === "GET") return Response.json(this.notifications.settings());
|
|
127
|
+
if (req.method !== "PUT") return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
128
|
+
const body: unknown = await req.json().catch(() => null);
|
|
129
|
+
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length !== 1 ||
|
|
130
|
+
!("mode" in body) || (body.mode !== "inbox-only" && body.mode !== "all-replies")) {
|
|
131
|
+
return Response.json({ error: "invalid_notification_settings" }, { status: 400 });
|
|
132
|
+
}
|
|
133
|
+
const mode = body.mode;
|
|
134
|
+
const write = this.writes.then(async () => {
|
|
135
|
+
if (mode === "all-replies" && this.notifications.settings().mode !== mode) await this.baseline();
|
|
136
|
+
this.notifications.setMode(mode);
|
|
137
|
+
const settings = this.notifications.settings();
|
|
138
|
+
this.publish(settings);
|
|
139
|
+
return Response.json(settings);
|
|
140
|
+
});
|
|
141
|
+
this.writes = write.catch(() => undefined);
|
|
142
|
+
return write;
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/runtime/paths.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface RuntimePaths {
|
|
|
41
41
|
readonly backups: string;
|
|
42
42
|
readonly scheduleFile: string;
|
|
43
43
|
readonly voiceSettingFile: string;
|
|
44
|
+
readonly securityCheckFile: string;
|
|
44
45
|
readonly logs: string;
|
|
45
46
|
readonly secrets: string;
|
|
46
47
|
readonly omoTokenFile: string;
|
|
@@ -108,6 +109,7 @@ export function runtimePaths(env: Env = process.env, exists: (path: string) => b
|
|
|
108
109
|
backups: join(data, "backups"),
|
|
109
110
|
scheduleFile: value(env, "ZGW_SCHEDULE_FILE") ?? join(data, "schedule.json"),
|
|
110
111
|
voiceSettingFile: value(env, "ZGW_VOICE_SETTING_FILE") ?? join(data, "voice-setting"),
|
|
112
|
+
securityCheckFile: value(env, "ZGW_SECURITY_CHECK_FILE") ?? join(data, "security-check"),
|
|
111
113
|
logs: join(home, "logs"),
|
|
112
114
|
secrets,
|
|
113
115
|
omoTokenFile: value(env, "ZGW_OMO_TOKEN_FILE") ?? join(secrets, "omo-ws-token"),
|
|
@@ -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
|
+
}
|