@ixo/oracle-runtime-workers 0.13.0 → 0.16.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.
- package/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- 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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
package/src/attachments/view.ts
CHANGED
|
@@ -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,310 @@
|
|
|
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(
|
|
62
|
+
401,
|
|
63
|
+
'A channel UCAN invocation is required',
|
|
64
|
+
'unauthorized',
|
|
65
|
+
);
|
|
66
|
+
const validator = await createUCANValidator({
|
|
67
|
+
serverDid: config.oracleDid,
|
|
68
|
+
rootIssuers: ['*'],
|
|
69
|
+
didResolver: config.didResolver,
|
|
70
|
+
requireExpiration: true,
|
|
71
|
+
// No replay marks: a poll repeats its exact body and may reuse an
|
|
72
|
+
// invocation, also concurrently. The durable receipt keyed by
|
|
73
|
+
// (user, binding, requestId) and the request hash in the invocation's
|
|
74
|
+
// facts make every repeat return the same run instead of a new one.
|
|
75
|
+
invocationStore: {
|
|
76
|
+
has: async () => false,
|
|
77
|
+
add: async () => undefined,
|
|
78
|
+
addIfAbsent: async () => true,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const result = await validator.validate(
|
|
82
|
+
token,
|
|
83
|
+
ChannelInvoke,
|
|
84
|
+
`ixo:channel:${input.bindingId}`,
|
|
85
|
+
);
|
|
86
|
+
const chain = result.proofChain;
|
|
87
|
+
const userDid = chain?.[0];
|
|
88
|
+
if (
|
|
89
|
+
!result.ok ||
|
|
90
|
+
!userDid?.startsWith('did:ixo:') ||
|
|
91
|
+
chain?.length !== 2 ||
|
|
92
|
+
chain[1] !== config.channelDid ||
|
|
93
|
+
result.invoker !== config.channelDid
|
|
94
|
+
)
|
|
95
|
+
throw new ChannelError(
|
|
96
|
+
401,
|
|
97
|
+
'Invalid user-rooted channel invocation',
|
|
98
|
+
'unauthorized',
|
|
99
|
+
);
|
|
100
|
+
const now = Math.floor(Date.now() / 1000);
|
|
101
|
+
const invocation = await parseDelegation(token);
|
|
102
|
+
const proof = invocation.proofs[0];
|
|
103
|
+
if (
|
|
104
|
+
invocation.proofs.length !== 1 ||
|
|
105
|
+
!proof ||
|
|
106
|
+
!('issuer' in proof) ||
|
|
107
|
+
proof.proofs.length !== 0 ||
|
|
108
|
+
proof.issuer.did() !== userDid ||
|
|
109
|
+
proof.audience.did() !== config.channelDid ||
|
|
110
|
+
proof.capabilities.length !== 1 ||
|
|
111
|
+
proof.capabilities[0]?.can !== 'ixo:channel/invoke' ||
|
|
112
|
+
proof.capabilities[0]?.with !== `ixo:channel:${input.bindingId}` ||
|
|
113
|
+
!Number.isFinite(proof.expiration) ||
|
|
114
|
+
!proof.expiration ||
|
|
115
|
+
proof.expiration > now + 300
|
|
116
|
+
)
|
|
117
|
+
throw new ChannelError(
|
|
118
|
+
401,
|
|
119
|
+
'A bounded, direct user channel grant is required',
|
|
120
|
+
'unauthorized',
|
|
121
|
+
);
|
|
122
|
+
if (
|
|
123
|
+
!result.expiration ||
|
|
124
|
+
result.expiration <= now ||
|
|
125
|
+
result.expiration > now + 60 ||
|
|
126
|
+
!Number.isFinite(invocation.expiration) ||
|
|
127
|
+
!invocation.expiration ||
|
|
128
|
+
invocation.expiration > now + 60
|
|
129
|
+
)
|
|
130
|
+
throw new ChannelError(
|
|
131
|
+
401,
|
|
132
|
+
'Channel invocations must expire within 60 seconds',
|
|
133
|
+
'unauthorized',
|
|
134
|
+
);
|
|
135
|
+
const nb = result.capability?.nb;
|
|
136
|
+
if (
|
|
137
|
+
result.capability?.can !== 'ixo:channel/invoke' ||
|
|
138
|
+
result.capability.with !== `ixo:channel:${input.bindingId}` ||
|
|
139
|
+
nb?.provider !== input.provider ||
|
|
140
|
+
nb.bindingRevision !== input.bindingRevision ||
|
|
141
|
+
nb.oracleDid !== config.oracleDid
|
|
142
|
+
)
|
|
143
|
+
throw new ChannelError(
|
|
144
|
+
403,
|
|
145
|
+
'Channel scope does not match this request',
|
|
146
|
+
'scope_mismatch',
|
|
147
|
+
);
|
|
148
|
+
const hash = await channelRequestHash(raw);
|
|
149
|
+
if (
|
|
150
|
+
result.facts?.length !== 1 ||
|
|
151
|
+
result.facts[0]?.requestId !== input.requestId ||
|
|
152
|
+
result.facts[0]?.requestHash !== hash
|
|
153
|
+
)
|
|
154
|
+
throw new ChannelError(
|
|
155
|
+
403,
|
|
156
|
+
'Invocation does not authorize this request body',
|
|
157
|
+
'scope_mismatch',
|
|
158
|
+
);
|
|
159
|
+
return {
|
|
160
|
+
userDid,
|
|
161
|
+
channel: {
|
|
162
|
+
callerDid: config.channelDid,
|
|
163
|
+
provider: input.provider,
|
|
164
|
+
bindingId: input.bindingId,
|
|
165
|
+
bindingRevision: input.bindingRevision,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* did:web documents (a channel service identity) resolved for every channel
|
|
172
|
+
* request, shared per isolate: successful lookups are cached as long as
|
|
173
|
+
* did:ixo keys, failures never, and concurrent lookups share one request.
|
|
174
|
+
*/
|
|
175
|
+
const channelWebResolver = createWebDIDResolver({
|
|
176
|
+
cacheTtlMs: IXO_DID_RESOLUTION_CACHE_TTL_MS,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export function channelAuthConfig(
|
|
180
|
+
env: Pick<
|
|
181
|
+
OracleWorkerEnv,
|
|
182
|
+
'CHANNEL_SERVICE_DID' | 'BLOCKSYNC_GRAPHQL_URL' | 'ORACLE_DID'
|
|
183
|
+
>,
|
|
184
|
+
): ChannelAuthConfig {
|
|
185
|
+
if (!env.CHANNEL_SERVICE_DID)
|
|
186
|
+
throw new ChannelError(
|
|
187
|
+
503,
|
|
188
|
+
'Channels are not configured',
|
|
189
|
+
'not_configured',
|
|
190
|
+
);
|
|
191
|
+
const resolver = createCompositeDIDResolver([
|
|
192
|
+
sharedIxoDIDResolver(env.BLOCKSYNC_GRAPHQL_URL),
|
|
193
|
+
channelWebResolver,
|
|
194
|
+
]);
|
|
195
|
+
return {
|
|
196
|
+
oracleDid: env.ORACLE_DID,
|
|
197
|
+
channelDid: env.CHANNEL_SERVICE_DID,
|
|
198
|
+
didResolver: async (did) => {
|
|
199
|
+
if (
|
|
200
|
+
did === env.CHANNEL_SERVICE_DID ||
|
|
201
|
+
did === env.ORACLE_DID ||
|
|
202
|
+
did.startsWith('did:ixo:')
|
|
203
|
+
)
|
|
204
|
+
return resolver(did);
|
|
205
|
+
return {
|
|
206
|
+
error: {
|
|
207
|
+
name: 'DIDKeyResolutionError',
|
|
208
|
+
did,
|
|
209
|
+
message: 'Unapproved channel identity',
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const BindingVerdict = z.strictObject({ active: z.boolean() });
|
|
217
|
+
|
|
218
|
+
export async function assertActiveChannelBinding(
|
|
219
|
+
identity: TurnIdentity,
|
|
220
|
+
env: Pick<
|
|
221
|
+
OracleWorkerEnv,
|
|
222
|
+
'ORACLE_DID' | 'AUTH_HUB_CHANNEL_SERVICE_KEY' | 'AUTH_HUB' | 'AUTH_HUB_URL'
|
|
223
|
+
>,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
if (!identity.channel)
|
|
226
|
+
throw new ChannelError(
|
|
227
|
+
403,
|
|
228
|
+
'Channel identity is required',
|
|
229
|
+
'identity_required',
|
|
230
|
+
);
|
|
231
|
+
if (!env.AUTH_HUB_CHANNEL_SERVICE_KEY || (!env.AUTH_HUB && !env.AUTH_HUB_URL))
|
|
232
|
+
throw new ChannelError(
|
|
233
|
+
503,
|
|
234
|
+
'Channel binding validation is not configured',
|
|
235
|
+
'not_configured',
|
|
236
|
+
);
|
|
237
|
+
const url = new URL(
|
|
238
|
+
'/api/internal/channels/validate-binding',
|
|
239
|
+
env.AUTH_HUB_URL ?? 'https://auth-hub.internal',
|
|
240
|
+
);
|
|
241
|
+
if (url.protocol !== 'https:')
|
|
242
|
+
throw new ChannelError(503, 'Auth Hub requires HTTPS', 'not_configured');
|
|
243
|
+
const request = new Request(url, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
headers: {
|
|
246
|
+
'content-type': 'application/json',
|
|
247
|
+
'x-channels-service-key': env.AUTH_HUB_CHANNEL_SERVICE_KEY,
|
|
248
|
+
},
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
bindingId: identity.channel.bindingId,
|
|
251
|
+
bindingRevision: identity.channel.bindingRevision,
|
|
252
|
+
userDid: identity.userDid,
|
|
253
|
+
provider: identity.channel.provider,
|
|
254
|
+
oracleDid: env.ORACLE_DID,
|
|
255
|
+
}),
|
|
256
|
+
signal: AbortSignal.timeout(10_000),
|
|
257
|
+
redirect: 'manual',
|
|
258
|
+
});
|
|
259
|
+
let response: Response;
|
|
260
|
+
try {
|
|
261
|
+
response = await (env.AUTH_HUB
|
|
262
|
+
? env.AUTH_HUB.fetch(request)
|
|
263
|
+
: fetch(request));
|
|
264
|
+
} catch {
|
|
265
|
+
throw new ChannelError(
|
|
266
|
+
503,
|
|
267
|
+
'Channel binding validation is unavailable',
|
|
268
|
+
'binding_check_unavailable',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (!response.ok)
|
|
272
|
+
throw new ChannelError(
|
|
273
|
+
503,
|
|
274
|
+
'Channel binding validation is unavailable',
|
|
275
|
+
'binding_check_unavailable',
|
|
276
|
+
);
|
|
277
|
+
const parsed = BindingVerdict.safeParse(await response.json());
|
|
278
|
+
if (!parsed.success)
|
|
279
|
+
throw new ChannelError(
|
|
280
|
+
503,
|
|
281
|
+
'Invalid channel binding validation response',
|
|
282
|
+
'binding_check_unavailable',
|
|
283
|
+
);
|
|
284
|
+
if (!parsed.data.active)
|
|
285
|
+
throw new ChannelError(
|
|
286
|
+
403,
|
|
287
|
+
'Channel binding is inactive',
|
|
288
|
+
'binding_inactive',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The binding check before a channel attempt executes (a queued run being
|
|
294
|
+
* dequeued, a recovery attempt). An inactive binding (403) ends the run. An
|
|
295
|
+
* unavailable or unconfigured validator (503) defers the attempt to the run
|
|
296
|
+
* coordinator's recovery backoff: nothing has executed, and one Auth Hub
|
|
297
|
+
* outage must not lose the user's message.
|
|
298
|
+
*/
|
|
299
|
+
export async function assertChannelAttemptAllowed(
|
|
300
|
+
identity: TurnIdentity,
|
|
301
|
+
env: Parameters<typeof assertActiveChannelBinding>[1],
|
|
302
|
+
): Promise<void> {
|
|
303
|
+
try {
|
|
304
|
+
await assertActiveChannelBinding(identity, env);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof ChannelError && error.status === 503)
|
|
307
|
+
throw new RunAttemptDeferred(error.message, { cause: error });
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|