zigmini 1.0.0-beta.3 → 1.1.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.
Files changed (138) hide show
  1. package/README.ko.md +27 -2
  2. package/README.md +27 -2
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
@@ -0,0 +1,215 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+
3
+ // 294 C7: server-side registry of Aside browser targets for registered agents. The model only ever sees an opaque
4
+ // targetHandle. A handle is bound to the local host, the confirmed CLI account, the CDP targetId (the official CLI
5
+ // exposes no other tab generation token, so a recreated tab gets a new targetId and never inherits a handle), the
6
+ // allowed origin, and the owning agent incarnation. Borrowed tabs are user-designated; owned tabs are opened by the
7
+ // adapter and closed by it.
8
+
9
+ export type AsideOperation = "open_owned" | "read" | "screenshot" | "navigate_owned" | "click" | "fill" | "close_owned";
10
+ export type AsidePermission = "browser.aside.read" | "browser.aside.control";
11
+
12
+ export type AsideRequest =
13
+ | { readonly operation: "open_owned"; readonly url: string }
14
+ | { readonly operation: "read" | "screenshot" | "close_owned"; readonly targetHandle: string }
15
+ | { readonly operation: "navigate_owned"; readonly targetHandle: string; readonly url: string }
16
+ | { readonly operation: "click"; readonly targetHandle: string; readonly snapshotRevision: string; readonly ref: string }
17
+ | { readonly operation: "fill"; readonly targetHandle: string; readonly snapshotRevision: string; readonly ref: string; readonly text: string };
18
+
19
+ export type AsideRefusal =
20
+ | "invalid_input" | "protected_target" | "unknown_target" | "target_closed" | "target_suspended" | "target_changed"
21
+ | "origin_changed" | "account_changed" | "target_identity_unclear" | "borrowed_target" | "stale_ref" | "excluded_frame"
22
+ | "permission_denied" | "action_ticket_required" | "aside_cli_unavailable" | "aside_output_invalid" | "aside_failed";
23
+
24
+ export interface AsideTarget {
25
+ readonly handle: string;
26
+ readonly kind: "borrowed" | "owned";
27
+ readonly host: string;
28
+ readonly account: string;
29
+ readonly targetId: string;
30
+ origin: string;
31
+ readonly agentId: string;
32
+ readonly incarnation: number;
33
+ readonly selectionSeq: number | null;
34
+ state: "active" | "suspended" | "closed";
35
+ snapshotRevision: string | null;
36
+ }
37
+
38
+ export interface CleanupReceipt {
39
+ readonly handle: string;
40
+ readonly targetId: string;
41
+ readonly reason: string;
42
+ readonly outcome: "closed" | "close_failed" | "kept_borrowed";
43
+ readonly at: string;
44
+ }
45
+
46
+ const FIELDS: Record<AsideOperation, readonly string[]> = {
47
+ open_owned: ["url"],
48
+ read: ["targetHandle"],
49
+ screenshot: ["targetHandle"],
50
+ close_owned: ["targetHandle"],
51
+ navigate_owned: ["targetHandle", "url"],
52
+ click: ["targetHandle", "snapshotRevision", "ref"],
53
+ fill: ["targetHandle", "snapshotRevision", "ref", "text"],
54
+ };
55
+ const TEXT_LIMIT = 4000;
56
+
57
+ /** Validates the model input as the closed union. Any extra key (code, selector, account, host, ...) is refused. */
58
+ export function parseAsideRequest(input: unknown): AsideRequest | { readonly refused: "invalid_input" } {
59
+ if (!input || typeof input !== "object" || Array.isArray(input)) return { refused: "invalid_input" };
60
+ const record = input as Record<string, unknown>;
61
+ const operation = record.operation;
62
+ if (typeof operation !== "string" || !Object.hasOwn(FIELDS, operation)) return { refused: "invalid_input" };
63
+ const fields = FIELDS[operation as AsideOperation];
64
+ for (const key of Object.keys(record)) if (key !== "operation" && !fields.includes(key)) return { refused: "invalid_input" };
65
+ for (const key of fields) {
66
+ const value = record[key];
67
+ if (typeof value !== "string" || value.length === 0) return { refused: "invalid_input" };
68
+ if (key === "text" ? value.length > TEXT_LIMIT : value.length > 2048) return { refused: "invalid_input" };
69
+ }
70
+ return record as unknown as AsideRequest;
71
+ }
72
+
73
+ export function permissionFor(operation: AsideOperation): AsidePermission {
74
+ return operation === "read" || operation === "screenshot" ? "browser.aside.read" : "browser.aside.control";
75
+ }
76
+
77
+ /** Normalized http(s) origin, or null for anything else (about:, chrome:, file:, data:, javascript:, aside:, ...). */
78
+ export function originOf(url: string): string | null {
79
+ let parsed: URL;
80
+ try {
81
+ parsed = new URL(url);
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
86
+ return parsed.origin;
87
+ }
88
+
89
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]);
90
+ const CREDENTIAL_PATH = /\/(login|signin|sign-in|logout|password|passwords|oauth|authorize|2fa|mfa|security|credentials?)(\/|$|\?)/i;
91
+
92
+ /** Gateway origins, loopback/internal admin URLs and credential pages are never model targets. */
93
+ export function isProtectedUrl(url: string, gatewayOrigins: readonly string[]): boolean {
94
+ const origin = originOf(url);
95
+ if (origin === null) return true;
96
+ if (gatewayOrigins.includes(origin)) return true;
97
+ const host = new URL(url).hostname;
98
+ if (LOOPBACK.has(host) || LOOPBACK.has(`[${host}]`) || host.endsWith(".local") || host.endsWith(".internal")) return true;
99
+ return CREDENTIAL_PATH.test(new URL(url).pathname);
100
+ }
101
+
102
+ /** A main-document ref the host produced (e<n>). Frame-prefixed refs (f<n>e<n>) are iframe content and are refused. */
103
+ export function classifyRef(ref: string): "main" | "frame" | "invalid" {
104
+ if (/^e\d{1,5}$/.test(ref)) return "main";
105
+ if (/^f\d{1,5}e\d{1,5}$/.test(ref)) return "frame";
106
+ return "invalid";
107
+ }
108
+
109
+ export interface Observation {
110
+ readonly targetId: string;
111
+ readonly url: string;
112
+ readonly account: string;
113
+ }
114
+
115
+ /** Compares what the CLI reported with the bound target. Checked before and after every read and action. */
116
+ export function checkObservation(target: AsideTarget, observed: Observation): "ok" | "target_changed" | "origin_changed" | "account_changed" {
117
+ if (observed.account !== target.account) return "account_changed";
118
+ if (observed.targetId !== target.targetId) return "target_changed";
119
+ if (originOf(observed.url) !== target.origin) return "origin_changed";
120
+ return "ok";
121
+ }
122
+
123
+ export function snapshotRevision(targetId: string, url: string, elements: readonly string[]): string {
124
+ return createHash("sha256").update(JSON.stringify([targetId, url, elements])).digest("hex").slice(0, 16);
125
+ }
126
+
127
+ export class AsideTargetRegistry {
128
+ private readonly targets = new Map<string, AsideTarget>();
129
+ readonly receipts: CleanupReceipt[] = [];
130
+
131
+ constructor(private readonly now: () => Date = () => new Date()) {}
132
+
133
+ /** Binds a user-selected open tab. Only the handle goes to the model; the tab list stays on the user's screen. */
134
+ designate(input: { host: string; account: string; targetId: string; url: string; agentId: string; incarnation: number; selectionSeq: number;
135
+ gatewayOrigins: readonly string[] }): AsideTarget | { readonly refused: AsideRefusal } {
136
+ if (!input.account || !input.targetId || !input.host) return { refused: "target_identity_unclear" };
137
+ if (isProtectedUrl(input.url, input.gatewayOrigins)) return { refused: "protected_target" };
138
+ return this.add("borrowed", input, input.selectionSeq);
139
+ }
140
+
141
+ /**
142
+ * D4: a target the gateway registry bound (C7 picker), carried to the session that executes the granted call under the
143
+ * same handle. A handle this registry already knows keeps its own record, so a suspension or close here is never undone.
144
+ */
145
+ adopt(input: Pick<AsideTarget, "handle" | "kind" | "host" | "account" | "targetId" | "origin" | "agentId" | "incarnation" | "selectionSeq">): AsideTarget {
146
+ const known = this.targets.get(input.handle);
147
+ if (known) return known;
148
+ const target: AsideTarget = { ...input, state: "active", snapshotRevision: null };
149
+ this.targets.set(target.handle, target);
150
+ return target;
151
+ }
152
+
153
+ registerOwned(input: { host: string; account: string; targetId: string; url: string; agentId: string; incarnation: number }): AsideTarget {
154
+ return this.add("owned", input, null);
155
+ }
156
+
157
+ private add(kind: AsideTarget["kind"], input: { host: string; account: string; targetId: string; url: string; agentId: string; incarnation: number },
158
+ selectionSeq: number | null): AsideTarget {
159
+ const origin = originOf(input.url);
160
+ if (origin === null) throw new Error("aside_target_origin_invalid");
161
+ const target: AsideTarget = { handle: `ash_${randomUUID()}`, kind, host: input.host, account: input.account, targetId: input.targetId, origin,
162
+ agentId: input.agentId, incarnation: input.incarnation, selectionSeq, state: "active", snapshotRevision: null };
163
+ this.targets.set(target.handle, target);
164
+ return target;
165
+ }
166
+
167
+ /** The target for this agent incarnation, or why it cannot be used. Another agent's handle is unknown to this one. */
168
+ resolve(handle: string, agentId: string, incarnation: number): AsideTarget | { readonly refused: AsideRefusal } {
169
+ const target = this.targets.get(handle);
170
+ if (!target || target.agentId !== agentId || target.incarnation !== incarnation) return { refused: "unknown_target" };
171
+ if (target.state === "closed") return { refused: "target_closed" };
172
+ if (target.state === "suspended") return { refused: "target_suspended" };
173
+ return target;
174
+ }
175
+
176
+ /** Keeps the record and marks it suspended; a new selection is needed to continue. */
177
+ suspend(target: AsideTarget): void {
178
+ target.state = "suspended";
179
+ target.snapshotRevision = null;
180
+ }
181
+
182
+ recordClose(target: AsideTarget, reason: string, outcome: CleanupReceipt["outcome"]): void {
183
+ if (outcome === "closed") target.state = "closed";
184
+ this.receipts.push({ handle: target.handle, targetId: target.targetId, reason, outcome, at: this.now().toISOString() });
185
+ }
186
+
187
+ ownedActive(agentId: string): AsideTarget[] {
188
+ return [...this.targets.values()].filter((target) => target.agentId === agentId && target.kind === "owned" && target.state !== "closed");
189
+ }
190
+
191
+ borrowed(agentId: string): AsideTarget[] {
192
+ return [...this.targets.values()].filter((target) => target.agentId === agentId && target.kind === "borrowed");
193
+ }
194
+ }
195
+
196
+ /** One-shot external action tickets (C8). The adapter consumes a ticket at start; a second start with it is refused. */
197
+ export class ActionTickets {
198
+ private readonly open = new Map<string, string>();
199
+
200
+ issue(digest: string): string {
201
+ const ticket = `act_${randomUUID()}`;
202
+ this.open.set(ticket, digest);
203
+ return ticket;
204
+ }
205
+
206
+ consume(ticket: string | undefined, digest: string): boolean {
207
+ if (!ticket || this.open.get(ticket) !== digest) return false;
208
+ this.open.delete(ticket);
209
+ return true;
210
+ }
211
+ }
212
+
213
+ export function actionDigest(handle: string, operation: "click" | "fill", ref: string, revision: string, text: string | null): string {
214
+ return createHash("sha256").update(JSON.stringify([handle, operation, ref, revision, text])).digest("hex");
215
+ }
@@ -0,0 +1,47 @@
1
+ import type { Store } from "../store.ts";
2
+ import type { AgentFileStore } from "./files.ts";
3
+ import { mergeAgentDefinition } from "./definition.ts";
4
+ import { CollaborationStoreError, type AgentRequest, type CollaborationGate, type CollaborationPermissionOutcome } from "./collaboration-types.ts";
5
+
6
+ export type CollaborationAuthority =
7
+ | { readonly mode: "standalone" }
8
+ | { readonly mode: "authority"; readonly provider?: Extract<CollaborationGate, { mode: "authority" }> };
9
+
10
+ /** One policy seam for HTTP admission and subsequent Hub dispatch; it never owns authority or sessions. */
11
+ export class CollaborationAdmission {
12
+ constructor(private readonly store: Store, private readonly files: AgentFileStore,
13
+ private readonly authority: CollaborationAuthority = { mode: "standalone" }) {}
14
+
15
+ gate(request?: AgentRequest): CollaborationGate {
16
+ const authority = this.authority;
17
+ if (authority.mode === "authority" && !authority.provider) throw new CollaborationStoreError("authority_adapter_required", 503);
18
+ const provider = authority.mode === "authority" ? authority.provider : undefined;
19
+ return { ...(provider ?? { mode: "standalone" }), assertAccess: (tx, source, target) => {
20
+ provider?.assertAccess(tx, source, target);
21
+ const sender = request ? request.requesterAgent : source.agentId;
22
+ const recipient = request?.targetAgent ?? target.agentId;
23
+ if (sender !== null) {
24
+ const fields = mergeAgentDefinition(this.files.read(sender), this.files.readDefaults()).fields;
25
+ const collaboration = fields.collaboration;
26
+ if (typeof collaboration !== "object" || collaboration === null || !("targets" in collaboration) ||
27
+ !Array.isArray(collaboration.targets) || !collaboration.targets.includes(recipient)) throw new CollaborationStoreError("target_forbidden");
28
+ if (request && this.store.ensureAgentLifecycle(sender) !== request.requesterLifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
29
+ if (request && provider) {
30
+ const access = provider.access(tx, sender);
31
+ if (access.accessState !== "open") throw new CollaborationStoreError("agent_access_blocked", 403);
32
+ if (access.authorityEpoch !== request.requesterAuthorityEpoch) throw new CollaborationStoreError("authority_epoch_mismatch");
33
+ }
34
+ }
35
+ return undefined;
36
+ } };
37
+ }
38
+
39
+ permission(outcome: CollaborationPermissionOutcome) {
40
+ const gate = this.gate();
41
+ if (gate.mode !== "authority") throw new CollaborationStoreError("authority_adapter_required", 503);
42
+ const request = this.store.getAgentRequest(outcome.collaborationRequestId);
43
+ const binding = request && this.store.agentBinding(request.targetConv);
44
+ if (!binding) throw new CollaborationStoreError("permission_outcome_conflict");
45
+ return this.store.dispatchCollaborationPermission(outcome, binding, gate);
46
+ }
47
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ import { NativeDocumentError, readNativeDocument, type NativeDocumentBuild, type NativeDocumentResult, type NativeTextCursor } from "../attachments/document-reader.ts";
3
+ import type { AttachmentFormat } from "../attachments/types.ts";
4
+ import type { ArtifactSecurityHold } from "../security-allow.ts";
5
+ import { CollaborationStoreError, collaborationDigest, type AgentArtifact } from "./collaboration-types.ts";
6
+
7
+ export type ArtifactReadArguments = ArtifactSecurityHold["readArguments"];
8
+ /**
9
+ * One release to a peer: the response, the texts the message security check scans, the reviewed representation, and
10
+ * the pages whose text layer the reader could not provide (nothing of them was scanned; the caller holds them).
11
+ */
12
+ export interface PresentedRead {
13
+ readonly body: Record<string, unknown>;
14
+ readonly texts: readonly string[];
15
+ readonly representation: Pick<ArtifactSecurityHold["representation"], "sha256" | "view" | "range" | "coverageDigest">;
16
+ readonly unscannedPages: readonly number[];
17
+ }
18
+
19
+ type PageRepresentation = NativeDocumentResult["coverage"]["pages"][number]["representation"];
20
+ /** The page classes whose content the text check could read; a page the reader classifies as rendered-only, or does not classify, was not scanned. */
21
+ const SCANNED_PAGE: ReadonlySet<PageRepresentation> = new Set<PageRepresentation>(["native-text", "ocr-text", "verified-blank"]);
22
+ /**
23
+ * The pages of a rendered range that no text check covered. The reader's text operation labels an image-only page
24
+ * native-text with an empty layer, so the per-page class comes from document.inspect, which reports rendered-image for
25
+ * a page without a text layer; such a page is held fail-closed, never released unchecked.
26
+ */
27
+ export function unscannedPages(rendered: NativeDocumentResult["coverage"], inspected: NativeDocumentResult["coverage"]): number[] {
28
+ const classes = new Map<number, PageRepresentation>(inspected.pages.map(page => [page.page, page.representation]));
29
+ return rendered.pages.map(page => page.page).filter(page => {
30
+ const seen = classes.get(page);
31
+ return seen === undefined || !SCANNED_PAGE.has(seen);
32
+ });
33
+ }
34
+
35
+ const sha = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
36
+ const MAX_TEXT_SCAN_CHUNKS = 64;
37
+
38
+ function format(artifact: AgentArtifact): AttachmentFormat | null {
39
+ const type = artifact.mediaType.split(";")[0]!.trim().toLowerCase(), name = artifact.originalName.toLowerCase();
40
+ if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
41
+ if (type === "text/markdown" || name.endsWith(".md")) return "md";
42
+ if (type === "text/html" || name.endsWith(".html") || name.endsWith(".htm")) return "html";
43
+ if (type.startsWith("text/") || name.endsWith(".txt")) return "txt";
44
+ return null;
45
+ }
46
+ const encode = (value: object): string => Buffer.from(JSON.stringify(value)).toString("base64url");
47
+ function decode(raw: string | undefined, artifact: AgentArtifact, view: "text" | "pages"): Record<string, unknown> | undefined {
48
+ if (raw === undefined) return undefined;
49
+ let value: unknown;
50
+ try { value = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); }
51
+ catch { throw new CollaborationStoreError("invalid_cursor", 400); }
52
+ if (typeof value !== "object" || value === null || Array.isArray(value) || (value as Record<string, unknown>).v !== view ||
53
+ (value as Record<string, unknown>).originalSha256 !== artifact.sha256) throw new CollaborationStoreError("invalid_cursor", 400);
54
+ return value as Record<string, unknown>;
55
+ }
56
+ function readerError(error: unknown): never {
57
+ if (!(error instanceof NativeDocumentError)) throw error;
58
+ switch (error.code) {
59
+ case "native_reader_unavailable": throw new CollaborationStoreError("native_reader_unavailable", 503);
60
+ case "invalid_cursor": case "invalid_document_options": throw new CollaborationStoreError(error.code, 400);
61
+ default: throw new CollaborationStoreError(error.code, 409);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Produces what files.read releases. Raw bytes are scanned whole (the download route hands the extension every byte);
67
+ * text and pages come only from the 293 native document reader, and pages are scanned through the text layer of the
68
+ * same pages. Nothing here releases: the caller checks `texts` and claims or holds.
69
+ */
70
+ export async function presentArtifactRead(input: { readonly artifact: AgentArtifact; readonly bytes: Buffer; readonly args: ArtifactReadArguments;
71
+ readonly reader: NativeDocumentBuild | null | undefined }): Promise<PresentedRead> {
72
+ const { artifact, bytes, args } = input;
73
+ const ids = { artifactId: artifact.id, requestId: args.requestId };
74
+ if (args.view === undefined) {
75
+ return { body: { ...ids, sha256: artifact.sha256, bytes: bytes.length, preview: bytes.subarray(0, 32768).toString("utf8"), truncated: bytes.length > 32768 },
76
+ texts: [bytes.toString("utf8")], unscannedPages: [],
77
+ representation: { sha256: artifact.sha256, view: "raw", range: { start: 0, end: bytes.length }, coverageDigest: collaborationDigest({ bytes: bytes.length }) } };
78
+ }
79
+ const selected = format(artifact);
80
+ if (selected === null || (args.view === "pages" && selected !== "pdf")) throw new CollaborationStoreError("view_unavailable", 400);
81
+ const reader = input.reader;
82
+ if (!reader) throw new CollaborationStoreError("native_reader_unavailable", 503);
83
+ const context = { signal: new AbortController().signal, isCurrent: () => true };
84
+ const read = (operation: "document.text" | "document.pages" | "document.inspect", options: Record<string, unknown>): Promise<NativeDocumentResult> =>
85
+ readNativeDocument(reader, { original: bytes, operation, options: { format: selected, ...options } }, context).catch(readerError);
86
+ if (args.view === "text") {
87
+ const cursor = decode(args.cursor, artifact, "text");
88
+ const native: NativeTextCursor | undefined = cursor && { originalSha256: artifact.sha256, representationSha256: String(cursor.representationSha256), offset: Number(cursor.offset) };
89
+ const result = await read("document.text", { ...(args.limit === undefined ? {} : { lineLimit: args.limit }), ...(native ? { cursor: native } : {}) });
90
+ const texts = result.content.flatMap(item => item.type === "text" ? [item.text] : []);
91
+ const range = result.textRange ?? { start: 0, end: texts.join("").length };
92
+ return { body: { ...ids, sha256: artifact.sha256, view: "text", coverage: result.coverage, content: result.content,
93
+ ...(result.nextCursor ? { nextCursor: encode({ v: "text", originalSha256: artifact.sha256, representationSha256: result.nextCursor.representationSha256, offset: result.nextCursor.offset }) } : {}) },
94
+ texts, unscannedPages: [], representation: { sha256: sha(JSON.stringify(result.content)), view: "text", range: { start: range.start, end: range.end }, coverageDigest: collaborationDigest(result.coverage) } };
95
+ }
96
+ const cursor = decode(args.cursor, artifact, "pages");
97
+ const firstPage = cursor ? Number(cursor.page) : 1, pageLimit = args.limit ?? 2;
98
+ const result = await read("document.pages", { firstPage, pageLimit });
99
+ const count = result.coverage.pages.length, next = firstPage + count;
100
+ // Rendered page images cannot be scanned as text; their selectable text layer is, chunk by chunk, for exactly these pages.
101
+ const texts: string[] = [];
102
+ let layer: NativeTextCursor | undefined;
103
+ for (let chunk = 0; ; chunk++) {
104
+ if (chunk === MAX_TEXT_SCAN_CHUNKS) throw new CollaborationStoreError("processing_limit", 409);
105
+ const part = await read("document.text", { firstPage, pageLimit: Math.max(count, 1), ...(layer ? { cursor: layer } : {}) });
106
+ texts.push(...part.content.flatMap(item => item.type === "text" ? [item.text] : []));
107
+ if (!part.nextCursor) break;
108
+ layer = part.nextCursor;
109
+ }
110
+ // A page without a text layer (document.inspect: rendered-image) was not scanned at all: the caller holds it, never releases it unchecked.
111
+ const inspected = await read("document.inspect", {});
112
+ return { body: { ...ids, sha256: artifact.sha256, view: "pages", coverage: result.coverage, content: result.content,
113
+ ...(next <= result.coverage.totalPages ? { nextCursor: encode({ v: "pages", originalSha256: artifact.sha256, page: next }) } : {}) },
114
+ texts, unscannedPages: unscannedPages(result.coverage, inspected.coverage),
115
+ representation: { sha256: sha(JSON.stringify([artifact.sha256, firstPage, pageLimit, result.coverage, texts])), view: "pages",
116
+ range: { start: firstPage, end: firstPage + count - 1 }, coverageDigest: collaborationDigest(result.coverage) } };
117
+ }
@@ -0,0 +1,196 @@
1
+ import { createSecretKey, randomBytes, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import type { Hub } from "../hub.ts";
4
+ import type { Store } from "../store.ts";
5
+ import type { ArtifactSecurityHold } from "../security-allow.ts";
6
+ import { OmoCommandError } from "../omo/client.ts";
7
+ import { CollaborationStoreError, collaborationDigest, type CollaborationBinding, type AgentRequest, type CollaborationGate, type CollaborationPermissionOutcome, type AgentActivation } from "./collaboration-types.ts";
8
+ import { parseManagedCallIdentity, sameManagedCallIdentity, verifyManagedCall,
9
+ type ManagedCallIdentity, type ManagedCallRequest } from "./managed-call.ts";
10
+
11
+ export const collaborationRecord = (value: unknown): value is Record<string, unknown> =>
12
+ value !== null && typeof value === "object" && !Array.isArray(value);
13
+ export const collaborationText = (value: unknown): value is string => typeof value === "string" && value.length > 0;
14
+ export type CollaborationLifecycle = {
15
+ readonly bind: (conv: string, inputId: string, dispatch?: CollaborationDispatch) => Promise<void>;
16
+ readonly gate: (request: AgentRequest) => CollaborationGate;
17
+ readonly permission: (outcome: CollaborationPermissionOutcome) => AgentActivation;
18
+ /** Supplied only by the actual registered document/owner adapter; absence is not authority. */
19
+ readonly artifactRead: (hold: ArtifactSecurityHold) => CollaborationGate;
20
+ readonly retire: (conv?: string) => void;
21
+ };
22
+ export type CollaborationDispatch = {
23
+ readonly activationId: string | null;
24
+ readonly requestId: string | null;
25
+ readonly rootId: string | null;
26
+ readonly read?: { readonly continuationId: string; readonly arguments: ArtifactSecurityHold["readArguments"] };
27
+ readonly claim: (binding: CollaborationBinding) => void;
28
+ };
29
+ type Session = {
30
+ readonly lease: NonNullable<ReturnType<Hub["collaborationSession"]>>;
31
+ readonly identity: ManagedCallIdentity;
32
+ readonly binding: CollaborationBinding;
33
+ readonly requestId: string | null;
34
+ readonly rootId: string | null;
35
+ readonly secret: ReturnType<typeof createSecretKey>;
36
+ readonly nonces: Set<string>;
37
+ };
38
+ const reject: (code: string) => never = code => { throw new CollaborationStoreError(code, 403); };
39
+
40
+ /** Gateway-owned secrets and nonce claims share the same lifetime as the actual Hub session lease. */
41
+ export class CollaborationSessions {
42
+ private readonly sessions = new Map<string, Session>();
43
+ constructor(private readonly hub: Pick<Hub, "collaborationSession">, private readonly store: Store) {}
44
+ retire(conv?: string): void {
45
+ if (conv === undefined) this.sessions.clear();
46
+ else this.sessions.delete(conv);
47
+ }
48
+ assertCurrent(session: Session): void {
49
+ if (this.sessions.get(session.binding.convId) !== session || !session.lease.current() ||
50
+ !isDeepStrictEqual(this.store.agentBinding(session.binding.convId), session.binding)) reject("binding_mismatch");
51
+ }
52
+ async bind(conv: string, inputId: string, dispatch?: CollaborationDispatch): Promise<void> {
53
+ this.retire(conv);
54
+ const lease = this.hub.collaborationSession(conv);
55
+ if (!lease) throw new CollaborationStoreError("collaboration_session_unavailable", 503);
56
+ const [state, runtime, listed] = await Promise.all([
57
+ lease.rpc("get_state"), lease.rpc("extension_request", { name: "zgw.agent_collaboration_runtime" }),
58
+ lease.rpc("list_sessions", { include_all: true, include_workers: true }),
59
+ ]);
60
+ if (!lease.current() || !collaborationRecord(state) || state.sessionId !== lease.durableId ||
61
+ state.sessionFile !== lease.sessionPath || !collaborationRecord(runtime) ||
62
+ !collaborationText(runtime.definitionSha) || !collaborationText(runtime.fingerprint) ||
63
+ !this.listed(listed, lease)) reject("native_binding_unavailable");
64
+ const binding: CollaborationBinding = Object.freeze({
65
+ convId: conv, agentId: lease.agentId, lifecycleEpoch: lease.agentId === null ? 1 : this.store.ensureAgentLifecycle(lease.agentId),
66
+ inputId, activationId: dispatch?.activationId ?? null, sessionPath: lease.sessionPath, durableSessionId: lease.durableId,
67
+ gatewayGeneration: lease.generation, definitionSha: runtime.definitionSha, ownerInputRevision: lease.ownerInputRevision,
68
+ ...(dispatch?.read ? { readContinuationId: dispatch.read.continuationId } : {}),
69
+ });
70
+ const identity = parseManagedCallIdentity({
71
+ role: lease.agentId === null ? "main" : "agent", agentId: lease.agentId, convId: conv,
72
+ convEpoch: lease.convEpoch, lifecycleEpoch: binding.lifecycleEpoch,
73
+ sessionPath: lease.sessionPath, durableSessionId: lease.durableId, sessionIncarnation: lease.incarnation,
74
+ gatewayGeneration: lease.generation, inputId, activationId: binding.activationId, toolCallId: "binding", logicalCallId: "binding",
75
+ });
76
+ const bytes = randomBytes(32);
77
+ const session: Session = { lease, identity, binding, requestId: dispatch?.requestId ?? null, rootId: dispatch?.rootId ?? null,
78
+ secret: createSecretKey(bytes), nonces: new Set() };
79
+ const reservationId = dispatch ? randomUUID() : undefined;
80
+ let reserved = false;
81
+ try {
82
+ const bound = await lease.rpc("extension_request", { name: "zgw.agent_collaboration_bind",
83
+ data: { identity, binding, secret: bytes.toString("hex"), requestId: session.requestId, rootId: session.rootId, inputEvidence: "client_entry",
84
+ ...(reservationId ? { reservationId } : {}), ...(dispatch?.read ? { read: { operation: "read", ...dispatch.read.arguments } } : {}) } })
85
+ .catch((error: unknown) => {
86
+ // The runtime applied another definition after the handshake read: a definite refusal, never unknown delivery.
87
+ if (error instanceof OmoCommandError && error.code === "version_conflict") throw new CollaborationStoreError("version_conflict", 409);
88
+ throw error;
89
+ });
90
+ reserved = reservationId !== undefined;
91
+ if (!lease.current() || !collaborationRecord(bound) || !this.matchesBase(bound, identity)) reject("native_binding_unavailable");
92
+ if (dispatch) dispatch.claim(binding);
93
+ else this.store.recordAgentBinding(binding);
94
+ if (reservationId) {
95
+ const armed = await lease.rpc("extension_request", { name: "zgw.agent_collaboration_reservation",
96
+ data: { action: "arm", reservationId, identity } });
97
+ if (!lease.current() || !collaborationRecord(armed) || armed.inputId !== inputId || armed.state !== "armed") reject("native_binding_unavailable");
98
+ }
99
+ this.sessions.set(conv, session);
100
+ } catch (error) {
101
+ // A callback can throw after committing. Query Store evidence rather than trusting callback completion.
102
+ const activation = binding.activationId === null ? null : this.store.getAgentActivation(binding.activationId);
103
+ const read = binding.readContinuationId === undefined ? null : this.store.nextArtifactReadInput(conv)?.hold;
104
+ const unclaimed = activation ? activation.state === "queued" && activation.binding === null && activation.inputId === inputId :
105
+ read?.continuation.id === binding.readContinuationId && read?.continuation.state === "queued" &&
106
+ read.continuation.targetBinding === null && read.continuation.dispatchReceiptId === null;
107
+ if (reservationId && reserved && unclaimed) {
108
+ const released = await lease.rpc("extension_request", { name: "zgw.agent_collaboration_reservation",
109
+ data: { action: "release", reservationId, identity } });
110
+ if (!collaborationRecord(released) || released.inputId !== inputId || released.state !== "released") reject("native_binding_unavailable");
111
+ }
112
+ throw error;
113
+ } finally { bytes.fill(0); }
114
+ }
115
+ private listed(value: unknown, lease: Session["lease"]): boolean {
116
+ return collaborationRecord(value) && Array.isArray(value.sessions) && value.sessions.some((row: unknown) =>
117
+ collaborationRecord(row) && row.sessionId === lease.sessionId && row.sessionPath === lease.sessionPath && row.status === "open");
118
+ }
119
+ private matchesBase(value: Record<string, unknown>, identity: ManagedCallIdentity): boolean {
120
+ return Object.entries(identity).every(([key, field]) => value[key] === field);
121
+ }
122
+ async authenticate(request: ManagedCallRequest & { readonly proof: unknown; readonly logicalPayload: unknown }) {
123
+ if (!collaborationRecord(request.proof)) return reject("invalid_proof");
124
+ const selector = parseManagedCallIdentity(request.proof.identity);
125
+ const session = this.sessions.get(selector.convId);
126
+ if (!session) return reject("binding_mismatch");
127
+ this.assertCurrent(session);
128
+ const [listed, native, state, transcript] = await Promise.all([
129
+ session.lease.rpc("list_sessions", { include_all: true, include_workers: true }),
130
+ session.lease.rpc("extension_request", { name: "zgw.agent_collaboration_identity" }),
131
+ session.lease.rpc("get_state"), session.lease.rpc("get_entries"),
132
+ ]);
133
+ this.assertCurrent(session);
134
+ if (!this.listed(listed, session.lease) || !collaborationRecord(native) ||
135
+ !this.matchesBase(native, session.identity) || native.consumed !== true ||
136
+ !collaborationRecord(state) || state.sessionId !== session.identity.durableSessionId ||
137
+ state.sessionFile !== session.identity.sessionPath) return reject("native_binding_mismatch");
138
+ // Follow the actual active branch to the last consumed input. Tool names/ordinals come from native entries.
139
+ if (!collaborationRecord(transcript) || !Array.isArray(transcript.entries) || !collaborationText(transcript.leafId)) return reject("native_invocation_missing");
140
+ const entries = new Map(transcript.entries.filter(collaborationRecord).filter(e => collaborationText(e.id)).map(e => [e.id, e]));
141
+ const seen = new Set<string>();
142
+ let cursor: unknown = transcript.leafId, logical: string | undefined, anchored = false;
143
+ while (collaborationText(cursor) && !seen.has(cursor)) {
144
+ seen.add(cursor);
145
+ const entry = entries.get(cursor);
146
+ if (!entry || !collaborationText(entry.id)) break;
147
+ const message = entry.message;
148
+ if (entry.type === "message" && collaborationRecord(message)) {
149
+ if (message.role === "user") {
150
+ anchored = message.clientInputId === session.identity.inputId && native.inputEntryId === entry.id;
151
+ break;
152
+ }
153
+ if (message.role === "assistant" && Array.isArray(message.content)) {
154
+ const ordinal = message.content.findIndex((part: unknown) => collaborationRecord(part) &&
155
+ part.type === "toolCall" && part.id === selector.toolCallId &&
156
+ part.name === `zgw_agent_${request.operation.split(".")[0]}`);
157
+ if (ordinal >= 0 && logical === undefined) logical = JSON.stringify([session.identity.durableSessionId, entry.id, ordinal]);
158
+ }
159
+ }
160
+ cursor = entry.parentId;
161
+ }
162
+ if (!anchored || !logical) return reject("native_invocation_missing");
163
+ const identity = parseManagedCallIdentity({ ...session.identity, toolCallId: selector.toolCallId, logicalCallId: logical });
164
+ const verified = verifyManagedCall(request, candidate => {
165
+ this.assertCurrent(session);
166
+ if (!sameManagedCallIdentity(candidate, identity)) return null;
167
+ return { identity, secret: session.secret, claimNonce: nonce => {
168
+ this.assertCurrent(session);
169
+ if (session.nonces.has(nonce)) return false;
170
+ session.nonces.add(nonce); return true;
171
+ } };
172
+ });
173
+ // Preserve the accepted native submission key. A rotated alias is unknown, never a fresh execution.
174
+ const operationKey = JSON.stringify([identity.durableSessionId, identity.toolCallId, request.operation]);
175
+ const payloadSha = collaborationDigest(request.logicalPayload);
176
+ const mappings = this.store.eventsAfter(identity.convId, 0).filter(event => event.type === "agent.collaboration.call" && collaborationRecord(event.data));
177
+ const prior = mappings.find(event => collaborationRecord(event.data) && event.data.logicalCallId === logical && event.data.operation === request.operation);
178
+ const wire = mappings.find(event => collaborationRecord(event.data) && event.data.operationKey === operationKey);
179
+ if (wire && (!collaborationRecord(wire.data) || wire.data.logicalCallId !== logical)) throw new CollaborationStoreError("logical_call_unknown");
180
+ if (prior && collaborationRecord(prior.data) && prior.data.payloadSha !== payloadSha) throw new CollaborationStoreError("submission_conflict");
181
+ if (prior && (!collaborationRecord(prior.data) || prior.data.operationKey !== operationKey ||
182
+ prior.data.inputId !== identity.inputId || prior.data.incarnation !== identity.sessionIncarnation)) {
183
+ throw new CollaborationStoreError("logical_call_unknown");
184
+ }
185
+ let operationId: string;
186
+ if (prior) {
187
+ if (!collaborationRecord(prior.data) || !collaborationText(prior.data.operationId)) throw new CollaborationStoreError("logical_call_unknown");
188
+ operationId = prior.data.operationId;
189
+ } else operationId = randomUUID();
190
+ if (!prior) this.store.appendEvent(identity.convId, "agent.collaboration.call", {
191
+ logicalCallId: logical, operation: request.operation, operationKey, operationId, payloadSha, inputId: identity.inputId, incarnation: identity.sessionIncarnation,
192
+ });
193
+ return { verified, binding: session.binding, requestId: session.requestId, rootId: session.rootId,
194
+ operationKey, operationId, assertCurrent: () => this.assertCurrent(session) };
195
+ }
196
+ }