@ixo/oracle-runtime-workers 0.13.0 → 0.15.0

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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -33,14 +33,15 @@ export async function uploadToSandbox(
33
33
  const safeFilename = sanitizeSandboxPath(filename);
34
34
  const safePath = sanitizeSandboxPath(destPath);
35
35
  const formData = new FormData();
36
- // Copy into a fresh ArrayBuffer-backed view so the Blob never sees a
37
- // SharedArrayBuffer-typed slice (the DOM lib types reject those).
38
- const copy = new Uint8Array(bytes.byteLength);
39
- copy.set(bytes);
40
- formData.set(
41
- 'file',
42
- new File([copy.buffer], safeFilename, { type: mimetype }),
43
- );
36
+ // A view over a plain ArrayBuffer goes into the Blob as it is (no second
37
+ // copy of a file of up to 25 MB); only a SharedArrayBuffer-backed one —
38
+ // which the Blob types reject — is copied.
39
+ const buffer = bytes.buffer;
40
+ const part =
41
+ buffer instanceof ArrayBuffer
42
+ ? new Uint8Array(buffer, bytes.byteOffset, bytes.byteLength)
43
+ : Uint8Array.from(bytes);
44
+ formData.set('file', new File([part], safeFilename, { type: mimetype }));
44
45
  formData.set('path', safePath);
45
46
  const response = await fetchImpl(`${baseUrl}/artifacts/upload`, {
46
47
  method: 'POST',
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Text a `view_attachment` call extracted, kept in the user's own SQLite
3
+ * file per (session, attachment reference, extraction model) so a later
4
+ * view of the same file costs neither a download nor a helper-model call.
5
+ * Only Matrix media is cached: an `mxc://` URI or event never changes,
6
+ * whereas what an http(s) URL serves can. Entries expire after
7
+ * `VIEW_CACHE_MAX_AGE_MS`, a session keeps its newest
8
+ * `VIEW_CACHE_MAX_PER_SESSION`, both enforced on write, and a deleted
9
+ * session takes its entries with it (`forgetSession`).
10
+ */
11
+ import type { DoSqliteDatabase } from '../sqlite/database';
12
+ import type { Logger } from '../plugin-api/types';
13
+
14
+ /** One session's cache, as `viewAttachment` sees it. */
15
+ export interface AttachmentTextCache {
16
+ get(ref: string, model: string): Promise<string | undefined>;
17
+ put(ref: string, model: string, text: string): Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * The longest text kept per entry (characters). Documents are extracted up
22
+ * to `MAX_TEXT_LENGTH` (50 000) plus a header, so this covers them; a longer
23
+ * description is not cached and is extracted again on the next view.
24
+ */
25
+ export const VIEW_CACHE_MAX_CHARS = 64 * 1024;
26
+
27
+ /** How long an entry is served and kept. */
28
+ export const VIEW_CACHE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
29
+
30
+ /** Entries a session keeps (the newest). */
31
+ export const VIEW_CACHE_MAX_PER_SESSION = 50;
32
+
33
+ /** What an http(s) URL serves can change between views. */
34
+ function isCacheable(ref: string): boolean {
35
+ return !/^https?:\/\//i.test(ref);
36
+ }
37
+
38
+ /** The "model" key of plain text decoded locally (no helper model involved). */
39
+ export const LOCAL_TEXT_EXTRACTION = 'local-text';
40
+
41
+ export class AttachmentTextCacheStore {
42
+ private setupPromise: Promise<void> | undefined;
43
+
44
+ constructor(
45
+ private readonly db: Pick<DoSqliteDatabase, 'run' | 'get'>,
46
+ private readonly log: Pick<Logger, 'warn'>,
47
+ private readonly now: () => number = Date.now,
48
+ ) {}
49
+
50
+ private setup(): Promise<void> {
51
+ this.setupPromise ??= this.createTables().catch((error: unknown) => {
52
+ this.setupPromise = undefined;
53
+ throw error;
54
+ });
55
+ return this.setupPromise;
56
+ }
57
+
58
+ private async createTables(): Promise<void> {
59
+ await this.db.run(
60
+ `CREATE TABLE IF NOT EXISTS attachment_text_cache (
61
+ session_id TEXT NOT NULL,
62
+ ref TEXT NOT NULL,
63
+ model TEXT NOT NULL,
64
+ text TEXT NOT NULL,
65
+ created_at INTEGER NOT NULL,
66
+ UNIQUE (session_id, ref, model)
67
+ )`,
68
+ );
69
+ await this.db.run(
70
+ `CREATE INDEX IF NOT EXISTS idx_attachment_text_cache_age
71
+ ON attachment_text_cache(created_at)`,
72
+ );
73
+ await this.db.run(
74
+ `CREATE INDEX IF NOT EXISTS idx_attachment_text_cache_session
75
+ ON attachment_text_cache(session_id, created_at)`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The cache of one session. Its failures are logged and treated as a miss
81
+ * (or a skipped write): a cache must never fail the view itself.
82
+ */
83
+ forSession(sessionId: string): AttachmentTextCache {
84
+ return {
85
+ get: async (ref, model) => {
86
+ if (!isCacheable(ref)) return undefined;
87
+ try {
88
+ await this.setup();
89
+ const row = await this.db.get<{ text: string }>(
90
+ `SELECT text FROM attachment_text_cache
91
+ WHERE session_id = ? AND ref = ? AND model = ? AND created_at > ?`,
92
+ [sessionId, ref, model, this.now() - VIEW_CACHE_MAX_AGE_MS],
93
+ );
94
+ return row?.text;
95
+ } catch (error) {
96
+ this.log.warn(
97
+ `[attachments] view cache read failed: ${error instanceof Error ? error.message : String(error)}`,
98
+ );
99
+ return undefined;
100
+ }
101
+ },
102
+ put: async (ref, model, text) => {
103
+ if (!isCacheable(ref) || text.length > VIEW_CACHE_MAX_CHARS) return;
104
+ try {
105
+ await this.setup();
106
+ const now = this.now();
107
+ await this.db.run(
108
+ `INSERT OR REPLACE INTO attachment_text_cache
109
+ (session_id, ref, model, text, created_at) VALUES (?, ?, ?, ?, ?)`,
110
+ [sessionId, ref, model, text, now],
111
+ );
112
+ await this.db.run(
113
+ `DELETE FROM attachment_text_cache WHERE created_at <= ?`,
114
+ [now - VIEW_CACHE_MAX_AGE_MS],
115
+ );
116
+ await this.db.run(
117
+ `DELETE FROM attachment_text_cache
118
+ WHERE session_id = ? AND rowid NOT IN (
119
+ SELECT rowid FROM attachment_text_cache WHERE session_id = ?
120
+ ORDER BY created_at DESC, rowid DESC LIMIT ?
121
+ )`,
122
+ [sessionId, sessionId, VIEW_CACHE_MAX_PER_SESSION],
123
+ );
124
+ } catch (error) {
125
+ this.log.warn(
126
+ `[attachments] view cache write failed: ${error instanceof Error ? error.message : String(error)}`,
127
+ );
128
+ }
129
+ },
130
+ };
131
+ }
132
+
133
+ /** Drop every entry of a deleted session; returns how many went. */
134
+ async forgetSession(sessionId: string): Promise<number> {
135
+ await this.setup();
136
+ const { changes } = await this.db.run(
137
+ `DELETE FROM attachment_text_cache WHERE session_id = ?`,
138
+ [sessionId],
139
+ );
140
+ return changes;
141
+ }
142
+ }
@@ -3,15 +3,19 @@
3
3
  * lane behind payload retention (`retention.ts`). The same routing as the
4
4
  * turn pipeline applies: when the current model reads the modality the bytes
5
5
  * come back as a native content block, otherwise the helper (`vision` role)
6
- * model turns them into text; plain text is decoded locally.
6
+ * model turns them into text; plain text is decoded locally. Text results
7
+ * are cached per session (`deps.cache`); native views are not (they are the
8
+ * bytes themselves).
7
9
  */
8
10
  import { classifyAttachment } from './classify';
9
11
  import type { NativeAttachment } from './content-blocks';
10
12
  import { loadAttachmentBytes, type MatrixMediaSource } from './download';
11
13
  import { extractText, type ExtractionProvider } from './extract';
12
- import { categorizeFile, verifyMagicBytes } from './magic';
14
+ import { categorizeFile, isPlainTextType, verifyMagicBytes } from './magic';
15
+ import { attachmentRef } from './retention';
13
16
  import { routeAttachment } from './route';
14
17
  import { sanitizeAttachmentFilename } from './sanitize';
18
+ import { LOCAL_TEXT_EXTRACTION, type AttachmentTextCache } from './view-cache';
15
19
  import type { AttachmentInput } from './types';
16
20
  import type { ModelInputCapabilities } from '../core/llm';
17
21
  import type { AttachmentMeta } from '../sqlite/serialization';
@@ -31,6 +35,11 @@ export interface ViewAttachmentDeps {
31
35
  fetchImpl?: typeof fetch;
32
36
  signal?: AbortSignal;
33
37
  logger: Logger;
38
+ /**
39
+ * The session's cache of extracted text (`AttachmentTextCacheStore`):
40
+ * a hit costs no download and no helper-model call.
41
+ */
42
+ cache?: AttachmentTextCache;
34
43
  }
35
44
 
36
45
  /** What the `view_attachment` tool sees on the host: session-scoped access. */
@@ -62,20 +71,25 @@ export async function viewAttachment(
62
71
  const attachment = toInput(meta);
63
72
  const kind = classifyAttachment(attachment);
64
73
  const strategy = routeAttachment(kind, deps.caps);
65
- const { bytes, mimetype } = await loadAttachmentBytes(
66
- attachment,
67
- deps.roomId,
68
- deps.source,
69
- { fetchImpl: deps.fetchImpl, signal: deps.signal },
70
- );
71
- deps.logger.log(
72
- `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
73
- );
74
74
  if (strategy === 'send-native') {
75
+ const { bytes, mimetype, sniffed } = await loadAttachmentBytes(
76
+ attachment,
77
+ deps.roomId,
78
+ deps.source,
79
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
80
+ );
81
+ deps.logger.log(
82
+ `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
83
+ );
75
84
  const loaded = classifyAttachment({
76
85
  mimetype,
77
- filename: attachment.filename,
86
+ filename: sniffed ? '' : attachment.filename,
78
87
  });
88
+ // Bytes that are not what the file claims never go back as a block.
89
+ if (loaded !== kind)
90
+ throw new Error(
91
+ `File content mismatch: claimed ${attachment.mimetype} but detected ${mimetype}`,
92
+ );
79
93
  return {
80
94
  kind: 'native',
81
95
  native: {
@@ -86,6 +100,7 @@ export async function viewAttachment(
86
100
  },
87
101
  };
88
102
  }
103
+
89
104
  const category = categorizeFile(attachment.mimetype);
90
105
  if (category === 'unsupported') {
91
106
  return {
@@ -93,6 +108,31 @@ export async function viewAttachment(
93
108
  text: `[File "${sanitizeAttachmentFilename(attachment.filename)}" (${attachment.mimetype}) is not a supported file type and could not be processed]`,
94
109
  };
95
110
  }
111
+ // Text extracted earlier in this session by the same model is final.
112
+ const ref = attachmentRef(meta);
113
+ const model =
114
+ category === 'document' && isPlainTextType(attachment.mimetype)
115
+ ? LOCAL_TEXT_EXTRACTION
116
+ : deps.extraction?.model;
117
+ const cacheKey = deps.cache && ref && model ? { ref, model } : undefined;
118
+ if (cacheKey) {
119
+ const cached = await deps.cache?.get(cacheKey.ref, cacheKey.model);
120
+ if (cached !== undefined) {
121
+ deps.logger.log(
122
+ `[attachments] view "${attachment.filename}" → cached text (${cacheKey.model})`,
123
+ );
124
+ return { kind: 'text', text: cached };
125
+ }
126
+ }
127
+ const { bytes, mimetype } = await loadAttachmentBytes(
128
+ attachment,
129
+ deps.roomId,
130
+ deps.source,
131
+ { fetchImpl: deps.fetchImpl, signal: deps.signal },
132
+ );
133
+ deps.logger.log(
134
+ `[attachments] view "${attachment.filename}" (${mimetype}) kind=${kind} → ${strategy} (${bytes.length} bytes)`,
135
+ );
96
136
  verifyMagicBytes(bytes, category, attachment, (m) =>
97
137
  deps.logger.warn(`[attachments] ${m}`),
98
138
  );
@@ -103,5 +143,6 @@ export async function viewAttachment(
103
143
  deps.extraction,
104
144
  { fetchImpl: deps.fetchImpl, signal: deps.signal },
105
145
  );
146
+ if (cacheKey) await deps.cache?.put(cacheKey.ref, cacheKey.model, text);
106
147
  return { kind: 'text', text };
107
148
  }
@@ -0,0 +1,267 @@
1
+ import {
2
+ createCompositeDIDResolver,
3
+ createWebDIDResolver,
4
+ createUCANValidator,
5
+ defineCapability,
6
+ parseDelegation,
7
+ Schema,
8
+ type DIDKeyResolver,
9
+ } from '@ixo/ucan';
10
+ import { z } from 'zod';
11
+ import type { OracleWorkerEnv, TurnIdentity } from '../do/contracts';
12
+ import { RunAttemptDeferred } from '../do/run-coordinator';
13
+ import {
14
+ IXO_DID_RESOLUTION_CACHE_TTL_MS,
15
+ sharedIxoDIDResolver,
16
+ } from '../shell/auth';
17
+ import {
18
+ ChannelError,
19
+ type ChannelTurnInput,
20
+ channelRequestHash,
21
+ } from './contract';
22
+
23
+ export const ChannelInvoke = defineCapability({
24
+ can: 'ixo:channel/invoke',
25
+ protocol: 'ixo:',
26
+ supportWildcards: false,
27
+ nb: {
28
+ provider: Schema.string(),
29
+ bindingRevision: Schema.integer(),
30
+ oracleDid: Schema.string(),
31
+ },
32
+ derives: (claimed, delegated) =>
33
+ claimed.nb?.provider === delegated.nb?.provider &&
34
+ claimed.nb?.bindingRevision === delegated.nb?.bindingRevision &&
35
+ claimed.nb?.oracleDid === delegated.nb?.oracleDid
36
+ ? { ok: {} }
37
+ : {
38
+ error: new Error('Channel caveats cannot change across a delegation'),
39
+ },
40
+ });
41
+
42
+ export interface ChannelAuthConfig {
43
+ oracleDid: string;
44
+ channelDid: string;
45
+ didResolver: DIDKeyResolver;
46
+ }
47
+
48
+ export async function authenticateChannel(
49
+ headers: Headers,
50
+ raw: string,
51
+ input: ChannelTurnInput,
52
+ config: ChannelAuthConfig,
53
+ ): Promise<TurnIdentity> {
54
+ const authorization = headers.get('authorization');
55
+ const token = authorization?.match(/^Bearer ([A-Za-z0-9+/=_-]+)$/i)?.[1];
56
+ if (
57
+ !token ||
58
+ token.length > 32_000 ||
59
+ headers.get('x-auth-type')?.toLowerCase() !== 'ucan'
60
+ )
61
+ throw new ChannelError(401, 'A channel UCAN invocation is required');
62
+ const validator = await createUCANValidator({
63
+ serverDid: config.oracleDid,
64
+ rootIssuers: ['*'],
65
+ didResolver: config.didResolver,
66
+ requireExpiration: true,
67
+ // No replay marks: a poll repeats its exact body and may reuse an
68
+ // invocation, also concurrently. The durable receipt keyed by
69
+ // (user, binding, requestId) and the request hash in the invocation's
70
+ // facts make every repeat return the same run instead of a new one.
71
+ invocationStore: {
72
+ has: async () => false,
73
+ add: async () => undefined,
74
+ addIfAbsent: async () => true,
75
+ },
76
+ });
77
+ const result = await validator.validate(
78
+ token,
79
+ ChannelInvoke,
80
+ `ixo:channel:${input.bindingId}`,
81
+ );
82
+ const chain = result.proofChain;
83
+ const userDid = chain?.[0];
84
+ if (
85
+ !result.ok ||
86
+ !userDid?.startsWith('did:ixo:') ||
87
+ chain?.length !== 2 ||
88
+ chain[1] !== config.channelDid ||
89
+ result.invoker !== config.channelDid
90
+ )
91
+ throw new ChannelError(401, 'Invalid user-rooted channel invocation');
92
+ const now = Math.floor(Date.now() / 1000);
93
+ const invocation = await parseDelegation(token);
94
+ const proof = invocation.proofs[0];
95
+ if (
96
+ invocation.proofs.length !== 1 ||
97
+ !proof ||
98
+ !('issuer' in proof) ||
99
+ proof.proofs.length !== 0 ||
100
+ proof.issuer.did() !== userDid ||
101
+ proof.audience.did() !== config.channelDid ||
102
+ proof.capabilities.length !== 1 ||
103
+ proof.capabilities[0]?.can !== 'ixo:channel/invoke' ||
104
+ proof.capabilities[0]?.with !== `ixo:channel:${input.bindingId}` ||
105
+ !Number.isFinite(proof.expiration) ||
106
+ !proof.expiration ||
107
+ proof.expiration > now + 300
108
+ )
109
+ throw new ChannelError(
110
+ 401,
111
+ 'A bounded, direct user channel grant is required',
112
+ );
113
+ if (
114
+ !result.expiration ||
115
+ result.expiration <= now ||
116
+ result.expiration > now + 60 ||
117
+ !Number.isFinite(invocation.expiration) ||
118
+ !invocation.expiration ||
119
+ invocation.expiration > now + 60
120
+ )
121
+ throw new ChannelError(
122
+ 401,
123
+ 'Channel invocations must expire within 60 seconds',
124
+ );
125
+ const nb = result.capability?.nb;
126
+ if (
127
+ result.capability?.can !== 'ixo:channel/invoke' ||
128
+ result.capability.with !== `ixo:channel:${input.bindingId}` ||
129
+ nb?.provider !== input.provider ||
130
+ nb.bindingRevision !== input.bindingRevision ||
131
+ nb.oracleDid !== config.oracleDid
132
+ )
133
+ throw new ChannelError(403, 'Channel scope does not match this request');
134
+ const hash = await channelRequestHash(raw);
135
+ if (
136
+ result.facts?.length !== 1 ||
137
+ result.facts[0]?.requestId !== input.requestId ||
138
+ result.facts[0]?.requestHash !== hash
139
+ )
140
+ throw new ChannelError(
141
+ 403,
142
+ 'Invocation does not authorize this request body',
143
+ );
144
+ return {
145
+ userDid,
146
+ channel: {
147
+ callerDid: config.channelDid,
148
+ provider: input.provider,
149
+ bindingId: input.bindingId,
150
+ bindingRevision: input.bindingRevision,
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * did:web documents (a channel service identity) resolved for every channel
157
+ * request, shared per isolate: successful lookups are cached as long as
158
+ * did:ixo keys, failures never, and concurrent lookups share one request.
159
+ */
160
+ const channelWebResolver = createWebDIDResolver({
161
+ cacheTtlMs: IXO_DID_RESOLUTION_CACHE_TTL_MS,
162
+ });
163
+
164
+ export function channelAuthConfig(
165
+ env: Pick<
166
+ OracleWorkerEnv,
167
+ 'CHANNEL_SERVICE_DID' | 'BLOCKSYNC_GRAPHQL_URL' | 'ORACLE_DID'
168
+ >,
169
+ ): ChannelAuthConfig {
170
+ if (!env.CHANNEL_SERVICE_DID)
171
+ throw new ChannelError(503, 'Channels are not configured');
172
+ const resolver = createCompositeDIDResolver([
173
+ sharedIxoDIDResolver(env.BLOCKSYNC_GRAPHQL_URL),
174
+ channelWebResolver,
175
+ ]);
176
+ return {
177
+ oracleDid: env.ORACLE_DID,
178
+ channelDid: env.CHANNEL_SERVICE_DID,
179
+ didResolver: async (did) => {
180
+ if (
181
+ did === env.CHANNEL_SERVICE_DID ||
182
+ did === env.ORACLE_DID ||
183
+ did.startsWith('did:ixo:')
184
+ )
185
+ return resolver(did);
186
+ return {
187
+ error: {
188
+ name: 'DIDKeyResolutionError',
189
+ did,
190
+ message: 'Unapproved channel identity',
191
+ },
192
+ };
193
+ },
194
+ };
195
+ }
196
+
197
+ const BindingVerdict = z.strictObject({ active: z.boolean() });
198
+
199
+ export async function assertActiveChannelBinding(
200
+ identity: TurnIdentity,
201
+ env: Pick<
202
+ OracleWorkerEnv,
203
+ 'ORACLE_DID' | 'AUTH_HUB_CHANNEL_SERVICE_KEY' | 'AUTH_HUB' | 'AUTH_HUB_URL'
204
+ >,
205
+ ): Promise<void> {
206
+ if (!identity.channel)
207
+ throw new ChannelError(403, 'Channel identity is required');
208
+ if (!env.AUTH_HUB_CHANNEL_SERVICE_KEY || (!env.AUTH_HUB && !env.AUTH_HUB_URL))
209
+ throw new ChannelError(503, 'Channel binding validation is not configured');
210
+ const url = new URL(
211
+ '/api/internal/channels/validate-binding',
212
+ env.AUTH_HUB_URL ?? 'https://auth-hub.internal',
213
+ );
214
+ if (url.protocol !== 'https:')
215
+ throw new ChannelError(503, 'Auth Hub requires HTTPS');
216
+ const request = new Request(url, {
217
+ method: 'POST',
218
+ headers: {
219
+ 'content-type': 'application/json',
220
+ 'x-channels-service-key': env.AUTH_HUB_CHANNEL_SERVICE_KEY,
221
+ },
222
+ body: JSON.stringify({
223
+ bindingId: identity.channel.bindingId,
224
+ bindingRevision: identity.channel.bindingRevision,
225
+ userDid: identity.userDid,
226
+ provider: identity.channel.provider,
227
+ oracleDid: env.ORACLE_DID,
228
+ }),
229
+ signal: AbortSignal.timeout(10_000),
230
+ redirect: 'manual',
231
+ });
232
+ let response: Response;
233
+ try {
234
+ response = await (env.AUTH_HUB
235
+ ? env.AUTH_HUB.fetch(request)
236
+ : fetch(request));
237
+ } catch {
238
+ throw new ChannelError(503, 'Channel binding validation is unavailable');
239
+ }
240
+ if (!response.ok)
241
+ throw new ChannelError(503, 'Channel binding validation is unavailable');
242
+ const parsed = BindingVerdict.safeParse(await response.json());
243
+ if (!parsed.success)
244
+ throw new ChannelError(503, 'Invalid channel binding validation response');
245
+ if (!parsed.data.active)
246
+ throw new ChannelError(403, 'Channel binding is inactive');
247
+ }
248
+
249
+ /**
250
+ * The binding check before a channel attempt executes (a queued run being
251
+ * dequeued, a recovery attempt). An inactive binding (403) ends the run. An
252
+ * unavailable or unconfigured validator (503) defers the attempt to the run
253
+ * coordinator's recovery backoff: nothing has executed, and one Auth Hub
254
+ * outage must not lose the user's message.
255
+ */
256
+ export async function assertChannelAttemptAllowed(
257
+ identity: TurnIdentity,
258
+ env: Parameters<typeof assertActiveChannelBinding>[1],
259
+ ): Promise<void> {
260
+ try {
261
+ await assertActiveChannelBinding(identity, env);
262
+ } catch (error) {
263
+ if (error instanceof ChannelError && error.status === 503)
264
+ throw new RunAttemptDeferred(error.message, { cause: error });
265
+ throw error;
266
+ }
267
+ }
@@ -0,0 +1,137 @@
1
+ import { z } from 'zod';
2
+ import type { ReplyPlan } from '../delivery/types';
3
+
4
+ export const ChannelTurnBody = z.strictObject({
5
+ provider: z.literal('whatsapp'),
6
+ bindingId: z.string().regex(/^chb_[A-Za-z0-9_-]{1,120}$/),
7
+ bindingRevision: z.number().int().positive().safe(),
8
+ requestId: z.string().min(1).max(180),
9
+ remoteMessageRef: z.string().regex(/^hmac:[a-f0-9]{64}$/),
10
+ sessionId: z.string().min(1).max(255).optional(),
11
+ message: z.string().min(1).max(16_000),
12
+ context: z.strictObject({ kind: z.literal('companion') }),
13
+ });
14
+
15
+ export type ChannelTurnInput = z.infer<typeof ChannelTurnBody>;
16
+
17
+ export const ChannelOrigin = z.strictObject({
18
+ v: z.literal(1),
19
+ transport: z.literal('whatsapp'),
20
+ binding_id: ChannelTurnBody.shape.bindingId,
21
+ remote_ref: ChannelTurnBody.shape.remoteMessageRef,
22
+ });
23
+
24
+ export type ChannelOrigin = z.infer<typeof ChannelOrigin>;
25
+
26
+ export function channelOrigin(
27
+ channel: Pick<
28
+ ChannelTurnInput,
29
+ 'provider' | 'bindingId' | 'remoteMessageRef'
30
+ >,
31
+ ): ChannelOrigin {
32
+ return {
33
+ v: 1,
34
+ transport: channel.provider,
35
+ binding_id: channel.bindingId,
36
+ remote_ref: channel.remoteMessageRef,
37
+ };
38
+ }
39
+
40
+ export interface ChannelIdentity {
41
+ callerDid: string;
42
+ provider: 'whatsapp';
43
+ bindingId: string;
44
+ bindingRevision: number;
45
+ }
46
+
47
+ export interface ChannelTurnResponse {
48
+ requestId: string;
49
+ runId: string;
50
+ sessionId: string;
51
+ status:
52
+ | 'queued'
53
+ | 'running'
54
+ | 'recovering'
55
+ | 'finished'
56
+ | 'aborted'
57
+ | 'interrupted'
58
+ | 'failed';
59
+ messageId?: string;
60
+ text?: string;
61
+ /**
62
+ * A finished turn's Reply Plan: the parts to deliver in order (short
63
+ * messages and artefact links). `text` stays the whole reply as one
64
+ * message for gateways that predate plans.
65
+ */
66
+ plan?: ReplyPlan;
67
+ }
68
+
69
+ export type ChannelTurnOutcome =
70
+ | { ok: true; result: ChannelTurnResponse }
71
+ | {
72
+ ok: false;
73
+ status: 400 | 401 | 403 | 404 | 409 | 410 | 413 | 429 | 503;
74
+ message: string;
75
+ };
76
+
77
+ export class ChannelError extends Error {
78
+ constructor(
79
+ readonly status: 400 | 401 | 403 | 404 | 409 | 410 | 413 | 429 | 503,
80
+ message: string,
81
+ ) {
82
+ super(message);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * A channel turn runs under the user's stored delegation to this oracle,
88
+ * like a Matrix turn. Without one there is no tool authority to run under;
89
+ * the turn is refused with an explicit error instead of running degraded.
90
+ */
91
+ export function requireChannelDelegation(present: boolean): void {
92
+ if (!present)
93
+ throw new ChannelError(
94
+ 409,
95
+ 'Companion delegation required: the user must authorize this oracle again',
96
+ );
97
+ }
98
+
99
+ export async function channelRequestHash(raw: string): Promise<string> {
100
+ const digest = await crypto.subtle.digest(
101
+ 'SHA-256',
102
+ new TextEncoder().encode(raw),
103
+ );
104
+ return Array.from(new Uint8Array(digest), (byte) =>
105
+ byte.toString(16).padStart(2, '0'),
106
+ ).join('');
107
+ }
108
+
109
+ export async function readChannelBody(request: Request): Promise<string> {
110
+ const reader = request.body?.getReader();
111
+ if (!reader) throw new ChannelError(400, 'A channel turn is required');
112
+ const chunks: Uint8Array[] = [];
113
+ let size = 0;
114
+ while (true) {
115
+ const chunk = await reader.read();
116
+ if (chunk.done) break;
117
+ size += chunk.value.byteLength;
118
+ if (size > 64_000) {
119
+ await reader.cancel();
120
+ throw new ChannelError(413, 'Channel request is too large');
121
+ }
122
+ chunks.push(chunk.value);
123
+ }
124
+ const bytes = new Uint8Array(size);
125
+ let offset = 0;
126
+ for (const chunk of chunks) {
127
+ bytes.set(chunk, offset);
128
+ offset += chunk.byteLength;
129
+ }
130
+ try {
131
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
132
+ bytes,
133
+ );
134
+ } catch {
135
+ throw new ChannelError(400, 'Channel request must be UTF-8');
136
+ }
137
+ }