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,647 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ // SIZE_OK: the T3 shared-file freeze requires the entire native wire seam in this file.
5
+ // No selector, document parser, authorization, sandbox, or process supervisor lives here.
6
+ enum HelperProtocolError: String, Error {
7
+ case invalidJSON = "invalid_json"
8
+ case invalidShape = "invalid_shape"
9
+ case invalidOperation = "invalid_operation"
10
+ case wrongPrivilege = "wrong_privilege"
11
+ case invalidIdentity = "invalid_identity"
12
+ case invalidBounds = "invalid_bounds"
13
+ case invalidBase64 = "invalid_base64"
14
+ case requestMismatch = "request_mismatch"
15
+ case terminalState = "terminal_state"
16
+ case missingTerminal = "missing_terminal"
17
+ case invalidSequence = "invalid_sequence"
18
+ case invalidSnapshot = "invalid_snapshot"
19
+ case invalidCursor = "invalid_cursor"
20
+ }
21
+
22
+ enum HelperPrivilege: String {
23
+ case selector, document
24
+ init(argument: String) throws {
25
+ guard let value = Self(rawValue: argument) else { throw HelperProtocolError.wrongPrivilege }
26
+ self = value
27
+ }
28
+ }
29
+
30
+ enum HelperLimits {
31
+ static let originalBytes = 10 * 1024 * 1024
32
+ static let requestBytes = 13_981_016 + 65_536
33
+ static let streamBytes = 16 * 1024 * 1024
34
+ static let frameBytes = 16 * 1024 * 1024
35
+ static let textBytes = 48 * 1024
36
+ static let preparedTextBytes = 20 * 1024 * 1024
37
+ static let imageBytes = 4 * 1024 * 1024
38
+ }
39
+
40
+ struct NativeIdentity: Codable, Equatable {
41
+ let device: String
42
+ let inode: String
43
+ let size: Int
44
+ let mtimeNs: String
45
+ let ctimeNs: String
46
+ let nlink: Int
47
+
48
+ func validate() throws {
49
+ guard let dev = Int32(device), String(dev) == device,
50
+ let ino = UInt64(inode), String(ino) == inode,
51
+ Self.validNanoseconds(mtimeNs), Self.validNanoseconds(ctimeNs),
52
+ (1...HelperLimits.originalBytes).contains(size), nlink == 1 else {
53
+ throw HelperProtocolError.invalidIdentity
54
+ }
55
+ }
56
+
57
+ private static func validNanoseconds(_ value: String) -> Bool {
58
+ guard HelperCodec.matches(value, #"^(?:0|-?[1-9][0-9]*)$"#) else { return false }
59
+ let negative = value.hasPrefix("-")
60
+ let magnitude = negative ? String(value.dropFirst()) : value
61
+ let maximum = negative ? "9223372036854775808000000000" : "9223372036854775807999999999"
62
+ return magnitude.count < maximum.count || (magnitude.count == maximum.count && magnitude <= maximum)
63
+ }
64
+
65
+ // time_t * 1e9 must never be computed in Int64 or Double.
66
+ static func nanoseconds(seconds: Int64, nanoseconds: Int) throws -> String {
67
+ guard (0..<1_000_000_000).contains(nanoseconds) else { throw HelperProtocolError.invalidIdentity }
68
+ let negative = seconds < 0
69
+ let magnitude = seconds.magnitude - (negative && nanoseconds > 0 ? 1 : 0)
70
+ let fraction = negative && nanoseconds > 0 ? 1_000_000_000 - nanoseconds : nanoseconds
71
+ let joined = String(magnitude) + String(repeating: "0", count: 9 - String(fraction).count) + String(fraction)
72
+ let trimmed = joined.drop(while: { $0 == "0" })
73
+ return trimmed.isEmpty ? "0" : (negative ? "-" : "") + trimmed
74
+ }
75
+ }
76
+
77
+ enum SelectorOperation: String, Codable { case list = "selector.list", snapshot = "selector.snapshot" }
78
+ enum SelectorRoot: String, Codable { case home, computer, downloads }
79
+ struct SelectorRequest: Codable {
80
+ let version: Int
81
+ let requestId: String
82
+ let operation: SelectorOperation
83
+ let root: SelectorRoot
84
+ let segments: [String]
85
+ let showHidden: Bool?
86
+ let query: String?
87
+ let afterName: String?
88
+ let expectedIdentity: NativeIdentity?
89
+ }
90
+
91
+ enum DocumentOperation: String, Codable {
92
+ case inspect = "document.inspect", text = "document.text", pages = "document.pages", ocr = "document.ocr"
93
+ }
94
+ enum DocumentFormat: String, Codable { case html, md, txt, pdf }
95
+ enum DocumentEncoding: String, Codable {
96
+ case utf8 = "utf-8", utf16le = "utf-16le", utf16be = "utf-16be", eucKR = "euc-kr", cp949
97
+ }
98
+ struct DocumentOptions: Codable {
99
+ let format: DocumentFormat
100
+ let encoding: DocumentEncoding?
101
+ let firstPage: Int?
102
+ let pageLimit: Int?
103
+ let lineLimit: Int?
104
+ let cursor: DocumentTextCursor?
105
+ init(format: DocumentFormat, encoding: DocumentEncoding?, firstPage: Int?, pageLimit: Int?,
106
+ lineLimit: Int?, cursor: DocumentTextCursor? = nil) {
107
+ self.format = format; self.encoding = encoding; self.firstPage = firstPage
108
+ self.pageLimit = pageLimit; self.lineLimit = lineLimit; self.cursor = cursor
109
+ }
110
+ }
111
+ // Internal scalar position only. The gateway owns external opaque cursor authorization.
112
+ struct DocumentTextCursor: Codable, Equatable {
113
+ let originalSha256: String
114
+ let representationSha256: String
115
+ let offset: Int
116
+ func validate() throws {
117
+ guard HelperCodec.matches(originalSha256, "^[0-9a-f]{64}$"),
118
+ HelperCodec.matches(representationSha256, "^[0-9a-f]{64}$"),
119
+ (1..<HelperLimits.preparedTextBytes).contains(offset) else { throw HelperProtocolError.invalidCursor }
120
+ }
121
+ }
122
+ struct DocumentTextRange: Codable {
123
+ let start: Int
124
+ let end: Int
125
+ let totalScalars: Int
126
+ let totalBytes: Int
127
+ }
128
+ struct DocumentRequest: Codable {
129
+ let version: Int
130
+ let requestId: String
131
+ let operation: DocumentOperation
132
+ let originalBase64: String
133
+ let options: DocumentOptions
134
+ func originalBytes() throws -> Data {
135
+ try HelperCodec.base64(originalBase64, maximum: HelperLimits.originalBytes)
136
+ }
137
+ }
138
+
139
+ private enum Discriminator: String, CodingKey { case operation, type }
140
+ enum HelperRequest: Codable {
141
+ case selector(SelectorRequest), document(DocumentRequest)
142
+ var requestId: String {
143
+ switch self { case .selector(let value): return value.requestId; case .document(let value): return value.requestId }
144
+ }
145
+ init(from decoder: Decoder) throws {
146
+ let operation = try decoder.container(keyedBy: Discriminator.self).decode(String.self, forKey: .operation)
147
+ switch operation {
148
+ case "selector.list", "selector.snapshot": self = .selector(try SelectorRequest(from: decoder))
149
+ case "document.inspect", "document.text", "document.pages", "document.ocr": self = .document(try DocumentRequest(from: decoder))
150
+ default: throw HelperProtocolError.invalidOperation
151
+ }
152
+ }
153
+ func encode(to encoder: Encoder) throws {
154
+ switch self { case .selector(let value): try value.encode(to: encoder); case .document(let value): try value.encode(to: encoder) }
155
+ }
156
+ }
157
+
158
+ enum NativeEntryKind: String, Codable { case directory, file, disabled }
159
+ struct NativeEntry: Codable {
160
+ let name: String
161
+ let kind: NativeEntryKind
162
+ let identity: NativeIdentity?
163
+ let selectable: Bool
164
+ let reason: String?
165
+ }
166
+ struct SelectorListed: Codable {
167
+ let requestId: String
168
+ let entries: [NativeEntry]
169
+ let nextName: String?
170
+ }
171
+ struct SnapshotChunk: Codable {
172
+ let requestId: String
173
+ let index: Int
174
+ let base64: String
175
+ }
176
+ struct SnapshotComplete: Codable {
177
+ let requestId: String
178
+ let before: NativeIdentity
179
+ let after: NativeIdentity
180
+ let bytes: Int
181
+ let sha256: String
182
+ }
183
+ enum PageRepresentation: String, Codable {
184
+ case nativeText = "native-text", renderedImage = "rendered-image", ocrText = "ocr-text", verifiedBlank = "verified-blank"
185
+ }
186
+ struct PageCoverage: Codable {
187
+ let page: Int
188
+ let representation: PageRepresentation
189
+ }
190
+ struct DocumentCoverage: Codable {
191
+ let totalPages: Int
192
+ let pages: [PageCoverage]
193
+ }
194
+ enum ImageMIME: String, Codable { case jpeg = "image/jpeg", png = "image/png" }
195
+ struct HelperImage: Codable {
196
+ let data: String
197
+ let mimeType: ImageMIME
198
+ }
199
+ enum HelperContent: Codable {
200
+ case text(String), image(HelperImage)
201
+ private enum Keys: String, CodingKey { case type, text }
202
+ init(from decoder: Decoder) throws {
203
+ let container = try decoder.container(keyedBy: Keys.self)
204
+ switch try container.decode(String.self, forKey: .type) {
205
+ case "text": self = .text(try container.decode(String.self, forKey: .text))
206
+ case "image": self = .image(try HelperImage(from: decoder))
207
+ default: throw HelperProtocolError.invalidShape
208
+ }
209
+ }
210
+ func encode(to encoder: Encoder) throws {
211
+ var container = encoder.container(keyedBy: Keys.self)
212
+ switch self {
213
+ case .text(let text):
214
+ try container.encode("text", forKey: .type)
215
+ try container.encode(text, forKey: .text)
216
+ case .image(let image):
217
+ try container.encode("image", forKey: .type)
218
+ try image.encode(to: encoder)
219
+ }
220
+ }
221
+ }
222
+ struct DocumentComplete: Codable {
223
+ let requestId: String
224
+ let originalSha256: String
225
+ let representationSha256: String
226
+ let coverage: DocumentCoverage
227
+ let content: [HelperContent]
228
+ let textRange: DocumentTextRange?
229
+ let nextCursor: DocumentTextCursor?
230
+ init(requestId: String, originalSha256: String, representationSha256: String,
231
+ coverage: DocumentCoverage, content: [HelperContent],
232
+ textRange: DocumentTextRange? = nil, nextCursor: DocumentTextCursor? = nil) {
233
+ self.requestId = requestId; self.originalSha256 = originalSha256
234
+ self.representationSha256 = representationSha256; self.coverage = coverage
235
+ self.content = content; self.textRange = textRange; self.nextCursor = nextCursor
236
+ }
237
+ }
238
+
239
+ struct PreparedTextPage: Codable {
240
+ let page: Int
241
+ let representation: PageRepresentation
242
+ let text: String
243
+ }
244
+
245
+ // Exact canonical per-page text, already produced by T4. This type does not parse or authorize.
246
+ struct PreparedDocumentText {
247
+ private let pages: [PreparedTextPage]
248
+ let bytes: Int
249
+ let scalars: Int
250
+ let sha256: String
251
+ let totalPages: Int
252
+
253
+ init(pages: [PreparedTextPage], totalPages: Int? = nil) throws {
254
+ let total = totalPages ?? pages.count
255
+ guard (1...300).contains(pages.count), (pages.count...300).contains(total) else { throw HelperProtocolError.invalidBounds }
256
+ var bytes = 0, scalars = 0, previous = 0
257
+ var digest = SHA256()
258
+ for page in pages {
259
+ guard page.page > previous, page.page <= total, !page.text.contains("\0"),
260
+ page.representation != .renderedImage,
261
+ page.representation != .verifiedBlank || page.text.isEmpty else { throw HelperProtocolError.invalidShape }
262
+ bytes += page.text.utf8.count
263
+ guard bytes <= HelperLimits.preparedTextBytes else { throw HelperProtocolError.invalidBounds }
264
+ scalars += page.text.unicodeScalars.count
265
+ digest.update(data: Data(page.text.utf8))
266
+ previous = page.page
267
+ }
268
+ self.pages = pages; self.bytes = bytes; self.scalars = scalars; self.totalPages = total
269
+ self.sha256 = digest.finalize().map({ String(format: "%02x", $0) }).joined()
270
+ }
271
+
272
+ func frame(for request: DocumentRequest) throws -> HelperFrame {
273
+ try HelperCodec.validate(.document(request))
274
+ guard request.operation == .text || request.operation == .ocr else { throw HelperProtocolError.invalidOperation }
275
+ let original = SHA256.hash(data: try request.originalBytes()).map({ String(format: "%02x", $0) }).joined()
276
+ let start = request.options.cursor?.offset ?? 0
277
+ if let cursor = request.options.cursor {
278
+ guard cursor.originalSha256 == original, cursor.representationSha256 == sha256,
279
+ start < scalars else { throw HelperProtocolError.invalidCursor }
280
+ }
281
+ var text = "", position = 0, end = start, written = 0, lines = 0
282
+ read: for page in pages {
283
+ for scalar in page.text.unicodeScalars {
284
+ defer { position += 1 }
285
+ if position < start { continue }
286
+ let part = String(scalar)
287
+ guard written + part.utf8.count <= HelperLimits.textBytes,
288
+ lines < (request.options.lineLimit ?? 2000) else { break read }
289
+ text.unicodeScalars.append(scalar)
290
+ written += part.utf8.count
291
+ end += 1
292
+ if scalar.value == 10 { lines += 1 }
293
+ }
294
+ }
295
+ var pageStart = 0
296
+ var coverage: [PageCoverage] = []
297
+ for page in pages {
298
+ let pageEnd = pageStart + page.text.unicodeScalars.count
299
+ let intersects = pageStart < end && pageEnd > start
300
+ let emptyAtBoundary = pageStart == pageEnd && pageStart >= start &&
301
+ (pageStart < end || (pageStart == scalars && end == scalars))
302
+ if intersects || emptyAtBoundary {
303
+ coverage.append(PageCoverage(page: page.page, representation: page.representation))
304
+ }
305
+ pageStart = pageEnd
306
+ }
307
+ let next = end < scalars ? DocumentTextCursor(originalSha256: original, representationSha256: sha256, offset: end) : nil
308
+ let result = HelperFrame.documentComplete(DocumentComplete(requestId: request.requestId, originalSha256: original,
309
+ representationSha256: sha256, coverage: DocumentCoverage(totalPages: totalPages, pages: coverage),
310
+ content: [.text(text)], textRange: DocumentTextRange(start: start, end: end, totalScalars: scalars, totalBytes: bytes),
311
+ nextCursor: next))
312
+ var state = try HelperFrameState(request: .document(request))
313
+ try state.accept(result)
314
+ try state.finish()
315
+ return result
316
+ }
317
+ }
318
+ enum HelperFailureCode: String, Codable {
319
+ case hostAccessDenied = "host_access_denied", fileChanged = "file_changed", invalidPDF = "invalid_pdf"
320
+ case passwordRequired = "password_required", encodingRequired = "encoding_required"
321
+ case unsupportedEncoding = "unsupported_encoding", ocrFailed = "ocr_failed", processingLimit = "processing_limit"
322
+ }
323
+ struct HelperFailure: Codable {
324
+ let requestId: String
325
+ let code: HelperFailureCode
326
+ }
327
+ enum HelperFrame: Codable {
328
+ case listed(SelectorListed), chunk(SnapshotChunk), snapshotComplete(SnapshotComplete)
329
+ case documentComplete(DocumentComplete), error(HelperFailure)
330
+ var requestId: String {
331
+ switch self {
332
+ case .listed(let value): return value.requestId
333
+ case .chunk(let value): return value.requestId
334
+ case .snapshotComplete(let value): return value.requestId
335
+ case .documentComplete(let value): return value.requestId
336
+ case .error(let value): return value.requestId
337
+ }
338
+ }
339
+ init(from decoder: Decoder) throws {
340
+ let type = try decoder.container(keyedBy: Discriminator.self).decode(String.self, forKey: .type)
341
+ switch type {
342
+ case "selector.listed": self = .listed(try SelectorListed(from: decoder))
343
+ case "chunk": self = .chunk(try SnapshotChunk(from: decoder))
344
+ case "snapshot.complete": self = .snapshotComplete(try SnapshotComplete(from: decoder))
345
+ case "document.complete": self = .documentComplete(try DocumentComplete(from: decoder))
346
+ case "error": self = .error(try HelperFailure(from: decoder))
347
+ default: throw HelperProtocolError.invalidOperation
348
+ }
349
+ }
350
+ func encode(to encoder: Encoder) throws {
351
+ var container = encoder.container(keyedBy: Discriminator.self)
352
+ switch self {
353
+ case .listed(let value):
354
+ try container.encode("selector.listed", forKey: .type); try value.encode(to: encoder)
355
+ case .chunk(let value):
356
+ try container.encode("chunk", forKey: .type); try value.encode(to: encoder)
357
+ case .snapshotComplete(let value):
358
+ try container.encode("snapshot.complete", forKey: .type); try value.encode(to: encoder)
359
+ case .documentComplete(let value):
360
+ try container.encode("document.complete", forKey: .type); try value.encode(to: encoder)
361
+ case .error(let value):
362
+ try container.encode("error", forKey: .type); try value.encode(to: encoder)
363
+ }
364
+ }
365
+ }
366
+
367
+ enum HelperCodec {
368
+ static func matches(_ value: String, _ pattern: String) -> Bool {
369
+ value.range(of: pattern, options: .regularExpression) == value.startIndex..<value.endIndex
370
+ }
371
+ static func base64(_ value: String, maximum: Int) throws -> Data {
372
+ guard value.utf8.count <= ((maximum + 2) / 3) * 4,
373
+ let decoded = Data(base64Encoded: value), !decoded.isEmpty, decoded.count <= maximum,
374
+ decoded.base64EncodedString() == value else { throw HelperProtocolError.invalidBase64 }
375
+ return decoded
376
+ }
377
+ private static func strict<T: Codable>(_ type: T.Type, data: Data) throws -> T {
378
+ guard String(data: data, encoding: .utf8) != nil else { throw HelperProtocolError.invalidJSON }
379
+ let decoded = try JSONDecoder().decode(type, from: data)
380
+ // Foundation Codable ignores unknown keys. Compare JSON values (not spelling)
381
+ // against its exact encoded shape, recursively, to reject extras and null optionals.
382
+ guard let original = try JSONSerialization.jsonObject(with: data) as? NSDictionary,
383
+ let normalized = try JSONSerialization.jsonObject(with: JSONEncoder().encode(decoded)) as? NSDictionary,
384
+ original.isEqual(normalized) else { throw HelperProtocolError.invalidShape }
385
+ return decoded
386
+ }
387
+ static func validate(_ request: HelperRequest) throws {
388
+ guard matches(request.requestId, #"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$"#) else {
389
+ throw HelperProtocolError.invalidShape
390
+ }
391
+ switch request {
392
+ case .selector(let value):
393
+ guard value.version == 1, value.segments.count <= 256,
394
+ value.segments.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\0") }),
395
+ value.query.map({ $0.unicodeScalars.count <= 200 }) ?? true,
396
+ value.afterName.map({ !$0.isEmpty }) ?? true else { throw HelperProtocolError.invalidBounds }
397
+ switch value.operation {
398
+ case .list:
399
+ guard value.showHidden != nil, value.expectedIdentity == nil else { throw HelperProtocolError.invalidShape }
400
+ case .snapshot:
401
+ guard let identity = value.expectedIdentity, value.showHidden == nil,
402
+ value.query == nil, value.afterName == nil else { throw HelperProtocolError.invalidShape }
403
+ try identity.validate()
404
+ }
405
+ case .document(let value):
406
+ guard value.version == 1,
407
+ value.options.firstPage.map({ (1...300).contains($0) }) ?? true,
408
+ value.options.pageLimit.map({ (1...2).contains($0) }) ?? true,
409
+ value.options.lineLimit.map({ (1...2000).contains($0) }) ?? true else { throw HelperProtocolError.invalidBounds }
410
+ _ = try value.originalBytes()
411
+ if let cursor = value.options.cursor {
412
+ guard value.operation == .text || value.operation == .ocr else { throw HelperProtocolError.invalidCursor }
413
+ try cursor.validate()
414
+ let original = SHA256.hash(data: try value.originalBytes()).map({ String(format: "%02x", $0) }).joined()
415
+ guard cursor.originalSha256 == original else { throw HelperProtocolError.invalidCursor }
416
+ }
417
+ }
418
+ }
419
+ static func decodeRequest(_ data: Data, privilege: HelperPrivilege) throws -> HelperRequest {
420
+ guard data.count <= HelperLimits.requestBytes else { throw HelperProtocolError.invalidBounds }
421
+ let request = try strict(HelperRequest.self, data: data)
422
+ switch (request, privilege) {
423
+ case (.selector, .selector), (.document, .document): break
424
+ default: throw HelperProtocolError.wrongPrivilege
425
+ }
426
+ try validate(request)
427
+ return request
428
+ }
429
+ static func encodeRequest(_ request: HelperRequest) throws -> Data {
430
+ try validate(request)
431
+ let data = try JSONEncoder().encode(request)
432
+ guard data.count <= HelperLimits.requestBytes else { throw HelperProtocolError.invalidBounds }
433
+ return data
434
+ }
435
+ static func readRequest(from input: FileHandle, privilege: HelperPrivilege) throws -> HelperRequest {
436
+ var data = Data()
437
+ while let part = try input.read(upToCount: min(65_536, HelperLimits.requestBytes + 1 - data.count)), !part.isEmpty {
438
+ data.append(part)
439
+ guard data.count <= HelperLimits.requestBytes else { throw HelperProtocolError.invalidBounds }
440
+ }
441
+ return try decodeRequest(data, privilege: privilege)
442
+ }
443
+ static func decodeFrame(_ data: Data) throws -> HelperFrame {
444
+ guard data.count <= HelperLimits.frameBytes else { throw HelperProtocolError.invalidBounds }
445
+ let frame = try strict(HelperFrame.self, data: data)
446
+ try validate(frame)
447
+ return frame
448
+ }
449
+ static func encodeFrame(_ frame: HelperFrame) throws -> Data {
450
+ try validate(frame)
451
+ var data = try JSONEncoder().encode(frame)
452
+ guard data.count <= HelperLimits.frameBytes else { throw HelperProtocolError.invalidBounds }
453
+ data.append(10)
454
+ return data
455
+ }
456
+ static func validate(_ frame: HelperFrame) throws {
457
+ guard matches(frame.requestId, #"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$"#) else {
458
+ throw HelperProtocolError.invalidShape
459
+ }
460
+ switch frame {
461
+ case .listed(let value):
462
+ guard value.entries.count <= 50, value.nextName.map({ !$0.isEmpty }) ?? true else { throw HelperProtocolError.invalidBounds }
463
+ for entry in value.entries {
464
+ guard !entry.name.isEmpty, entry.reason.map({ !$0.isEmpty }) ?? true else { throw HelperProtocolError.invalidShape }
465
+ try entry.identity?.validate()
466
+ }
467
+ case .chunk(let value):
468
+ guard (0...HelperLimits.originalBytes).contains(value.index) else { throw HelperProtocolError.invalidBounds }
469
+ _ = try base64(value.base64, maximum: HelperLimits.originalBytes)
470
+ case .snapshotComplete(let value):
471
+ try value.before.validate(); try value.after.validate()
472
+ guard (1...HelperLimits.originalBytes).contains(value.bytes), matches(value.sha256, "^[0-9a-f]{64}$") else {
473
+ throw HelperProtocolError.invalidBounds
474
+ }
475
+ case .documentComplete(let value):
476
+ guard matches(value.originalSha256, "^[0-9a-f]{64}$"), matches(value.representationSha256, "^[0-9a-f]{64}$"),
477
+ (1...300).contains(value.coverage.totalPages), value.coverage.pages.count <= 300,
478
+ value.coverage.pages.allSatisfy({ (1...value.coverage.totalPages).contains($0.page) }),
479
+ value.content.count <= 2000 else { throw HelperProtocolError.invalidBounds }
480
+ var textBytes = 0
481
+ var images = 0
482
+ for content in value.content {
483
+ switch content {
484
+ case .text(let text): textBytes += text.utf8.count
485
+ case .image(let image):
486
+ images += 1
487
+ _ = try base64(image.data, maximum: HelperLimits.imageBytes)
488
+ }
489
+ }
490
+ guard textBytes <= HelperLimits.textBytes, images <= 2 else { throw HelperProtocolError.invalidBounds }
491
+ if let range = value.textRange {
492
+ guard range.start >= 0, range.end >= range.start, range.end <= range.totalScalars,
493
+ range.totalScalars <= range.totalBytes, range.totalBytes <= HelperLimits.preparedTextBytes,
494
+ range.totalBytes <= range.totalScalars * 4,
495
+ range.end > range.start || range.totalScalars == 0,
496
+ value.content.count == 1, case .text(let text) = value.content[0],
497
+ text.unicodeScalars.count == range.end - range.start,
498
+ text.utf8.count <= range.totalBytes else { throw HelperProtocolError.invalidBounds }
499
+ let remainingScalars = range.totalScalars - (range.end - range.start)
500
+ let remainingBytes = range.totalBytes - textBytes
501
+ guard remainingBytes >= remainingScalars,
502
+ remainingBytes <= remainingScalars * 4 else { throw HelperProtocolError.invalidBounds }
503
+ if range.end < range.totalScalars {
504
+ guard let cursor = value.nextCursor, cursor.offset == range.end,
505
+ cursor.originalSha256 == value.originalSha256,
506
+ cursor.representationSha256 == value.representationSha256 else { throw HelperProtocolError.invalidCursor }
507
+ try cursor.validate()
508
+ } else {
509
+ guard value.nextCursor == nil else { throw HelperProtocolError.invalidCursor }
510
+ }
511
+ if range.start == 0 && range.end == range.totalScalars {
512
+ let digest = SHA256.hash(data: Data(text.utf8)).map({ String(format: "%02x", $0) }).joined()
513
+ guard value.representationSha256 == digest, text.utf8.count == range.totalBytes else {
514
+ throw HelperProtocolError.invalidSnapshot
515
+ }
516
+ }
517
+ } else if value.nextCursor != nil { throw HelperProtocolError.invalidCursor }
518
+ case .error: break
519
+ }
520
+ }
521
+ static func readFrames(from input: FileHandle, request: HelperRequest, emit: (HelperFrame) throws -> Void) throws {
522
+ var state = try HelperFrameState(request: request)
523
+ var line = Data()
524
+ var total = 0
525
+ while let part = try input.read(upToCount: 65_536), !part.isEmpty {
526
+ total += part.count
527
+ guard total <= HelperLimits.streamBytes else { throw HelperProtocolError.invalidBounds }
528
+ for byte in part {
529
+ if byte == 10 {
530
+ let frame = try decodeFrame(line)
531
+ try state.accept(frame)
532
+ try emit(frame)
533
+ line.removeAll(keepingCapacity: true)
534
+ } else {
535
+ guard line.count < HelperLimits.frameBytes else { throw HelperProtocolError.invalidBounds }
536
+ line.append(byte)
537
+ }
538
+ }
539
+ }
540
+ guard line.isEmpty else { throw HelperProtocolError.invalidJSON }
541
+ try state.finish()
542
+ }
543
+ }
544
+
545
+ struct HelperFrameState {
546
+ private let request: HelperRequest
547
+ private var terminal = false
548
+ private var nextIndex = 0
549
+ private var bytes = 0
550
+ private var digest = SHA256()
551
+
552
+ init(request: HelperRequest) throws {
553
+ try HelperCodec.validate(request)
554
+ self.request = request
555
+ }
556
+ mutating func accept(_ frame: HelperFrame) throws {
557
+ guard !terminal else { throw HelperProtocolError.terminalState }
558
+ guard frame.requestId == request.requestId else { throw HelperProtocolError.requestMismatch }
559
+ try HelperCodec.validate(frame)
560
+ switch (request, frame) {
561
+ case (_, .error): terminal = true
562
+ case (.selector(let selection), .listed):
563
+ guard selection.operation == .list else { throw HelperProtocolError.invalidSequence }
564
+ terminal = true
565
+ case (.selector(let selection), .chunk(let chunk)):
566
+ guard selection.operation == .snapshot, chunk.index == nextIndex else { throw HelperProtocolError.invalidSequence }
567
+ let data = try HelperCodec.base64(chunk.base64, maximum: HelperLimits.originalBytes)
568
+ guard bytes + data.count <= HelperLimits.originalBytes else { throw HelperProtocolError.invalidBounds }
569
+ bytes += data.count
570
+ nextIndex += 1
571
+ digest.update(data: data)
572
+ case (.selector(let selection), .snapshotComplete(let value)):
573
+ guard selection.operation == .snapshot, nextIndex > 0, bytes == value.bytes,
574
+ value.before == selection.expectedIdentity, value.after == value.before,
575
+ value.bytes == value.before.size,
576
+ value.sha256 == digest.finalize().map({ String(format: "%02x", $0) }).joined() else {
577
+ throw HelperProtocolError.invalidSnapshot
578
+ }
579
+ terminal = true
580
+ case (.document(let document), .documentComplete(let value)):
581
+ let originalDigest = SHA256.hash(data: try document.originalBytes()).map({ String(format: "%02x", $0) }).joined()
582
+ guard value.originalSha256 == originalDigest else { throw HelperProtocolError.invalidSnapshot }
583
+ var lines = 0
584
+ for content in value.content {
585
+ if case .text(let text) = content, !text.isEmpty {
586
+ lines += text.utf8.filter({ $0 == 10 }).count + (text.hasSuffix("\n") ? 0 : 1)
587
+ }
588
+ }
589
+ guard lines <= (document.options.lineLimit ?? 2000) else { throw HelperProtocolError.invalidBounds }
590
+ if document.operation == .text || document.operation == .ocr {
591
+ guard let range = value.textRange, range.start == (document.options.cursor?.offset ?? 0) else {
592
+ throw HelperProtocolError.invalidCursor
593
+ }
594
+ if let cursor = document.options.cursor {
595
+ guard value.representationSha256 == cursor.representationSha256 else { throw HelperProtocolError.invalidCursor }
596
+ }
597
+ } else {
598
+ guard value.textRange == nil, value.nextCursor == nil else { throw HelperProtocolError.invalidCursor }
599
+ }
600
+ if document.operation == .pages {
601
+ let first = document.options.firstPage ?? 1
602
+ let end = min(first + (document.options.pageLimit ?? 2), value.coverage.totalPages + 1)
603
+ guard first < end, Set(value.coverage.pages.map(\.page)) == Set(first..<end) else {
604
+ throw HelperProtocolError.invalidBounds
605
+ }
606
+ }
607
+ terminal = true
608
+ default: throw HelperProtocolError.invalidSequence
609
+ }
610
+ }
611
+ func finish() throws {
612
+ guard terminal else { throw HelperProtocolError.missingTerminal }
613
+ }
614
+ }
615
+
616
+ // Entrypoints select their privilege before reading stdin and pass writer.emit to
617
+ // HostSelector.handle(_:emit:) OR DocumentRead.handle(_:emit:), never a fallback.
618
+ // They must call finish() and require normal child exit before publishing success.
619
+ final class HelperFrameWriter {
620
+ private var state: HelperFrameState
621
+ private let output: FileHandle
622
+ private var written = 0
623
+ private var failed = false
624
+ init(request: HelperRequest, output: FileHandle) throws {
625
+ state = try HelperFrameState(request: request)
626
+ self.output = output
627
+ }
628
+ func emit(_ frame: HelperFrame) throws {
629
+ guard !failed else { throw HelperProtocolError.terminalState }
630
+ do {
631
+ var next = state
632
+ try next.accept(frame)
633
+ let data = try HelperCodec.encodeFrame(frame)
634
+ guard written + data.count <= HelperLimits.streamBytes else { throw HelperProtocolError.invalidBounds }
635
+ try output.write(contentsOf: data)
636
+ state = next
637
+ written += data.count
638
+ } catch {
639
+ failed = true
640
+ throw error
641
+ }
642
+ }
643
+ func finish() throws {
644
+ guard !failed else { throw HelperProtocolError.terminalState }
645
+ try state.finish()
646
+ }
647
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+ import Darwin
3
+
4
+ // Selector-only executable. DocumentRead and PDFKit/Vision are not linked here.
5
+ signal(SIGPIPE, SIG_IGN)
6
+ do {
7
+ let request = try HelperCodec.readRequest(from: .standardInput, privilege: .selector)
8
+ guard case .selector(let selector) = request else { throw HelperProtocolError.wrongPrivilege }
9
+ let writer = try HelperFrameWriter(request: request, output: .standardOutput)
10
+ do {
11
+ try HostSelector.handle(selector, emit: writer.emit)
12
+ try writer.finish()
13
+ } catch let failure as SelectorFailure {
14
+ try writer.emit(.error(HelperFailure(requestId: selector.requestId, code: failure.code)))
15
+ try writer.finish()
16
+ exit(1)
17
+ }
18
+ } catch {
19
+ // Never print private paths, OS error text, or input on protocol failure.
20
+ try? FileHandle.standardError.write(contentsOf: Data("selector_failed\n".utf8))
21
+ exit(1)
22
+ }