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
@@ -0,0 +1,31 @@
1
+ --- a/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
2
+ +++ b/@code-yeongyu/senpi/dist/core/extensions/builtin/compaction/speculative-summary.js
3
+ @@ -124,6 +124,7 @@
4
+ headers,
5
+ extraBody: options.auth.extraBody,
6
+ env: options.auth.env,
7
+ + cwd: options.context.cwd,
8
+ streamKind: "auxiliary",
9
+ onPayload: async (payload, model) => {
10
+ const sanitized = model.api === "anthropic-messages" ? sanitizeAnthropicPayload(payload) : payload;
11
+ --- a/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
12
+ +++ b/@code-yeongyu/senpi/dist/modes/rpc/multi-session-host.js
13
+ @@ -227,7 +227,7 @@
14
+ onHandoffParked: async (ids) => {
15
+ await Promise.all(ids.map(async (id) => {
16
+ await writer.flushConnection(id);
17
+ - connections.get(id)?.close();
18
+ + await connections.get(id)?.finish();
19
+ }));
20
+ },
21
+ }, hostContext, {
22
+ @@ -342,7 +342,8 @@
23
+ process.stderr.write(`senpi rpc connection ${id} release failed: ${errorMessage(cause)}\n`);
24
+ });
25
+ };
26
+ - connections.set(id, { id, sink, detach, close: () => socket.destroy() });
27
+ + connections.set(id, { id, sink, detach, close: () => socket.destroy(),
28
+ + finish: () => new Promise((resolve) => socket.end(resolve)) });
29
+ socket.once("close", detach);
30
+ socket.once("error", () => detach());
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zigmini",
3
- "version": "0.8.0-beta.7",
3
+ "version": "0.9.0-beta.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "zigmini": "bin/zigmini"
package/src/cli/setup.ts CHANGED
@@ -49,7 +49,7 @@ export interface SetupDeps {
49
49
  readonly env: Env;
50
50
  readonly paths: RuntimePaths;
51
51
  readonly bunPath: string;
52
- readonly installEngine: (options: { home: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
52
+ readonly installEngine: (options: { home: string; agentDir: string; acceptLicense: boolean; engineOnly: boolean; confirmLicense?: (summary: string, path: string) => Promise<boolean> }) => Promise<string>;
53
53
  readonly globalOmoVersion: () => Promise<string | null>;
54
54
  readonly installGlobalOmo: (version: string) => Promise<void>;
55
55
  /** The executable checked or installed above; injected installers may keep the plain omo command. */
@@ -221,7 +221,11 @@ async function engineStep(options: SetupOptions, deps: SetupDeps): Promise<numbe
221
221
  return answer?.trim().toLowerCase() === "y";
222
222
  };
223
223
  try {
224
- const target = await deps.installEngine({ home: deps.paths.home, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
224
+ // Findings 214-217: the candidate is checked against the store of the agent folder the bridge will use.
225
+ const configFile = join(deps.paths.home, "config.env");
226
+ const dedicated = options.agentDir ?? (existsSync(configFile) ? parseConfigEnv(readFileSync(configFile, "utf8")).ZGW_OMO_AGENT_DIR : undefined);
227
+ const agentDir = dedicated ? resolve(dedicated) : defaultAgentDir(deps.env);
228
+ const target = await deps.installEngine({ home: deps.paths.home, agentDir, acceptLicense: options.acceptLicense, engineOnly: options.engineOnly, confirmLicense });
225
229
  deps.out(message(lang, "engineDone", { target }));
226
230
  return 0;
227
231
  } catch (error) {
package/src/decisions.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { decisionContext } from "./decision-context.ts";
3
3
  import { SecurityCheckError, type MessageGuard } from "./security-check.ts";
4
+ import { securityDecision, SecurityRuntimeError } from "./security-allow.ts";
4
5
  import type { OmoEvent } from "./omo/client.ts";
5
6
  import type { ActionAnswerData, DecisionAnswerRequest, DecisionInfo, DecisionsPageData, DecisionUpdatedData } from "./protocol.ts";
6
7
  import { blockedMainQuestion, blockedQuestion, DecisionStoreError, RESUMABLE_CLOSED, Store, type Decision, type DecisionMethod, type DecisionSubmission, type StoredEvent } from "./store.ts";
@@ -163,6 +164,10 @@ export class DecisionService {
163
164
  }
164
165
 
165
166
  info(decision: Decision): DecisionInfo {
167
+ if (securityDecision(decision)) {
168
+ const security = this.store.securityHold(decision.id)?.state;
169
+ return { ...decision, work: null, allowedActions: [], ...(security ? { security } : {}) };
170
+ }
166
171
  const work = decision.workId === null ? null : this.store.getWork(decision.workId);
167
172
  let allowedActions: DecisionInfo["allowedActions"] = [];
168
173
  if (unresolved(decision) && !["completed", "failed", "cancelled"].includes(work?.status ?? "") &&
@@ -200,12 +205,17 @@ export class DecisionService {
200
205
  }
201
206
 
202
207
  answer(id: string, input: unknown, checked = false): DecisionInfo {
208
+ const original = this.store.getDecision(id);
209
+ if (original && securityDecision(original)) throw new DecisionStoreError("security_endpoint_required");
203
210
  const request = parseDecisionAnswer(input);
204
211
  if (!checked) {
205
212
  const owner = this.store.getDecision(id)?.ownerConv ?? "main";
206
- this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async () => {
207
- this.answer(id, input, true);
208
- });
213
+ const snapshot = structuredClone(request);
214
+ this.options.guard?.holdOrPass(owner, "answer", answerTexts(request), async (receipt) => {
215
+ receipt({ kind: "answer", decisionId: id, submissionId: snapshot.submissionId });
216
+ this.answer(id, snapshot, true);
217
+ }, { key: JSON.stringify(["answer", id, request.submissionId]), snapshot,
218
+ originalDecisionId: id, submissionId: request.submissionId });
209
219
  }
210
220
  this.refresh();
211
221
  const before = this.required(id);
@@ -228,15 +238,15 @@ export class DecisionService {
228
238
  try {
229
239
  if (!this.ports.sendLive(decision)) this.review(id, "receipt_unknown");
230
240
  } catch (error) {
231
- this.error(error);
241
+ this.error(checked ? new SecurityRuntimeError("dispatch_unknown") : error);
232
242
  this.review(id, "receipt_unknown");
233
243
  }
234
244
  } else if (operation?.data.mainInputSeq !== null && operation?.data.mainInputSeq !== undefined) {
235
245
  const inputEvent = this.store.getEvent(operation.data.mainInputSeq);
236
246
  if (!inputEvent) throw new Error("decision_main_intent_missing");
237
- this.dispatch(id, () => this.ports.dispatchMain(inputEvent));
247
+ this.dispatch(id, () => this.ports.dispatchMain(inputEvent), checked);
238
248
  } else if (operation?.data.commandId && decision.workId) {
239
- this.dispatch(id, () => this.ports.dispatchWork(decision.workId!));
249
+ this.dispatch(id, () => this.ports.dispatchWork(decision.workId!), checked);
240
250
  }
241
251
  this.refresh();
242
252
  return this.info(this.required(id));
@@ -247,6 +257,7 @@ export class DecisionService {
247
257
  * 취소 button; a later answer to the closed decision is rejected as a conflict.
248
258
  */
249
259
  dismiss(id: string, input: unknown): DecisionInfo {
260
+ if (securityDecision(this.required(id))) throw new DecisionStoreError("security_endpoint_required");
250
261
  const expectedRevision = parseDecisionDismiss(input);
251
262
  this.refresh();
252
263
  let dismissal;
@@ -274,9 +285,9 @@ export class DecisionService {
274
285
  return this.info(this.required(id));
275
286
  }
276
287
 
277
- private dispatch(id: string, send: () => Promise<void>): void {
288
+ private dispatch(id: string, send: () => Promise<void>, protectedPayload = false): void {
278
289
  void send().catch((error: unknown) => {
279
- this.error(error);
290
+ this.error(protectedPayload ? new SecurityRuntimeError("dispatch_unknown") : error);
280
291
  if (!this.stopped) this.review(id, "receipt_unknown");
281
292
  }).finally(() => {
282
293
  if (!this.stopped) this.refresh();
@@ -523,6 +534,7 @@ export class DecisionService {
523
534
  this.store.reconcileDecisionReceipts();
524
535
  const now = Date.now();
525
536
  for (let decision of this.store.allDecisions()) {
537
+ if (securityDecision(decision)) continue;
526
538
  if (!unresolved(decision)) continue;
527
539
  const context = this.store.decisionContextReason(decision);
528
540
  if (context === "work_failed" || context === "work_cancelled") {
@@ -550,16 +562,18 @@ export class DecisionService {
550
562
  }
551
563
  }
552
564
  for (const decision of this.store.allDecisions()) {
565
+ if (securityDecision(decision)) continue;
553
566
  if (decision.method === "question" && decision.status === "pending" && decision.bindingState === "live") {
554
567
  this.store.reconcileReissuedQuestion(decision.id, this.ports.liveBinding(decision));
555
568
  }
556
569
  }
570
+ this.store.notifySecurityReceipts();
557
571
  const all = this.store.allDecisions();
558
572
  for (const decision of all) {
559
573
  const previous = this.known.get(decision.id);
560
574
  this.known.set(decision.id, decision);
561
575
  if (!previous || previous.revision < decision.revision) {
562
- if (previous && previous.status !== decision.status && !unresolved(decision)) {
576
+ if (!securityDecision(decision) && previous && previous.status !== decision.status && !unresolved(decision)) {
563
577
  const answer = record(decision.answer) ? decision.answer : {};
564
578
  const kind = decision.method === "question" ? "question" : decision.method === "confirm" ? "confirm" : "value";
565
579
  const dismissed = decision.status === "closed" ? DISMISSED_REASONS[decision.reason ?? ""] : undefined;
@@ -580,7 +594,7 @@ export class DecisionService {
580
594
  }
581
595
  const times: number[] = [];
582
596
  if (unresolved(decision) && decision.bindingState === "live" && decision.deadlineAt !== null) times.push(decision.deadlineAt);
583
- if (decision.status === "submitted") {
597
+ if (!securityDecision(decision) && decision.status === "submitted") {
584
598
  const operation = this.store.latestDecisionOperation(decision.id);
585
599
  times.push(Date.parse(operation?.ts ?? decision.updatedAt) + this.receiptMs);
586
600
  }
@@ -626,7 +640,7 @@ export class DecisionService {
626
640
  }
627
641
  if (!route[2] && req.method === "GET") {
628
642
  const decision = this.required(id);
629
- if (unresolved(decision)) {
643
+ if (!securityDecision(decision) && unresolved(decision)) {
630
644
  let timer: ReturnType<typeof setTimeout> | undefined;
631
645
  try {
632
646
  await Promise.race([
@@ -647,12 +661,17 @@ export class DecisionService {
647
661
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
648
662
  } catch (error) {
649
663
  if (error instanceof SecurityCheckError) {
650
- return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422 });
664
+ return Response.json({ error: error.code, message: error.message, held: error.held }, { status: 422, headers: { "Cache-Control": "no-store" } });
665
+ }
666
+ if (error instanceof SecurityRuntimeError) {
667
+ return Response.json({ error: error.code, ...(error.held ? { held: error.held } : {}) }, {
668
+ status: error.code === "held_conflict" ? 409 : 503, headers: { "Cache-Control": "no-store" },
669
+ });
651
670
  }
652
671
  if (error instanceof DecisionStoreError) {
653
672
  return Response.json({
654
673
  error: error.code, decision: error.decision ? this.info(error.decision) : null,
655
- }, { status: error.code === "decision_not_found" ? 404 : error.code.startsWith("invalid_") ? 400 : 409 });
674
+ }, { status: error.code === "decision_not_found" ? 404 : error.code === "security_endpoint_required" || error.code.startsWith("invalid_") ? 400 : 409 });
656
675
  }
657
676
  if (error instanceof URIError) return Response.json({ error: "invalid_decision" }, { status: 400 });
658
677
  this.error(error);
@@ -1,6 +1,6 @@
1
1
  {
2
- "omo": "5.1.35",
3
- "senpi": "2026.10.16",
4
- "threadSchema": 7,
5
- "verifiedAt": "2026-10-19"
2
+ "omo": "5.1.48",
3
+ "senpi": "2026.10.25-2",
4
+ "threadSchema": 10,
5
+ "verifiedAt": "2026-10-28"
6
6
  }
@@ -1,10 +1,13 @@
1
1
  import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
- import { createConnection } from "node:net";
3
+ import type { Socket } from "node:net";
4
+ import { connectEngineSocket } from "./rpc-socket.ts";
4
5
  import type { ServerWebSocket } from "bun";
5
6
  import { OmoClient } from "../omo/client.ts";
6
7
  import { applyPatch, patchPath } from "./apply-patch.ts";
7
8
  import { engineBuildId, engineRevision } from "./build-id.ts";
9
+ import { readStoreSchema, storeIncompatibility } from "./schema-guard.ts";
10
+ import { supportedSchema } from "./thread-schema.ts";
8
11
 
9
12
  const PATCHES = new URL("../../host/omo-check/patches/", import.meta.url).pathname;
10
13
  const LICENSE = "Sustainable Use License 1.0";
@@ -219,34 +222,54 @@ export async function smokeEngine(home: string, candidate: string, options: { re
219
222
  void proc.exited.then((code) => ready.reject(new Error(`engine_smoke_exit:${code}`)));
220
223
  let server: ReturnType<typeof Bun.serve> | undefined;
221
224
  let client: OmoClient | undefined;
222
- const sockets = new Set<ReturnType<typeof createConnection>>();
225
+ const sockets = new Set<Socket>();
226
+ const handshake = Promise.withResolvers<void>();
223
227
  try {
224
228
  await bounded(ready.promise, timeout);
225
229
  server = Bun.serve({
226
230
  hostname: "127.0.0.1", port: options.port ?? 0,
227
- fetch: (req, srv) => srv.upgrade(req, { data: {} }) ? undefined : new Response("", { status: 400 }),
231
+ fetch: (req, srv) => srv.upgrade(req, { data: { queue: [], closed: false } }) ? undefined : new Response("", { status: 400 }),
228
232
  websocket: {
229
- open(ws: ServerWebSocket<{ upstream?: ReturnType<typeof createConnection> }>) {
230
- const upstream = createConnection(socket);
231
- sockets.add(upstream);
232
- ws.data = { upstream };
233
- let pending = "";
234
- upstream.on("data", (chunk) => {
235
- pending += chunk.toString();
236
- let newline: number;
237
- while ((newline = pending.indexOf("\n")) !== -1) {
238
- ws.send(pending.slice(0, newline));
239
- pending = pending.slice(newline + 1);
233
+ // The engine's own authenticated connection (socket_auth, key root = the smoke agent folder). Frames the client
234
+ // sends while authentication runs are queued in order; a failed authentication fails the smoke, never falls back.
235
+ open(ws: ServerWebSocket<{ upstream?: Socket; queue: string[]; closed: boolean }>) {
236
+ void connectEngineSocket({ socketPath: socket, binary: join(candidate, "node_modules/.bin/omo"), agentDir: join(scratch, "agent") }).then((upstream) => {
237
+ if (ws.data.closed) {
238
+ upstream.destroy();
239
+ return;
240
240
  }
241
+ sockets.add(upstream);
242
+ ws.data.upstream = upstream;
243
+ relay(ws, upstream);
244
+ for (const frame of ws.data.queue.splice(0)) upstream.write(`${frame}\n`);
245
+ }, (error: unknown) => {
246
+ handshake.reject(error instanceof Error ? error : new Error(String(error)));
247
+ ws.close();
241
248
  });
242
- upstream.once("error", () => ws.close());
243
- upstream.once("close", () => { sockets.delete(upstream); ws.close(); });
244
249
  },
245
- message(ws, message) { ws.data.upstream?.write(`${message}\n`); },
246
- close(ws) { ws.data.upstream?.destroy(); },
250
+ message(ws, message) {
251
+ if (ws.data.upstream) ws.data.upstream.write(`${message}\n`);
252
+ else ws.data.queue.push(String(message));
253
+ },
254
+ close(ws) {
255
+ ws.data.closed = true;
256
+ ws.data.upstream?.destroy();
257
+ },
247
258
  },
248
259
  });
249
- const handshake = Promise.withResolvers<void>();
260
+ const relay = (ws: ServerWebSocket<{ upstream?: Socket; queue: string[]; closed: boolean }>, upstream: Socket) => {
261
+ let pending = "";
262
+ upstream.on("data", (chunk) => {
263
+ pending += chunk.toString();
264
+ let newline: number;
265
+ while ((newline = pending.indexOf("\n")) !== -1) {
266
+ ws.send(pending.slice(0, newline));
267
+ pending = pending.slice(newline + 1);
268
+ }
269
+ });
270
+ upstream.once("error", () => ws.close());
271
+ upstream.once("close", () => { sockets.delete(upstream); ws.close(); });
272
+ };
250
273
  client = new OmoClient(`ws://127.0.0.1:${server.port}`, "", {
251
274
  onEvent() {},
252
275
  onStatus(status) {
@@ -271,8 +294,27 @@ export async function smokeEngine(home: string, candidate: string, options: { re
271
294
  }
272
295
  }
273
296
 
297
+ /**
298
+ * Findings 214-217: the candidate must read the agent folder's shared store before it is selected. The store is read
299
+ * through the guard's temp copy; an unknown support value or an unreadable store is not a known incompatibility.
300
+ */
301
+ export function assertStoreCompatible(agentDir: string, candidate: string): void {
302
+ let supported: number | null = null;
303
+ try {
304
+ supported = supportedSchema(candidate);
305
+ } catch {
306
+ supported = null;
307
+ }
308
+ const found = storeIncompatibility(readStoreSchema(agentDir), supported);
309
+ if (found !== null) throw new Error(`${ENGINE_SCHEMA_INCOMPATIBLE}:store=${found.store},supported=${found.supported}`);
310
+ }
311
+
312
+ export const ENGINE_SCHEMA_INCOMPATIBLE = "engine_schema_incompatible";
313
+
274
314
  export async function installEngine(options: {
275
315
  readonly home: string;
316
+ /** The agent folder the bridge uses; when set, a candidate that cannot read its store is never selected. */
317
+ readonly agentDir?: string;
276
318
  readonly version?: string;
277
319
  readonly patchDir?: string;
278
320
  readonly registry?: string;
@@ -281,11 +323,17 @@ export async function installEngine(options: {
281
323
  readonly confirmLicense?: (summary: string, path: string) => Promise<boolean>;
282
324
  readonly smoke?: { readonly port?: number; readonly timeoutMs?: number };
283
325
  }): Promise<string> {
284
- const version = options.version ?? JSON.parse(readFileSync(new URL("./compat.json", import.meta.url), "utf8")).omo;
326
+ const pin: { readonly omo: string; readonly threadSchema?: number } = JSON.parse(readFileSync(new URL("./compat.json", import.meta.url), "utf8"));
327
+ const version = options.version ?? pin.omo;
328
+ if (options.agentDir !== undefined && version === pin.omo) {
329
+ const found = storeIncompatibility(readStoreSchema(options.agentDir), pin.threadSchema ?? null);
330
+ if (found !== null) throw new Error(`${ENGINE_SCHEMA_INCOMPATIBLE}:store=${found.store},supported=${found.supported}`);
331
+ }
285
332
  const omoDir = join(resolve(options.home), "omo");
286
333
  await installStock(version, omoDir, options.registry);
287
334
  const target = buildPatched(version, omoDir, options.patchDir);
288
335
  const candidate = join(omoDir, target);
336
+ if (options.agentDir !== undefined) assertStoreCompatible(options.agentDir, candidate);
289
337
  await acceptEngineLicense(options.home, candidate, options);
290
338
  await smokeEngine(options.home, candidate, options.smoke);
291
339
  candidateExecutable(candidate);
@@ -0,0 +1,36 @@
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { createConnection, Socket } from "node:net";
3
+ import { dirname, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ export interface EngineSocketOptions {
7
+ readonly socketPath: string;
8
+ readonly binary: string;
9
+ readonly agentDir: string;
10
+ }
11
+
12
+ /** Use the selected engine's key-record validation and challenge-response implementation. */
13
+ export async function connectEngineSocket(options: EngineSocketOptions): Promise<Socket> {
14
+ const modules = dirname(dirname(dirname(realpathSync(options.binary))));
15
+ const authPath = join(modules, "@code-yeongyu/senpi/dist/modes/rpc/rpc-auth-client.js");
16
+ if (existsSync(authPath)) {
17
+ const auth: { readonly connectAuthenticatedRpcSocket?: unknown } = await import(pathToFileURL(authPath).href);
18
+ if (typeof auth.connectAuthenticatedRpcSocket !== "function") {
19
+ throw new Error("engine_rpc_auth_module_invalid");
20
+ }
21
+ const result: unknown = await auth.connectAuthenticatedRpcSocket(options.socketPath, undefined, { keyRoot: options.agentDir });
22
+ if (typeof result !== "object" || result === null || !("socket" in result) || !(result.socket instanceof Socket)) {
23
+ throw new Error("engine_rpc_auth_socket_invalid");
24
+ }
25
+ return result.socket;
26
+ }
27
+ // Retained pre-auth engines and the native transport fixture have no authentication module.
28
+ return await new Promise<Socket>((resolve, reject) => {
29
+ const socket = createConnection(options.socketPath);
30
+ socket.once("error", reject);
31
+ socket.once("connect", () => {
32
+ socket.off("error", reject);
33
+ resolve(socket);
34
+ });
35
+ });
36
+ }
@@ -38,6 +38,15 @@ export interface SchemaCheck {
38
38
  export interface SavedGuard extends SchemaCheck {
39
39
  readonly mode: GuardMode;
40
40
  readonly at: string;
41
+ /** The refusing bridge's runtime slot and pinned engine folder; null for a bridge that did not say (findings 214-217). */
42
+ readonly slot?: string | null;
43
+ readonly engine?: string | null;
44
+ }
45
+
46
+ /** Which bridge start ran the guard: a slot bridge passes its slot and pinned engine folder. */
47
+ export interface GuardOrigin {
48
+ readonly slot: string | null;
49
+ readonly engine: string | null;
41
50
  }
42
51
 
43
52
  export interface GuardInput {
@@ -48,6 +57,7 @@ export interface GuardInput {
48
57
  readonly supported: () => number;
49
58
  readonly bunInstall: string;
50
59
  readonly now: () => Date;
60
+ readonly origin?: GuardOrigin;
51
61
  }
52
62
 
53
63
  export interface GuardResult {
@@ -116,11 +126,23 @@ export function compareVersions(a: string, b: string): number {
116
126
  return preA < preB ? -1 : 1;
117
127
  }
118
128
 
129
+ /** Findings 214-217: the installed global version is an observation, not the proven actor of the schema change. */
119
130
  export function tooNewMessage(schema: number, globalVersion: string | null): string {
120
- return `다른 OMO(전역 ${globalVersion ?? "버전 알 수 없음"})가 공유 폴더 스키마를 ${schema}으로 올렸습니다. ` +
131
+ return `공유 폴더 스키마가 ${schema}이라 이 게이트웨이 엔진이 읽지 못합니다(지금 설치된 전역 OMO: ${globalVersion ?? "버전 알 수 없음"}, 스키마를 올린 주체는 확인하지 않음). ` +
121
132
  "`zigmini update`로 맞는 버전을 받거나, 이 게이트웨이 전용 에이전트 폴더를 쓰세요(`zigmini setup --agent-dir`)";
122
133
  }
123
134
 
135
+ export const SCHEMA_INCOMPATIBLE = "schema_incompatible";
136
+
137
+ /**
138
+ * Findings 214-217: a known incompatibility is a readable store newer than what the engine supports. A missing or
139
+ * unreadable store, or an engine whose support is unknown, is not known to be incompatible.
140
+ */
141
+ export function storeIncompatibility(store: StoreReading, supported: number | null): { readonly store: number; readonly supported: number } | null {
142
+ if (store.state !== "ok" || supported === null || store.version <= supported) return null;
143
+ return { store: store.version, supported };
144
+ }
145
+
124
146
  export function checkSchema(store: StoreReading, supported: number | null, globalVersion: string | null): SchemaCheck {
125
147
  const version = store.state === "ok" ? store.version : null;
126
148
  if (store.state === "missing") return { state: "missing", store: null, supported, globalVersion, message: null };
@@ -147,7 +169,7 @@ export function guardStart(input: GuardInput): GuardResult {
147
169
  const file = join(input.run, GUARD_FILE);
148
170
  if (refuse) {
149
171
  mkdirSync(input.run, { recursive: true, mode: 0o700 });
150
- const saved: SavedGuard = { ...check, mode: input.mode, at: input.now().toISOString() };
172
+ const saved: SavedGuard = { ...check, mode: input.mode, at: input.now().toISOString(), slot: input.origin?.slot ?? null, engine: input.origin?.engine ?? null };
151
173
  writeFileSync(file, `${JSON.stringify(saved)}\n`, { mode: 0o600 });
152
174
  } else rmSync(file, { force: true });
153
175
  if (check.state === "too_new") {
@@ -170,6 +192,16 @@ export function readGuardState(run: string): SavedGuard | null {
170
192
  }
171
193
  }
172
194
 
195
+ /**
196
+ * Findings 214-217: the refusal of this start attempt only. The shared run/schema-guard.json may be left by another slot,
197
+ * engine or an earlier start, which must not fail (or explain) the current one.
198
+ */
199
+ export function attemptRefusal(saved: SavedGuard | null, attempt: { readonly slot: string; readonly engine: string | null; readonly since: string }): string | null {
200
+ if (saved === null || saved.mode !== "enforce" || saved.slot !== attempt.slot || saved.engine !== attempt.engine) return null;
201
+ if (!(Date.parse(saved.at) >= Date.parse(attempt.since))) return null;
202
+ return `저장소 스키마 ${saved.store} > 엔진 ${attempt.engine ?? "current"} 지원 ${saved.supported}로 브리지가 시작을 거부했습니다. ${saved.message}`;
203
+ }
204
+
173
205
  /** `/api/health` `omo`: a connected bridge wins over a stale file; otherwise an enforced refusal shows as schema_too_new. */
174
206
  export function healthOmo(status: string, saved: SavedGuard | null): string {
175
207
  return status !== "ready" && saved?.mode === "enforce" ? SCHEMA_TOO_NEW : status;
package/src/hub.ts CHANGED
@@ -14,7 +14,9 @@ import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
14
14
  import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
15
15
  import { readProfile } from "./profile.ts";
16
16
  import type { MessageGuard } from "./security-check.ts";
17
+ import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
17
18
  import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
19
+ import { notificationHistory } from "./notification-history.ts";
18
20
  import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
19
21
 
20
22
  export type Broadcast = (envelope: Envelope) => void;
@@ -64,7 +66,7 @@ export interface HubOptions {
64
66
  /** Answers `/usage` (model account usage) as a notice; the command never reaches omo. */
65
67
  usageReport?: () => Promise<string>;
66
68
  /** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
67
- guard?: Pick<MessageGuard, "check" | "hold">;
69
+ guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
68
70
  }
69
71
 
70
72
  /** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
@@ -371,13 +373,16 @@ export class Hub {
371
373
  async notificationBaseline(): Promise<void> {
372
374
  for (const conv of this.store.listConversations()) {
373
375
  if (!conv.threadId) continue;
374
- const { sessionId } = await this.ensureSession(conv);
376
+ const sessionPath = conv.threadId;
375
377
  const generation = this.generation;
376
378
  const revision = this.options.replies?.revision();
377
- const result = await this.requireOmo().command<EntriesResult>("get_entries", { sessionId });
378
- if (generation !== this.generation || this.omoStatus !== "ready" || this.rt(conv.id).sessionId !== sessionId ||
379
- this.store.getConversation(conv.id)?.threadId !== conv.threadId) throw new Error("reply_baseline_discarded");
380
- if (revision !== undefined) this.options.replies?.observe({ sessionPath: conv.threadId, conv: conv.id, title: conv.title }, result.entries, revision);
379
+ const current = () => generation === this.generation &&
380
+ this.store.getConversation(conv.id)?.threadId === conv.threadId;
381
+ await notificationHistory(conv.threadId, (entry) => {
382
+ if (!current()) throw new Error("reply_baseline_discarded");
383
+ if (revision !== undefined) this.options.replies?.observe({ sessionPath, conv: conv.id, title: conv.title }, [entry], revision);
384
+ }, true);
385
+ if (!current()) throw new Error("reply_baseline_discarded");
381
386
  }
382
387
  }
383
388
 
@@ -799,8 +804,16 @@ export class Hub {
799
804
  else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
800
805
  else if (this.store.unreportedWorkUpdates(conv.id).length > 0 || this.workSummary(conv.id).length > 0) await this.ensureSession(current);
801
806
  if (this.options.replies?.settings().mode === "all-replies" && current.threadId) {
802
- await this.ensureSession(this.store.getConversation(conv.id) ?? current);
803
- await this.syncLedger(conv.id);
807
+ if (this.rt(conv.id).sessionId) await this.syncLedger(conv.id);
808
+ else {
809
+ const sessionPath = current.threadId;
810
+ const revision = this.options.replies.revision();
811
+ await notificationHistory(sessionPath, (entry) => {
812
+ if (generation !== this.generation || this.omoStatus !== "ready" ||
813
+ this.store.getConversation(conv.id)?.threadId !== sessionPath) throw new Error("reply_history_discarded");
814
+ this.options.replies?.observe({ sessionPath, conv: conv.id, title: current.title }, [entry], revision);
815
+ });
816
+ }
804
817
  }
805
818
  // Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
806
819
  void this.notifyWorkUpdates(conv.id);
@@ -815,12 +828,17 @@ export class Hub {
815
828
  }
816
829
 
817
830
  /** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
818
- async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false): Promise<void> {
831
+ async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
819
832
  if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
833
+ if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
834
+ const context = { ...(submissionId ? { key: JSON.stringify(["message", convId, submissionId]), submissionId } : {}),
835
+ snapshot: structuredClone({ text, src, trust, images, referenceIds }) };
836
+ if (this.options.guard?.existing(context)) return;
820
837
  const conv = this.store.getConversation(convId);
821
838
  if (!conv) throw new Error(`unknown_conversation:${convId}`);
822
839
  const body = text.trim();
823
- const references = resolveMessageReferences(this.store, convId, referenceIds);
840
+ const source = structuredClone(src);
841
+ const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
824
842
  if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
825
843
  this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
826
844
  return;
@@ -837,29 +855,36 @@ export class Hub {
837
855
  throw new MessageReferenceError("message_reference_images_unavailable");
838
856
  }
839
857
  if (!body && attached.length === 0 && references.length === 0) return;
840
- // Improvement 165: a finding holds the message in gateway memory. The guard sends the card with the values to the
841
- // user's screens only (never stored, never in a prompt); the user's "send" runs this call again with checked.
858
+ const attachmentSnapshot = this.attachments(prompt.images);
859
+ const send = async (receipt?: (ref: SecurityReceipt) => void) => {
860
+ if (receipt && prompt.images.some((name) => !dir || !mediaFile(dir, name))) throw new SecurityRuntimeError("dispatch_failed");
861
+ const input = this.store.acceptMainUser(convId, {
862
+ text: body, src: source, trust, ...(attached.length ? { images: attached } : {}),
863
+ ...(references.length ? { references } : {}),
864
+ }, receipt);
865
+ this.publishStored(input);
866
+ this.decisions?.refresh();
867
+ const rotationDone = this.rt(convId).rotationDone;
868
+ if (rotationDone) await rotationDone;
869
+ if (this.closeUncertain(convId)) {
870
+ if (this.closeRecoveries.get(convId)?.exhausted) {
871
+ if (receipt) throw new SecurityRuntimeError("dispatch_unknown");
872
+ throw new Error("session_close_recovery_exhausted");
873
+ }
874
+ this.scheduleCloseRecovery(convId);
875
+ this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
876
+ message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
877
+ return;
878
+ }
879
+ await this.prompt(convId, prompt.text, prompt.images, input.seq, true, attachmentSnapshot, receipt !== undefined);
880
+ };
881
+ // The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
842
882
  const found = checked ? null : this.options.guard?.check([prompt.text]);
843
883
  if (found) {
844
- this.options.guard!.hold(convId, "message", found, () => this.sendText(convId, text, src, trust, images, referenceIds, true), body);
845
- return;
846
- }
847
- const input = this.store.acceptMainUser(convId, {
848
- text: body, src, trust, ...(attached.length ? { images: attached } : {}),
849
- ...(references.length ? { references } : {}),
850
- });
851
- this.publishStored(input);
852
- this.decisions?.refresh();
853
- const rotationDone = this.rt(convId).rotationDone;
854
- if (rotationDone) await rotationDone;
855
- if (this.closeUncertain(convId)) {
856
- if (this.closeRecoveries.get(convId)?.exhausted) throw new Error("session_close_recovery_exhausted");
857
- this.scheduleCloseRecovery(convId);
858
- this.emit(convId, "notice", { kind: "session-close-recovery", level: "info",
859
- message: "세션 닫기 상태를 확인하고 있습니다. 메시지는 확인 후 전달합니다." });
884
+ this.options.guard!.hold(convId, "message", found, send, body, context);
860
885
  return;
861
886
  }
862
- await this.prompt(convId, prompt.text, prompt.images, input.seq, true);
887
+ await send();
863
888
  }
864
889
 
865
890
  /** Reserve gateway report admission before its HTTP body is parsed. The source
@@ -1604,7 +1629,7 @@ export class Hub {
1604
1629
  }
1605
1630
 
1606
1631
  /** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
1607
- private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false): Promise<void> {
1632
+ private async prompt(convId: string, body: string, images: string[] = [], messageSeq?: number, admitted = false, attachmentSnapshot?: ReturnType<Hub["attachments"]>, protectedPayload = false): Promise<void> {
1608
1633
  const conv = this.store.getConversation(convId);
1609
1634
  if (!conv) return;
1610
1635
  const rt = this.rt(convId);
@@ -1622,7 +1647,7 @@ export class Hub {
1622
1647
  if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
1623
1648
  if (admitted && this.store.mainUserDispatched(convId, reference)) return;
1624
1649
  const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
1625
- const attached = this.attachments(images);
1650
+ const attached = attachmentSnapshot ?? this.attachments(images);
1626
1651
  const text = [body, attached.note].filter(Boolean).join("\n\n");
1627
1652
  const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
1628
1653
  const inputId = crypto.randomUUID();
@@ -1652,6 +1677,7 @@ export class Hub {
1652
1677
  this.decisions?.refresh();
1653
1678
  } catch (error) {
1654
1679
  if (generation !== this.generation || this.omoStatus !== "ready") return;
1680
+ if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
1655
1681
  const message = errorMessage(error);
1656
1682
  this.emit(convId, "error", this.continuityError(message));
1657
1683
  }