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,410 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fchmodSync, fsyncSync, lstatSync, mkdirSync, openSync, rmdirSync, unlinkSync, writeSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { dlopen } from "bun:ffi";
5
+
6
+ const MAX = 10 * 1024 * 1024;
7
+ const MAX_PICKERS = 8;
8
+ const MAX_HANDLES = 20_000;
9
+ const STREAM_MAX = 16 * 1024 * 1024;
10
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
11
+ type Root = "home" | "computer" | "downloads";
12
+ type Format = "html" | "md" | "txt" | "pdf";
13
+ export interface PickerScope {
14
+ readonly ownerPrincipal: string;
15
+ readonly principalRevision: number;
16
+ readonly draftId: string;
17
+ readonly convId: string;
18
+ readonly convEpoch: number;
19
+ readonly recipient: { readonly kind: "main" | "agent"; readonly id: string; readonly epoch: number };
20
+ readonly generation: string;
21
+ }
22
+ export interface SourceIdentity {
23
+ readonly device: string; readonly inode: string; readonly size: number;
24
+ readonly mtimeNs: string; readonly ctimeNs: string; readonly nlink: number;
25
+ }
26
+ interface Location { readonly root: Root; readonly segments: readonly string[] }
27
+ export interface SelectedSource extends Location {
28
+ readonly name: string; readonly format: Format; readonly identity: SourceIdentity;
29
+ }
30
+ interface NativeEntry {
31
+ readonly name: string; readonly kind: "directory" | "file" | "disabled";
32
+ readonly selectable: boolean; readonly identity?: SourceIdentity; readonly reason?: string;
33
+ }
34
+ interface NativePage { readonly entries: readonly NativeEntry[]; readonly nextName?: string }
35
+ export interface PickerEntry {
36
+ readonly entryToken: string; readonly kind: NativeEntry["kind"]; readonly name: string;
37
+ readonly relativeDisplay: string; readonly selectable: boolean; readonly size?: number; readonly reason?: string;
38
+ }
39
+ export interface SelectionReceipt {
40
+ readonly attachmentId: string; readonly state: "sealed"; readonly revision: number;
41
+ }
42
+ export interface SealedOriginal {
43
+ readonly attachmentId: string; readonly name: string; readonly format: Format;
44
+ readonly bytes: number; readonly sha256: string;
45
+ }
46
+ type Code = "invalid_picker_input" | "picker_scope_mismatch" | "picker_expired" | "picker_closed" | "invalid_token"
47
+ | "invalid_cursor" | "operation_conflict" | "entry_disabled" | "host_access_denied" | "file_changed"
48
+ | "processing_limit" | "invalid_pdf" | "native_protocol" | "seal_conflict";
49
+ export class HostSelectorError extends Error {
50
+ constructor(readonly code: Code) { super(code); this.name = "HostSelectorError"; }
51
+ }
52
+ function fail(code: Code): never { throw new HostSelectorError(code); }
53
+ function object(value: unknown): value is Record<string, unknown> {
54
+ return value !== null && typeof value === "object" && !Array.isArray(value);
55
+ }
56
+ function shape(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
57
+ if (!object(value) || required.some(k => !(k in value)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) {
58
+ return fail("native_protocol");
59
+ }
60
+ return value;
61
+ }
62
+ function component(value: unknown): value is string {
63
+ return typeof value === "string" && value.length > 0 && value !== "." && value !== ".." && !/[\/\0]/u.test(value);
64
+ }
65
+ function identity(value: unknown): SourceIdentity {
66
+ const v = shape(value, ["device", "inode", "size", "mtimeNs", "ctimeNs", "nlink"]);
67
+ for (const [key, min, max] of [
68
+ ["device", -2147483648n, 2147483647n], ["inode", 0n, 18446744073709551615n],
69
+ ["mtimeNs", -9223372036854775808000000000n, 9223372036854775807999999999n],
70
+ ["ctimeNs", -9223372036854775808000000000n, 9223372036854775807999999999n],
71
+ ] as const) {
72
+ const n = v[key];
73
+ if (typeof n !== "string" || !/^(?:0|-?[1-9][0-9]*)$/.test(n) || n.length > 28 || BigInt(n) < min || BigInt(n) > max) fail("native_protocol");
74
+ }
75
+ if (typeof v.device !== "string" || typeof v.inode !== "string" || typeof v.mtimeNs !== "string" ||
76
+ typeof v.ctimeNs !== "string" || typeof v.size !== "number" || !Number.isSafeInteger(v.size) ||
77
+ v.size < 1 || v.size > MAX || v.nlink !== 1) return fail("native_protocol");
78
+ return Object.freeze({ device: v.device, inode: v.inode, size: v.size, mtimeNs: v.mtimeNs, ctimeNs: v.ctimeNs, nlink: 1 });
79
+ }
80
+ const sameIdentity = (a: SourceIdentity, b: SourceIdentity) => a.device === b.device && a.inode === b.inode &&
81
+ a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
82
+ function format(name: string): Format | undefined {
83
+ switch (name.slice(name.lastIndexOf(".") + 1).toLowerCase()) {
84
+ case "html": case "htm": return "html";
85
+ case "md": case "markdown": return "md";
86
+ case "txt": return "txt";
87
+ case "pdf": return "pdf";
88
+ }
89
+ }
90
+ function page(value: Record<string, unknown>): NativePage {
91
+ shape(value, ["requestId", "type", "entries"], ["nextName"]);
92
+ if (!Array.isArray(value.entries) || value.entries.length > 50 || (value.nextName !== undefined && !component(value.nextName))) return fail("native_protocol");
93
+ let previous: string | undefined;
94
+ const entries = value.entries.map((raw: unknown): NativeEntry => {
95
+ const e = shape(raw, ["name", "kind", "selectable"], ["identity", "reason"]);
96
+ if (!component(e.name) || (previous !== undefined && Buffer.compare(Buffer.from(previous), Buffer.from(e.name)) >= 0)) return fail("native_protocol");
97
+ previous = e.name;
98
+ switch (e.kind) {
99
+ case "directory":
100
+ if (e.selectable !== true || e.identity !== undefined || e.reason !== undefined) return fail("native_protocol");
101
+ return { name: e.name, kind: "directory", selectable: true };
102
+ case "file":
103
+ if (e.selectable !== true || !format(e.name) || e.reason !== undefined) return fail("native_protocol");
104
+ return { name: e.name, kind: "file", selectable: true, identity: identity(e.identity) };
105
+ case "disabled":
106
+ if (e.selectable !== false || e.identity !== undefined || typeof e.reason !== "string" || !e.reason) return fail("native_protocol");
107
+ return { name: e.name, kind: "disabled", selectable: false, reason: e.reason };
108
+ default: return fail("native_protocol");
109
+ }
110
+ });
111
+ if (value.nextName !== undefined && (entries.length !== 50 || value.nextName !== entries.at(-1)?.name)) fail("native_protocol");
112
+ return { entries, ...(typeof value.nextName === "string" ? { nextName: value.nextName } : {}) };
113
+ }
114
+
115
+ /** Private host adapter; callers must supply already-authenticated human scope.
116
+ * No model tool, HTTP route, parser operation or runtime compiler is registered here. */
117
+ export class NativeSelector {
118
+ constructor(private readonly executable: string, private readonly env: NodeJS.ProcessEnv = process.env) {}
119
+ list(location: Location, query: string, showHidden: boolean, afterName?: string, signal?: AbortSignal): Promise<NativePage> {
120
+ return this.exchange({ operation: "selector.list", root: location.root, segments: location.segments, query, showHidden,
121
+ ...(afterName === undefined ? {} : { afterName }) }, undefined, undefined, signal).then(result => {
122
+ if (!result.page) return fail("native_protocol");
123
+ return result.page;
124
+ });
125
+ }
126
+ snapshot(source: SelectedSource, chunk: (bytes: Buffer) => void, signal?: AbortSignal) {
127
+ return this.exchange({ operation: "selector.snapshot", root: source.root, segments: source.segments,
128
+ expectedIdentity: source.identity }, source.identity, chunk, signal);
129
+ }
130
+ private async exchange(body: Readonly<Record<string, unknown>>, expected?: SourceIdentity,
131
+ onChunk?: (bytes: Buffer) => void, signal?: AbortSignal): Promise<{ readonly page?: NativePage; readonly bytes: number; readonly sha256: string }> {
132
+ if (signal?.aborted) return fail("picker_closed");
133
+ const requestId = randomUUID();
134
+ const child = Bun.spawn([this.executable], { env: this.env, stdin: new Blob([JSON.stringify({ version: 1, requestId, ...body })]),
135
+ stdout: "pipe", stderr: "pipe" });
136
+ let timedOut = false;
137
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, 60_000);
138
+ const abort = () => child.kill("SIGKILL");
139
+ signal?.addEventListener("abort", abort, { once: true });
140
+ // Drain stderr without retaining native paths or unbounded diagnostics.
141
+ const stderr = (async () => { for await (const _ of child.stderr) { /* intentionally discard diagnostics */ } })();
142
+ let total = 0, index = 0, streamBytes = 0;
143
+ let pending = Buffer.alloc(0);
144
+ let terminal = false;
145
+ let listing: NativePage | undefined;
146
+ let failure: Code | undefined;
147
+ let sha256 = "";
148
+ const hash = createHash("sha256");
149
+ try {
150
+ for await (const part of child.stdout) {
151
+ streamBytes += part.length;
152
+ if (streamBytes > STREAM_MAX) fail("processing_limit");
153
+ pending = Buffer.concat([pending, part]);
154
+ let newline: number;
155
+ while ((newline = pending.indexOf(10)) >= 0) {
156
+ const line = pending.subarray(0, newline); pending = pending.subarray(newline + 1);
157
+ if (terminal || line.length === 0) fail("native_protocol");
158
+ let raw: unknown;
159
+ try { raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line)); }
160
+ catch (error) { if (error instanceof SyntaxError || error instanceof TypeError) fail("native_protocol"); throw error; }
161
+ if (!object(raw) || raw.requestId !== requestId) fail("native_protocol");
162
+ switch (raw.type) {
163
+ case "selector.listed":
164
+ if (expected) fail("native_protocol");
165
+ listing = page(raw); terminal = true; break;
166
+ case "chunk": {
167
+ shape(raw, ["requestId", "type", "index", "base64"]);
168
+ if (!expected || !onChunk || raw.index !== index++ || typeof raw.base64 !== "string" ||
169
+ raw.base64.length > 87_384) return fail("native_protocol");
170
+ const bytes = Buffer.from(raw.base64, "base64");
171
+ if (!bytes.length || bytes.toString("base64") !== raw.base64) fail("native_protocol");
172
+ total += bytes.length;
173
+ if (total > MAX || total > expected.size) fail("processing_limit");
174
+ hash.update(bytes); onChunk(bytes); break;
175
+ }
176
+ case "snapshot.complete":
177
+ shape(raw, ["requestId", "type", "before", "after", "bytes", "sha256"]);
178
+ if (!expected || !sameIdentity(identity(raw.before), expected) || !sameIdentity(identity(raw.after), expected) ||
179
+ total < 1 || raw.bytes !== total || total !== expected.size) fail("file_changed");
180
+ sha256 = hash.digest("hex");
181
+ if (raw.sha256 !== sha256) fail("native_protocol");
182
+ terminal = true; break;
183
+ case "error":
184
+ shape(raw, ["requestId", "type", "code"]);
185
+ switch (raw.code) {
186
+ case "host_access_denied": case "file_changed": case "processing_limit":
187
+ failure = raw.code; terminal = true; break;
188
+ default: fail("native_protocol");
189
+ }
190
+ break;
191
+ default: fail("native_protocol");
192
+ }
193
+ }
194
+ }
195
+ const exit = await child.exited;
196
+ if (signal?.aborted) fail("picker_closed");
197
+ if (timedOut) fail("processing_limit");
198
+ if (failure) fail(failure);
199
+ if (exit !== 0 || !terminal || pending.length !== 0) fail("native_protocol");
200
+ return { ...(listing ? { page: listing } : {}), bytes: total, sha256 };
201
+ } finally {
202
+ clearTimeout(timer); signal?.removeEventListener("abort", abort);
203
+ child.kill(); await child.exited; await stderr;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Darwin RENAME_EXCL is an atomic no-replace rename, unlike node:fs.rename.
209
+ // Retain the library for the lifetime of this module and pass directory descriptors.
210
+ const libc = dlopen("/usr/lib/libSystem.B.dylib", {
211
+ renameatx_np: { args: ["i32", "cstring", "i32", "cstring", "u32"], returns: "i32" },
212
+ });
213
+ const errorCode = (error: unknown) => error instanceof Error && "code" in error ? error.code : undefined;
214
+ // A failed seal keeps no source bytes. An original published by another writer stays.
215
+ function discard(directory: string) {
216
+ try { unlinkSync(join(directory, ".partial")); }
217
+ catch (error) { if (errorCode(error) !== "ENOENT") throw error; }
218
+ try { rmdirSync(directory); }
219
+ catch (error) { if (errorCode(error) !== "ENOTEMPTY") throw error; }
220
+ }
221
+ export async function sealOriginal(runtimeHome: string, attachmentId: string, source: SelectedSource,
222
+ native: NativeSelector, signal?: AbortSignal, beforePublish: () => void = () => undefined): Promise<SealedOriginal> {
223
+ if (!UUID.test(attachmentId)) return fail("invalid_picker_input");
224
+ const base = join(runtimeHome, "attachments");
225
+ try { mkdirSync(base, { mode: 0o700 }); }
226
+ catch (error) { if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error; }
227
+ const stat = lstatSync(base);
228
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700) fail("seal_conflict");
229
+ const directory = join(base, attachmentId);
230
+ try { mkdirSync(directory, { mode: 0o700 }); }
231
+ catch (error) { if (error instanceof Error && "code" in error && error.code === "EEXIST") fail("seal_conflict"); throw error; }
232
+ const dir = openSync(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
233
+ let file: number | undefined;
234
+ let published = false;
235
+ try {
236
+ file = openSync(join(directory, ".partial"), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
237
+ fchmodSync(file, 0o600);
238
+ const descriptor = file;
239
+ let bytes = 0; let prefix = Buffer.alloc(0);
240
+ const hash = createHash("sha256");
241
+ const result = await native.snapshot(source, chunk => {
242
+ bytes += chunk.length;
243
+ if (bytes > MAX) fail("processing_limit");
244
+ if (prefix.length < 5) prefix = Buffer.concat([prefix, chunk]).subarray(0, 5);
245
+ hash.update(chunk);
246
+ let offset = 0;
247
+ while (offset < chunk.length) {
248
+ const written = writeSync(descriptor, chunk, offset, chunk.length - offset);
249
+ if (written <= 0) fail("host_access_denied");
250
+ offset += written;
251
+ }
252
+ }, signal);
253
+ if (bytes < 1 || bytes > MAX || result.bytes !== bytes || result.sha256 !== hash.digest("hex")) fail("file_changed");
254
+ if (source.format === "pdf" && !prefix.equals(Buffer.from("%PDF-"))) fail("invalid_pdf");
255
+ fsyncSync(file); closeSync(file); file = undefined;
256
+ // Synchronous check and rename form the publication boundary: no await gap.
257
+ if (signal?.aborted) fail("picker_closed");
258
+ beforePublish();
259
+ if (libc.symbols.renameatx_np(dir, Buffer.from(".partial\0"), dir, Buffer.from("original\0"), 0x00000004) !== 0) fail("seal_conflict");
260
+ published = true;
261
+ fsyncSync(dir);
262
+ return Object.freeze({ attachmentId, name: source.name, format: source.format, bytes, sha256: result.sha256 });
263
+ } finally {
264
+ if (file !== undefined) closeSync(file);
265
+ closeSync(dir);
266
+ if (!published) discard(directory);
267
+ }
268
+ }
269
+
270
+ interface Handle extends Location { readonly row: NativeEntry; readonly parent?: string }
271
+ interface PickerRecord {
272
+ readonly scopeKey: string; readonly scope: PickerScope; readonly handles: Map<string, Handle>;
273
+ readonly cursors: Map<string, { readonly token: string; readonly query: string; readonly hidden: boolean; readonly after: string }>;
274
+ readonly abort: AbortController; closed: boolean; touched: number;
275
+ }
276
+ const scopeKey = (s: PickerScope) => JSON.stringify([s.ownerPrincipal, s.principalRevision, s.draftId, s.convId,
277
+ s.convEpoch, s.recipient.kind, s.recipient.id, s.recipient.epoch, s.generation]);
278
+ interface Options {
279
+ readonly native: NativeSelector; readonly runtimeHome: string; readonly generation: string;
280
+ readonly current: (scope: PickerScope) => boolean; readonly now?: () => number;
281
+ }
282
+ /** Maps are process-generation scoped. T6 owns HTTP authentication and durable T2
283
+ * operation/CAS integration. resolveSelection + sealOriginal allow T2's allocated
284
+ * UUID to be sealed without a second snapshot or a second authorization system. */
285
+ export class HostPicker {
286
+ private readonly pickers = new Map<string, PickerRecord>();
287
+ private readonly operations = new Map<string, { readonly fingerprint: string; readonly result: Promise<SelectionReceipt> }>();
288
+ constructor(private readonly options: Options) {}
289
+ private now() { return (this.options.now ?? Date.now)(); }
290
+ private scope(s: PickerScope) {
291
+ if (!s.ownerPrincipal || !UUID.test(s.draftId) || !s.convId || !s.recipient.id ||
292
+ !["main", "agent"].includes(s.recipient.kind) || ![s.principalRevision, s.convEpoch, s.recipient.epoch].every(n => Number.isSafeInteger(n) && n > 0) ||
293
+ s.generation !== this.options.generation || !this.options.current(s)) fail("picker_scope_mismatch");
294
+ }
295
+ private picker(s: PickerScope, id: string, retry = false) {
296
+ this.scope(s);
297
+ const p = this.pickers.get(id);
298
+ if (!p || p.scopeKey !== scopeKey(s)) return fail("picker_scope_mismatch");
299
+ if (!retry && p.closed) fail("picker_closed");
300
+ if (!retry && this.now() - p.touched >= 15 * 60_000) { p.abort.abort(); fail("picker_expired"); }
301
+ if (!retry) p.touched = this.now();
302
+ return p;
303
+ }
304
+ async create(s: PickerScope) {
305
+ this.scope(s);
306
+ const scope = Object.freeze({ ...s, recipient: Object.freeze({ ...s.recipient }) });
307
+ const p: PickerRecord = { scope, scopeKey: scopeKey(s), handles: new Map(), cursors: new Map(),
308
+ abort: new AbortController(), closed: false, touched: this.now() };
309
+ const pickerId = randomUUID();
310
+ const roots = (["home", "computer", "downloads"] as const).map(root => {
311
+ const name = root.charAt(0).toUpperCase() + root.slice(1);
312
+ const token = randomUUID();
313
+ p.handles.set(token, { root, segments: [], row: { name, kind: "directory", selectable: true } });
314
+ return { entryToken: token, kind: "directory" as const, name, relativeDisplay: name, selectable: true };
315
+ });
316
+ const home = roots[0], downloads = roots[2];
317
+ if (!home || !downloads) return fail("native_protocol");
318
+ let currentDirectoryToken = downloads.entryToken;
319
+ try { await this.options.native.list({ root: "downloads", segments: [] }, "", false, undefined, p.abort.signal); }
320
+ catch (error) {
321
+ if (!(error instanceof HostSelectorError) || error.code !== "host_access_denied") throw error;
322
+ await this.options.native.list({ root: "home", segments: [] }, "", false, undefined, p.abort.signal);
323
+ currentDirectoryToken = home.entryToken;
324
+ }
325
+ this.scope(s);
326
+ this.evict(s.ownerPrincipal);
327
+ this.pickers.set(pickerId, p);
328
+ return { pickerId, rootEntries: roots, currentDirectoryToken, revision: 1 };
329
+ }
330
+ async list(s: PickerScope, id: string, input: unknown) {
331
+ const p = this.picker(s, id);
332
+ if (!object(input) || Object.keys(input).some(k => !["directoryToken", "cursor", "query", "showHidden"].includes(k)) ||
333
+ typeof input.directoryToken !== "string" || (input.cursor !== undefined && typeof input.cursor !== "string") ||
334
+ (input.query !== undefined && (typeof input.query !== "string" || [...input.query].length > 200)) ||
335
+ (input.showHidden !== undefined && typeof input.showHidden !== "boolean")) return fail("invalid_picker_input");
336
+ const token = input.directoryToken;
337
+ const handle = p.handles.get(token);
338
+ if (!handle || handle.row.kind !== "directory") return fail("invalid_token");
339
+ const query = typeof input.query === "string" ? input.query : "", hidden = input.showHidden === true;
340
+ const cursor = typeof input.cursor === "string" ? p.cursors.get(input.cursor) : undefined;
341
+ if (input.cursor !== undefined && (!cursor || cursor.token !== token || cursor.query !== query || cursor.hidden !== hidden)) fail("invalid_cursor");
342
+ const result = await this.options.native.list(handle, query, hidden, cursor?.after, p.abort.signal);
343
+ this.picker(s, id);
344
+ if (p.handles.size + result.entries.length > MAX_HANDLES || p.cursors.size >= MAX_HANDLES) fail("picker_expired");
345
+ const entries = result.entries.map(row => {
346
+ const entryToken = randomUUID();
347
+ p.handles.set(entryToken, { root: handle.root, segments: [...handle.segments, row.name], row, parent: token });
348
+ return { entryToken, kind: row.kind, name: row.name, relativeDisplay: [...handle.segments, row.name].join("/"),
349
+ selectable: row.selectable, ...(row.identity ? { size: row.identity.size } : {}), ...(row.reason ? { reason: row.reason } : {}) };
350
+ });
351
+ let nextCursor: string | undefined;
352
+ if (result.nextName) {
353
+ nextCursor = randomUUID();
354
+ p.cursors.set(nextCursor, { token, query, hidden, after: result.nextName });
355
+ }
356
+ return { entries, ...(handle.parent ? { parentToken: handle.parent } : {}), ...(nextCursor ? { nextCursor } : {}) };
357
+ }
358
+ resolveSelection(s: PickerScope, id: string, token: string, retry = false): SelectedSource {
359
+ const p = this.picker(s, id, retry);
360
+ return this.selected(p, token);
361
+ }
362
+ /** The scope this principal opened the picker with; no other principal learns that it exists. */
363
+ scopeOf(ownerPrincipal: string, id: string): PickerScope {
364
+ const p = this.pickers.get(id);
365
+ if (!p || p.scope.ownerPrincipal !== ownerPrincipal) return fail("picker_scope_mismatch");
366
+ return p.scope;
367
+ }
368
+ /** Records live only for this process; expired ones go first and a principal keeps a bounded number. */
369
+ private evict(ownerPrincipal: string): void {
370
+ const mine: string[] = [];
371
+ for (const [id, p] of this.pickers) {
372
+ if (this.now() - p.touched >= 15 * 60_000) { p.abort.abort(); this.pickers.delete(id); }
373
+ else if (p.scope.ownerPrincipal === ownerPrincipal) mine.push(id);
374
+ }
375
+ for (const id of mine.slice(0, Math.max(0, mine.length - (MAX_PICKERS - 1)))) {
376
+ this.pickers.get(id)?.abort.abort();
377
+ this.pickers.delete(id);
378
+ }
379
+ }
380
+ private selected(p: PickerRecord, token: string): SelectedSource {
381
+ const handle = p.handles.get(token);
382
+ if (!handle) return fail("invalid_token");
383
+ const f = format(handle.row.name);
384
+ if (handle.row.kind !== "file" || !handle.row.selectable || !handle.row.identity || !f) return fail("entry_disabled");
385
+ return Object.freeze({ root: handle.root, segments: Object.freeze([...handle.segments]),
386
+ name: handle.row.name, format: f, identity: handle.row.identity });
387
+ }
388
+ async select(s: PickerScope, id: string, input: unknown): Promise<SelectionReceipt> {
389
+ if (!object(input) || Object.keys(input).some(k => !["entryToken", "operationId", "pickerId"].includes(k)) ||
390
+ typeof input.entryToken !== "string" || typeof input.operationId !== "string" || !UUID.test(input.operationId)) return fail("invalid_picker_input");
391
+ const p = this.picker(s, id, true);
392
+ const source = this.selected(p, input.entryToken);
393
+ const key = JSON.stringify([s.ownerPrincipal, s.draftId, input.operationId]);
394
+ const fingerprint = JSON.stringify([scopeKey(s), source]);
395
+ const existing = this.operations.get(key);
396
+ if (existing) {
397
+ if (existing.fingerprint !== fingerprint) fail("operation_conflict");
398
+ return existing.result;
399
+ }
400
+ this.picker(s, id);
401
+ const attachmentId = randomUUID();
402
+ const result = sealOriginal(this.options.runtimeHome, attachmentId, source, this.options.native, p.abort.signal,
403
+ () => { this.picker(s, id); }).then((): SelectionReceipt => Object.freeze({ attachmentId, state: "sealed", revision: 1 }));
404
+ this.operations.set(key, { fingerprint, result });
405
+ return result;
406
+ }
407
+ close(s: PickerScope, id: string) {
408
+ const p = this.picker(s, id, true); p.closed = true; p.abort.abort();
409
+ }
410
+ }
@@ -0,0 +1,83 @@
1
+ import { createSecretKey, randomBytes, randomUUID, type KeyObject } from "node:crypto";
2
+ import {
3
+ sameManagedCallIdentity, type ManagedCallBinding, type ManagedCallBindingResolver, type ManagedCallIdentity,
4
+ type VerifiedManagedCall,
5
+ } from "../agent/managed-call.ts";
6
+ import type { StoredEvent } from "../store.ts";
7
+
8
+ export interface ManagedSession {
9
+ readonly role: "main" | "agent";
10
+ readonly agentId: string | null;
11
+ readonly convId: string;
12
+ readonly convEpoch: number;
13
+ readonly lifecycleEpoch: number;
14
+ readonly sessionPath: string;
15
+ readonly durableSessionId: string;
16
+ }
17
+ interface Live {
18
+ readonly session: ManagedSession;
19
+ readonly incarnation: string;
20
+ readonly secret: KeyObject;
21
+ readonly live: () => boolean;
22
+ readonly nonces: Set<string>;
23
+ inputId: string | null;
24
+ inputSeq: number | null;
25
+ }
26
+ interface InputLedger {
27
+ getEvent(seq: number): StoredEvent | null;
28
+ mainInputConsumed(input: StoredEvent): boolean;
29
+ }
30
+ const NONCE_LIMIT = 65_536;
31
+ export const attachmentReadCall = (toolCallId: string) => `attachment-read:${toolCallId}`;
32
+ export const attachmentDocumentCall = (toolCallId: string) => `attachment-document:${toolCallId}`;
33
+
34
+ /**
35
+ * Gateway custody of per-session secrets and call nonces. Each bind rotates the secret, so a
36
+ * reconnect, rotation or gateway restart can never reuse an earlier incarnation's proof.
37
+ */
38
+ export class ManagedBindings {
39
+ readonly generation = randomUUID();
40
+ private readonly byConv = new Map<string, Live>();
41
+ constructor(private readonly inputs: InputLedger) {}
42
+
43
+ bind(session: ManagedSession, live: () => boolean): { secret: string; identity: Omit<ManagedCallIdentity, "inputId" | "toolCallId" | "logicalCallId"> } {
44
+ const raw = randomBytes(32), incarnation = randomUUID();
45
+ this.byConv.set(session.convId, { session, incarnation, secret: createSecretKey(raw), live, nonces: new Set(), inputId: null, inputSeq: null });
46
+ return { secret: raw.toString("hex"), identity: { ...session, sessionIncarnation: incarnation, gatewayGeneration: this.generation, activationId: null } };
47
+ }
48
+ setInput(convId: string, incarnation: string, inputId: string, inputSeq: number): void {
49
+ const record = this.byConv.get(convId);
50
+ if (record?.incarnation !== incarnation) return;
51
+ record.inputId = inputId;
52
+ record.inputSeq = inputSeq;
53
+ }
54
+ retire(convId: string): void { this.byConv.delete(convId); }
55
+
56
+ private expected(record: Live, identity: ManagedCallIdentity): ManagedCallIdentity | null {
57
+ const document = identity.logicalCallId === attachmentDocumentCall(identity.toolCallId);
58
+ if (!record.live() || record.inputId === null || record.inputSeq === null || identity.inputId !== record.inputId ||
59
+ (!document && identity.logicalCallId !== attachmentReadCall(identity.toolCallId))) return null;
60
+ const input = this.inputs.getEvent(record.inputSeq);
61
+ // The engine resolves a document before the consumed event reaches the gateway; the input is still this session's admitted one.
62
+ if (input === null || (!document && !this.inputs.mainInputConsumed(input))) return null;
63
+ return { ...record.session, sessionIncarnation: record.incarnation, gatewayGeneration: this.generation,
64
+ inputId: record.inputId, toolCallId: identity.toolCallId, logicalCallId: identity.logicalCallId, activationId: null };
65
+ }
66
+ readonly resolver: ManagedCallBindingResolver = (identity): ManagedCallBinding | null => {
67
+ const record = this.byConv.get(identity.convId);
68
+ if (!record) return null;
69
+ const expected = this.expected(record, identity);
70
+ if (!expected) return null;
71
+ return { identity: expected, secret: record.secret, claimNonce: nonce => {
72
+ if (record.nonces.has(nonce) || record.nonces.size >= NONCE_LIMIT) return false;
73
+ record.nonces.add(nonce);
74
+ return true;
75
+ } };
76
+ };
77
+ /** Final check before content release: a newer input or rebind makes this call stale. */
78
+ isCurrent(call: VerifiedManagedCall): boolean {
79
+ const record = this.byConv.get(call.convId);
80
+ const expected = record && this.expected(record, call);
81
+ return !!expected && sameManagedCallIdentity(expected, call);
82
+ }
83
+ }