@ixo/oracle-runtime-workers 0.17.1 → 0.18.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/package.json +3 -3
- package/src/channels/auth.ts +65 -0
- package/src/channels/contract.ts +32 -2
- package/src/channels/failures.ts +64 -0
- package/src/channels/turns.ts +660 -105
- package/src/core/index.ts +36 -3
- package/src/do/contracts.ts +10 -0
- package/src/do/room-mirror.ts +7 -2
- package/src/do/run-store-test-do.ts +2 -2
- package/src/do/run-store.ts +41 -6
- package/src/do/user-oracle-do.ts +188 -77
- package/src/shell/app.ts +16 -9
package/src/core/index.ts
CHANGED
|
@@ -488,11 +488,27 @@ export function createRuntimeCore(opts: RuntimeCoreOptions): RuntimeCore {
|
|
|
488
488
|
registries.manifests.register(plugin, manifestOverrides[plugin.name]);
|
|
489
489
|
}
|
|
490
490
|
const manifestErrors: string[] = [];
|
|
491
|
+
const lintVerbose = opts.env['BOOT_LINT_VERBOSE'] === 'true';
|
|
492
|
+
const lintCounts = new Map<string, number>();
|
|
491
493
|
for (const { pluginName, manifest } of registries.manifests.collect()) {
|
|
492
494
|
const result = validateManifest(manifest, pluginName);
|
|
493
|
-
|
|
495
|
+
if (lintVerbose)
|
|
496
|
+
for (const warning of result.warnings) logger.warn(`[boot] ${warning}`);
|
|
497
|
+
else if (result.warnings.length > 0)
|
|
498
|
+
lintCounts.set(pluginName, result.warnings.length);
|
|
494
499
|
if (!result.valid) manifestErrors.push(...result.errors);
|
|
495
500
|
}
|
|
501
|
+
// The soft-rule warnings are the same on every boot of an isolate (one
|
|
502
|
+
// core per isolate): one summary line, the full list with
|
|
503
|
+
// BOOT_LINT_VERBOSE=true.
|
|
504
|
+
if (lintCounts.size > 0)
|
|
505
|
+
logger.warn(
|
|
506
|
+
`[boot] manifest lint: ${[...lintCounts.values()].reduce((a, b) => a + b, 0)} soft warning(s) (${[
|
|
507
|
+
...lintCounts,
|
|
508
|
+
]
|
|
509
|
+
.map(([plugin, count]) => `${plugin}=${count}`)
|
|
510
|
+
.join(', ')}); set BOOT_LINT_VERBOSE=true to list them`,
|
|
511
|
+
);
|
|
496
512
|
const guides = registries.manifests.operatingGuides();
|
|
497
513
|
if (guides.length > 0) {
|
|
498
514
|
logger.log(
|
|
@@ -707,9 +723,26 @@ export function createRuntimeCore(opts: RuntimeCoreOptions): RuntimeCore {
|
|
|
707
723
|
registries.tools,
|
|
708
724
|
registries.subAgents,
|
|
709
725
|
);
|
|
710
|
-
|
|
726
|
+
if (lintVerbose) {
|
|
727
|
+
for (const issue of cross.errors) {
|
|
728
|
+
logger.warn(
|
|
729
|
+
`[boot] ${issue} (boot-time tools only — ignore if the tool comes from getRequestTools)`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
} else if (cross.errors.length > 0) {
|
|
733
|
+
const perPlugin = new Map<string, number>();
|
|
734
|
+
for (const issue of cross.errors) {
|
|
735
|
+
const plugin = /^\[([^\]]+)\]/.exec(issue)?.[1] ?? 'unknown';
|
|
736
|
+
perPlugin.set(plugin, (perPlugin.get(plugin) ?? 0) + 1);
|
|
737
|
+
}
|
|
711
738
|
logger.warn(
|
|
712
|
-
`[boot] ${
|
|
739
|
+
`[boot] manifest examples: ${cross.errors.length} reference(s) to tools not registered at boot (${[
|
|
740
|
+
...perPlugin,
|
|
741
|
+
]
|
|
742
|
+
.map(([plugin, count]) => `${plugin}=${count}`)
|
|
743
|
+
.join(
|
|
744
|
+
', ',
|
|
745
|
+
)}; ignore those from getRequestTools); set BOOT_LINT_VERBOSE=true to list them`,
|
|
713
746
|
);
|
|
714
747
|
}
|
|
715
748
|
} catch (err) {
|
package/src/do/contracts.ts
CHANGED
|
@@ -660,6 +660,16 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
|
|
|
660
660
|
input: ChannelTurnInput,
|
|
661
661
|
requestHash: string,
|
|
662
662
|
): Promise<ChannelTurnOutcome>;
|
|
663
|
+
/**
|
|
664
|
+
* A poll of a channel request whose run exists, answered by a warm
|
|
665
|
+
* object; `null` = admit it through `channelTurn` (after the binding
|
|
666
|
+
* check).
|
|
667
|
+
*/
|
|
668
|
+
channelPoll(
|
|
669
|
+
identity: TurnIdentity,
|
|
670
|
+
input: ChannelTurnInput,
|
|
671
|
+
requestHash: string,
|
|
672
|
+
): Promise<ChannelTurnOutcome | null>;
|
|
663
673
|
/** Run a turn and return the final text (used by the Matrix gateway). */
|
|
664
674
|
runTurn(req: TurnRequest): Promise<TurnResult>;
|
|
665
675
|
/**
|
package/src/do/room-mirror.ts
CHANGED
|
@@ -97,8 +97,10 @@ export class RoomMirror {
|
|
|
97
97
|
/**
|
|
98
98
|
* Queue one mirror behind the session's previous ones. `prepare` runs when
|
|
99
99
|
* the session's earlier mirrors have settled and resolves what to send
|
|
100
|
-
* (`null` = nothing to mirror, e.g. no room).
|
|
101
|
-
*
|
|
100
|
+
* (`null` = nothing to mirror, e.g. no room). A mirror that cannot be
|
|
101
|
+
* posted is logged and the chain moves on; a `required` one (a channel
|
|
102
|
+
* request's, whose receipt records the delivery) rejects its caller
|
|
103
|
+
* instead, and the chain still moves on.
|
|
102
104
|
*/
|
|
103
105
|
enqueue(
|
|
104
106
|
sessionId: string,
|
|
@@ -107,7 +109,10 @@ export class RoomMirror {
|
|
|
107
109
|
required = false,
|
|
108
110
|
): Promise<void> {
|
|
109
111
|
const previous = this.chains.get(sessionId) ?? Promise.resolve();
|
|
112
|
+
// Starts once the previous mirror settled either way: a required mirror
|
|
113
|
+
// that failed (and rejected its own caller) must not skip this one.
|
|
110
114
|
const tracked: Promise<void> = previous
|
|
115
|
+
.catch(() => undefined)
|
|
111
116
|
.then(() => prepare())
|
|
112
117
|
.then((send) => (send ? this.send(send, label) : undefined))
|
|
113
118
|
.catch((err: unknown) => {
|
|
@@ -412,8 +412,8 @@ async function legacyPrune(db: DoSqliteDatabase, now: number): Promise<void> {
|
|
|
412
412
|
await db.transaction(async () => {
|
|
413
413
|
if (row.client === 'channel')
|
|
414
414
|
await db.run(
|
|
415
|
-
'INSERT OR IGNORE INTO channel_run_tombstones (run_id, status) VALUES (?, ?)',
|
|
416
|
-
[row.run_id, row.status],
|
|
415
|
+
'INSERT OR IGNORE INTO channel_run_tombstones (run_id, status, pruned_at) VALUES (?, ?, ?)',
|
|
416
|
+
[row.run_id, row.status, now],
|
|
417
417
|
);
|
|
418
418
|
for (const table of [
|
|
419
419
|
'turn_tool_marks',
|
package/src/do/run-store.ts
CHANGED
|
@@ -486,8 +486,26 @@ export class RunStore {
|
|
|
486
486
|
state TEXT NOT NULL DEFAULT 'pending'
|
|
487
487
|
) WITHOUT ROWID`);
|
|
488
488
|
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_run_tombstones (
|
|
489
|
-
run_id TEXT PRIMARY KEY, status TEXT NOT NULL
|
|
489
|
+
run_id TEXT PRIMARY KEY, status TEXT NOT NULL, pruned_at INTEGER
|
|
490
490
|
) WITHOUT ROWID`);
|
|
491
|
+
// When the run was pruned (epoch ms): tombstones are removed a while
|
|
492
|
+
// later (`pruneChannelTombstones`). Rows from before the column count
|
|
493
|
+
// from its migration.
|
|
494
|
+
const tombstoneColumns = await this.db.exec<{ name: string }>(
|
|
495
|
+
'PRAGMA table_info(channel_run_tombstones)',
|
|
496
|
+
);
|
|
497
|
+
if (!tombstoneColumns.some((c) => c.name === 'pruned_at')) {
|
|
498
|
+
await this.db.run(
|
|
499
|
+
'ALTER TABLE channel_run_tombstones ADD COLUMN pruned_at INTEGER',
|
|
500
|
+
);
|
|
501
|
+
await this.db.run(
|
|
502
|
+
'UPDATE channel_run_tombstones SET pruned_at = ? WHERE pruned_at IS NULL',
|
|
503
|
+
[this.now()],
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
await this.db.run(
|
|
507
|
+
'CREATE INDEX IF NOT EXISTS idx_channel_run_tombstones_pruned ON channel_run_tombstones(pruned_at)',
|
|
508
|
+
);
|
|
491
509
|
// The Reply Plan a finished chat-surface run delivers (chat delivery).
|
|
492
510
|
await this.db.run(`CREATE TABLE IF NOT EXISTS turn_run_plans (
|
|
493
511
|
run_id TEXT PRIMARY KEY, plan TEXT NOT NULL
|
|
@@ -519,9 +537,9 @@ export class RunStore {
|
|
|
519
537
|
if (await this.db.get(`${stale} LIMIT 1`, [cutoff]))
|
|
520
538
|
await this.db.transaction(async () => {
|
|
521
539
|
await this.db.run(
|
|
522
|
-
`INSERT OR IGNORE INTO channel_run_tombstones (run_id, status)
|
|
523
|
-
SELECT run_id, status FROM turn_runs WHERE client = 'channel' AND ${STALE_RUN_FILTER}`,
|
|
524
|
-
[cutoff],
|
|
540
|
+
`INSERT OR IGNORE INTO channel_run_tombstones (run_id, status, pruned_at)
|
|
541
|
+
SELECT run_id, status, ? FROM turn_runs WHERE client = 'channel' AND ${STALE_RUN_FILTER}`,
|
|
542
|
+
[this.now(), cutoff],
|
|
525
543
|
);
|
|
526
544
|
for (const table of [
|
|
527
545
|
'turn_tool_marks',
|
|
@@ -537,8 +555,9 @@ export class RunStore {
|
|
|
537
555
|
});
|
|
538
556
|
// Every receipt whose run is tombstoned, this boot or an earlier one
|
|
539
557
|
// that stopped before getting here. Driven by the receipts (at most the
|
|
540
|
-
// retention window of them), one key lookup each into the tombstones
|
|
541
|
-
//
|
|
558
|
+
// retention window of them), one key lookup each into the tombstones
|
|
559
|
+
// (pruned separately, by age, from the housekeeping alarm through
|
|
560
|
+
// `pruneChannelTombstones`, never scanned here). The table exists once the
|
|
542
561
|
// object has served a channel turn.
|
|
543
562
|
const receipts = await this.db.get<{ name: string }>(
|
|
544
563
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'channel_requests'`,
|
|
@@ -569,6 +588,22 @@ export class RunStore {
|
|
|
569
588
|
return row?.plan ?? null;
|
|
570
589
|
}
|
|
571
590
|
|
|
591
|
+
/**
|
|
592
|
+
* Remove up to `limit` tombstones of channel runs pruned before `before`
|
|
593
|
+
* (epoch ms); returns how many went. A request id reused after its
|
|
594
|
+
* tombstone is gone is a new request and runs again.
|
|
595
|
+
*/
|
|
596
|
+
async pruneChannelTombstones(before: number, limit: number): Promise<number> {
|
|
597
|
+
await this.setup();
|
|
598
|
+
const pruned = await this.db.run(
|
|
599
|
+
`DELETE FROM channel_run_tombstones WHERE run_id IN (
|
|
600
|
+
SELECT run_id FROM channel_run_tombstones WHERE pruned_at < ? LIMIT ?
|
|
601
|
+
)`,
|
|
602
|
+
[before, limit],
|
|
603
|
+
);
|
|
604
|
+
return pruned.changes;
|
|
605
|
+
}
|
|
606
|
+
|
|
572
607
|
async wasChannelRunPruned(runId: string): Promise<boolean> {
|
|
573
608
|
await this.setup();
|
|
574
609
|
return !!(await this.db.get<{ run_id: string }>(
|
package/src/do/user-oracle-do.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { ChannelTurns } from '../channels/turns';
|
|
2
2
|
import { assertChannelAttemptAllowed } from '../channels/auth';
|
|
3
3
|
import {
|
|
4
|
+
CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
4
5
|
ChannelError,
|
|
5
6
|
channelRequestHash,
|
|
6
7
|
channelOrigin,
|
|
7
8
|
delegationRequiredError,
|
|
8
9
|
requireChannelDelegation,
|
|
10
|
+
StaleSessionError,
|
|
9
11
|
type ChannelTurnInput,
|
|
10
12
|
type ChannelTurnOutcome,
|
|
11
13
|
} from '../channels/contract';
|
|
14
|
+
import { classifyChannelFailure } from '../channels/failures';
|
|
12
15
|
/* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
|
|
13
16
|
/**
|
|
14
17
|
* `UserOracleDO` — one Durable Object per (user DID, oracle).
|
|
@@ -498,6 +501,8 @@ const META_LAST_ACCESS = 'meta:lastAccessAt';
|
|
|
498
501
|
const META_DELEGATION = 'meta:delegation';
|
|
499
502
|
/** How long a "no deposited delegation" verdict is trusted before re-checking the room. */
|
|
500
503
|
const DELEGATION_MISS_TTL_MS = 60_000;
|
|
504
|
+
/** Storage key prefix: a room this object found end-to-end encrypted. */
|
|
505
|
+
const ROOM_ENCRYPTED_PREFIX = 'room-encrypted:';
|
|
501
506
|
const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
|
|
502
507
|
/** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
|
|
503
508
|
const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
|
|
@@ -862,6 +867,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
862
867
|
private readonly sessionRooms = new Map<string, string>();
|
|
863
868
|
/** The user's main oracle room, once resolved (the session list is scoped to it). */
|
|
864
869
|
private mainRoomId: string | null = null;
|
|
870
|
+
/** Rooms known to be end-to-end encrypted (see `roomIsEncrypted`). */
|
|
871
|
+
private readonly encryptedRooms = new Set<string>();
|
|
865
872
|
/** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
|
|
866
873
|
private reauthPrompter: ReauthPrompter | null = null;
|
|
867
874
|
/** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
|
|
@@ -1165,9 +1172,19 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1165
1172
|
* this object's cache, then the `ucan_delegation` room state the client
|
|
1166
1173
|
* deposited. Nothing found → plugins degrade exactly as before (no
|
|
1167
1174
|
* downstream auth), re-checked after `DELEGATION_MISS_TTL_MS`.
|
|
1175
|
+
*
|
|
1176
|
+
* `minRemainingSeconds` (the channel path's margin): a stored copy with
|
|
1177
|
+
* less than that left is held, but the room state is read anyway, at
|
|
1178
|
+
* once and without the miss throttle — a newer deposit whose push to
|
|
1179
|
+
* this object failed is there — and adopted when it outlives the held
|
|
1180
|
+
* copy. The throttle stays for true misses (nothing stored).
|
|
1168
1181
|
*/
|
|
1169
|
-
private async hydrateDelegation(
|
|
1182
|
+
private async hydrateDelegation(
|
|
1183
|
+
userDid: string,
|
|
1184
|
+
options: { minRemainingSeconds?: number } = {},
|
|
1185
|
+
): Promise<void> {
|
|
1170
1186
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1187
|
+
const margin = options.minRemainingSeconds ?? 0;
|
|
1171
1188
|
const live = (
|
|
1172
1189
|
d: StoredDelegation | null | undefined,
|
|
1173
1190
|
): d is StoredDelegation =>
|
|
@@ -1175,14 +1192,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1175
1192
|
(typeof d?.expiration !== 'number' || d.expiration > nowSeconds);
|
|
1176
1193
|
const cached =
|
|
1177
1194
|
await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
|
|
1195
|
+
let held: CachedDelegation | null = null;
|
|
1178
1196
|
if (live(cached)) {
|
|
1179
|
-
this.
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1197
|
+
held = await this.delegationRecord(cached.raw, cached.expiration);
|
|
1198
|
+
this.delegations.set(userDid, held);
|
|
1199
|
+
if (
|
|
1200
|
+
margin === 0 ||
|
|
1201
|
+
(typeof held.expiration === 'number' &&
|
|
1202
|
+
held.expiration > nowSeconds + margin)
|
|
1203
|
+
)
|
|
1204
|
+
return;
|
|
1184
1205
|
}
|
|
1185
|
-
if (Date.now() < this.delegationMissUntil) return;
|
|
1206
|
+
if (!held && Date.now() < this.delegationMissUntil) return;
|
|
1186
1207
|
try {
|
|
1187
1208
|
const room = await this.gateway.resolveUserRoom(userDid);
|
|
1188
1209
|
const json = room
|
|
@@ -1209,15 +1230,23 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1209
1230
|
: {}),
|
|
1210
1231
|
}
|
|
1211
1232
|
: null;
|
|
1212
|
-
if (live(deposited)) {
|
|
1213
|
-
this.
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
);
|
|
1217
|
-
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
1218
|
-
console.log(
|
|
1219
|
-
`[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
|
|
1233
|
+
if (live(deposited) && deposited.raw !== held?.raw) {
|
|
1234
|
+
const record = await this.delegationRecord(
|
|
1235
|
+
deposited.raw,
|
|
1236
|
+
deposited.expiration,
|
|
1220
1237
|
);
|
|
1238
|
+
// A held copy is replaced only by a deposit that outlives it.
|
|
1239
|
+
if (
|
|
1240
|
+
!held ||
|
|
1241
|
+
(typeof record.expiration === 'number' &&
|
|
1242
|
+
record.expiration > (held.expiration ?? 0))
|
|
1243
|
+
) {
|
|
1244
|
+
this.delegations.set(userDid, record);
|
|
1245
|
+
await this.ctx.storage.put(META_DELEGATION, deposited);
|
|
1246
|
+
console.log(
|
|
1247
|
+
`[user-do] delegation for ${userDid} loaded from the deposited room state (${held ? 'the held copy was inside the margin' : 'header-less turn'})`,
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1221
1250
|
return;
|
|
1222
1251
|
}
|
|
1223
1252
|
} catch (err) {
|
|
@@ -1225,7 +1254,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1225
1254
|
`[user-do] could not load ${userDid}'s deposited delegation: ${err instanceof Error ? err.message : String(err)}`,
|
|
1226
1255
|
);
|
|
1227
1256
|
}
|
|
1228
|
-
this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
1257
|
+
if (!held) this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
|
|
1229
1258
|
}
|
|
1230
1259
|
|
|
1231
1260
|
/**
|
|
@@ -1701,57 +1730,58 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1701
1730
|
|
|
1702
1731
|
this.channelTurns = new ChannelTurns(liveDb, {
|
|
1703
1732
|
createSession: async (identity, markerTxnId) => {
|
|
1704
|
-
const
|
|
1705
|
-
if (!
|
|
1733
|
+
const roomId = await this.resolveMainRoomId(identity.userDid);
|
|
1734
|
+
if (!roomId)
|
|
1706
1735
|
throw new ChannelError(
|
|
1707
1736
|
428,
|
|
1708
1737
|
'Companion room is not ready',
|
|
1709
1738
|
'room_not_ready',
|
|
1710
1739
|
);
|
|
1711
|
-
return (
|
|
1712
|
-
|
|
1713
|
-
roomId: room.roomId,
|
|
1714
|
-
markerTxnId,
|
|
1715
|
-
})
|
|
1716
|
-
).sessionId;
|
|
1740
|
+
return (await this.createSession(identity, { roomId, markerTxnId }))
|
|
1741
|
+
.sessionId;
|
|
1717
1742
|
},
|
|
1718
1743
|
assertSession: async (identity, sessionId) => {
|
|
1719
1744
|
const session = await this.sessions!.getSession(sessionId);
|
|
1720
|
-
|
|
1745
|
+
// Deleted (or never created here): admission releases the
|
|
1746
|
+
// binding's hold on it instead of refusing for ever.
|
|
1747
|
+
if (!session) throw new StaleSessionError();
|
|
1748
|
+
const roomId = await this.resolveMainRoomId(identity.userDid);
|
|
1721
1749
|
if (
|
|
1722
|
-
!session ||
|
|
1723
1750
|
!sessionId.startsWith('$') ||
|
|
1724
|
-
!
|
|
1725
|
-
session.roomId !==
|
|
1751
|
+
!roomId ||
|
|
1752
|
+
session.roomId !== roomId
|
|
1726
1753
|
)
|
|
1727
1754
|
throw new ChannelError(
|
|
1728
1755
|
404,
|
|
1729
1756
|
'Companion session not found',
|
|
1730
1757
|
'session_not_found',
|
|
1731
1758
|
);
|
|
1732
|
-
|
|
1733
|
-
room.roomId,
|
|
1734
|
-
'm.room.encryption',
|
|
1735
|
-
);
|
|
1736
|
-
const parsed: unknown = encryption ? JSON.parse(encryption) : null;
|
|
1737
|
-
if (
|
|
1738
|
-
!parsed ||
|
|
1739
|
-
typeof parsed !== 'object' ||
|
|
1740
|
-
!('algorithm' in parsed) ||
|
|
1741
|
-
parsed.algorithm !== 'm.megolm.v1.aes-sha2'
|
|
1742
|
-
)
|
|
1759
|
+
if (!(await this.roomIsEncrypted(roomId)))
|
|
1743
1760
|
throw new ChannelError(
|
|
1744
1761
|
409,
|
|
1745
1762
|
'Companion room must be encrypted',
|
|
1746
1763
|
'room_not_encrypted',
|
|
1747
1764
|
);
|
|
1748
1765
|
},
|
|
1749
|
-
//
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1766
|
+
// A new request only (a poll of an existing run is never refused
|
|
1767
|
+
// for it); checked again as the run starts. A held delegation
|
|
1768
|
+
// inside the margin re-reads the deposited room state first.
|
|
1769
|
+
requireDelegation: async (identity) => {
|
|
1770
|
+
try {
|
|
1771
|
+
requireChannelDelegation(
|
|
1772
|
+
this.delegations.get(identity.userDid),
|
|
1773
|
+
Math.floor(Date.now() / 1000),
|
|
1774
|
+
);
|
|
1775
|
+
} catch {
|
|
1776
|
+
await this.hydrateDelegation(identity.userDid, {
|
|
1777
|
+
minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
1778
|
+
});
|
|
1779
|
+
requireChannelDelegation(
|
|
1780
|
+
this.delegations.get(identity.userDid),
|
|
1781
|
+
Math.floor(Date.now() / 1000),
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1755
1785
|
getRun: (runId) => this.runStore!.get(runId),
|
|
1756
1786
|
getPlan: (runId) => this.runStore!.getPlan(runId),
|
|
1757
1787
|
wasPruned: (runId) => this.runStore!.wasChannelRunPruned(runId),
|
|
@@ -1772,12 +1802,18 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
1772
1802
|
this.ctx.waitUntil(live.done.catch(() => undefined));
|
|
1773
1803
|
return live.record;
|
|
1774
1804
|
},
|
|
1805
|
+
requestOf: (record) =>
|
|
1806
|
+
(JSON.parse(record.request) as StoredRunRequest).turn,
|
|
1775
1807
|
mirror: (request, text, author) =>
|
|
1776
1808
|
this.replayToRoom(request, text, author),
|
|
1777
1809
|
changed: async () => {
|
|
1778
1810
|
this.markDirty();
|
|
1779
1811
|
await this.ctx.storage.sync();
|
|
1780
1812
|
},
|
|
1813
|
+
scheduleRetry: (at) => this.ctx.waitUntil(this.requestHousekeeping(at)),
|
|
1814
|
+
keepAlive: (work) => this.ctx.waitUntil(work),
|
|
1815
|
+
pruneTombstones: (before, limit) =>
|
|
1816
|
+
this.runStore!.pruneChannelTombstones(before, limit),
|
|
1781
1817
|
});
|
|
1782
1818
|
|
|
1783
1819
|
const core = this.core;
|
|
@@ -2316,6 +2352,20 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2316
2352
|
if (next !== null) deadlines.push(Math.max(next, now + 1000));
|
|
2317
2353
|
}
|
|
2318
2354
|
|
|
2355
|
+
// Channel requests: retry the Companion room mirrors that are due, and
|
|
2356
|
+
// prune (bounded) receipts that never started a run and old run
|
|
2357
|
+
// tombstones (channels/turns.ts).
|
|
2358
|
+
if (this.channelTurns) {
|
|
2359
|
+
try {
|
|
2360
|
+
const next = await this.channelTurns.housekeeping();
|
|
2361
|
+
if (next !== null) deadlines.push(Math.max(next, now + 1000));
|
|
2362
|
+
} catch (err) {
|
|
2363
|
+
console.error(
|
|
2364
|
+
`[user-do] channel housekeeping failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2319
2369
|
// Legacy-blob compaction: one bounded batch per tick until the whole
|
|
2320
2370
|
// file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
|
|
2321
2371
|
// flight holds a snapshot of the file, which the compactor's swap-in
|
|
@@ -2663,24 +2713,28 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2663
2713
|
'channel_grant_not_tool_authority',
|
|
2664
2714
|
);
|
|
2665
2715
|
// The shell (the only caller) checked the binding just before.
|
|
2666
|
-
// The
|
|
2667
|
-
//
|
|
2668
|
-
//
|
|
2669
|
-
//
|
|
2670
|
-
//
|
|
2671
|
-
//
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
)
|
|
2716
|
+
// The 900 s margin is checked by admission, for a NEW request only
|
|
2717
|
+
// (and again as its run starts): a poll of a run that exists
|
|
2718
|
+
// answers its status whatever the delegation's state. Before a
|
|
2719
|
+
// COLD object boots, though, a delegation must be live (present,
|
|
2720
|
+
// unexpired): the boot may load the owner copy under it, and a user
|
|
2721
|
+
// without one must get `delegation_required`, not a boot failure.
|
|
2722
|
+
// Hydration reads the stored copy, then the deposited room state
|
|
2723
|
+
// (re-read when the stored copy is inside the margin).
|
|
2724
|
+
if (!this.db) {
|
|
2725
|
+
const live = (): boolean => {
|
|
2726
|
+
const held = this.delegations.get(identity.userDid);
|
|
2727
|
+
return (
|
|
2728
|
+
Boolean(held?.raw) &&
|
|
2729
|
+
(typeof held?.expiration !== 'number' ||
|
|
2730
|
+
held.expiration > Math.floor(Date.now() / 1000))
|
|
2731
|
+
);
|
|
2732
|
+
};
|
|
2733
|
+
if (!live())
|
|
2734
|
+
await this.hydrateDelegation(identity.userDid, {
|
|
2735
|
+
minRemainingSeconds: CHANNEL_DELEGATION_MIN_REMAINING_SECONDS,
|
|
2736
|
+
});
|
|
2737
|
+
if (!live()) throw delegationRequiredError();
|
|
2684
2738
|
}
|
|
2685
2739
|
try {
|
|
2686
2740
|
await this.ready(identity);
|
|
@@ -2699,17 +2753,48 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2699
2753
|
result: await this.channelTurns!.submit(identity, input, requestHash),
|
|
2700
2754
|
};
|
|
2701
2755
|
} catch (error) {
|
|
2702
|
-
|
|
2756
|
+
// A failure retrying cannot fix (the room refused the oracle, the
|
|
2757
|
+
// file storage rejected it) is a final refusal, not a 503.
|
|
2758
|
+
const refusal =
|
|
2759
|
+
error instanceof ChannelError ? error : classifyChannelFailure(error);
|
|
2760
|
+
if (refusal)
|
|
2703
2761
|
return {
|
|
2704
2762
|
ok: false,
|
|
2705
|
-
status:
|
|
2706
|
-
code:
|
|
2707
|
-
message:
|
|
2763
|
+
status: refusal.status,
|
|
2764
|
+
code: refusal.code,
|
|
2765
|
+
message: refusal.message,
|
|
2708
2766
|
};
|
|
2709
2767
|
throw error;
|
|
2710
2768
|
}
|
|
2711
2769
|
}
|
|
2712
2770
|
|
|
2771
|
+
/**
|
|
2772
|
+
* A poll of a channel request whose run exists, answered by a warm
|
|
2773
|
+
* object from SQLite without booting, admitting or waiting on the
|
|
2774
|
+
* admission lock. `null` when the object is cold or the request has no
|
|
2775
|
+
* run yet: the shell then checks the binding with the Auth Hub and
|
|
2776
|
+
* calls `channelTurn`. A binding revoked meanwhile is still enforced
|
|
2777
|
+
* where it matters: before a queued or recovered attempt executes.
|
|
2778
|
+
*/
|
|
2779
|
+
async channelPoll(
|
|
2780
|
+
identity: TurnIdentity,
|
|
2781
|
+
input: ChannelTurnInput,
|
|
2782
|
+
requestHash: string,
|
|
2783
|
+
): Promise<ChannelTurnOutcome | null> {
|
|
2784
|
+
if (identity.ucanDelegation || !this.initPromise || this.evicting)
|
|
2785
|
+
return null;
|
|
2786
|
+
try {
|
|
2787
|
+
await this.initPromise;
|
|
2788
|
+
} catch {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
if (this.userDid !== identity.userDid || !this.channelTurns) return null;
|
|
2792
|
+
const result = await this.channelTurns.poll(identity, input, requestHash);
|
|
2793
|
+
if (!result) return null;
|
|
2794
|
+
await this.recordAccess();
|
|
2795
|
+
return { ok: true, result };
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2713
2798
|
async listSessions(
|
|
2714
2799
|
identity: TurnIdentity,
|
|
2715
2800
|
o: { limit?: number; offset?: number } = {},
|
|
@@ -2745,6 +2830,37 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
2745
2830
|
return room?.roomId ?? null;
|
|
2746
2831
|
}
|
|
2747
2832
|
|
|
2833
|
+
/**
|
|
2834
|
+
* Whether the room is end-to-end encrypted (Megolm). Encryption cannot
|
|
2835
|
+
* be turned off in Matrix, so a positive answer is remembered for good,
|
|
2836
|
+
* in memory and in storage (`room-encrypted:<roomId>`): the homeserver
|
|
2837
|
+
* is asked once per room, not once per channel admission. A negative
|
|
2838
|
+
* answer is never remembered.
|
|
2839
|
+
*/
|
|
2840
|
+
private async roomIsEncrypted(roomId: string): Promise<boolean> {
|
|
2841
|
+
if (this.encryptedRooms.has(roomId)) return true;
|
|
2842
|
+
const key = `${ROOM_ENCRYPTED_PREFIX}${roomId}`;
|
|
2843
|
+
if ((await this.ctx.storage.get<boolean>(key)) === true) {
|
|
2844
|
+
this.encryptedRooms.add(roomId);
|
|
2845
|
+
return true;
|
|
2846
|
+
}
|
|
2847
|
+
const encryption = await this.gateway.getRoomStateEvent(
|
|
2848
|
+
roomId,
|
|
2849
|
+
'm.room.encryption',
|
|
2850
|
+
);
|
|
2851
|
+
const parsed: unknown = encryption ? JSON.parse(encryption) : null;
|
|
2852
|
+
const encrypted =
|
|
2853
|
+
typeof parsed === 'object' &&
|
|
2854
|
+
parsed !== null &&
|
|
2855
|
+
'algorithm' in parsed &&
|
|
2856
|
+
parsed.algorithm === 'm.megolm.v1.aes-sha2';
|
|
2857
|
+
if (encrypted) {
|
|
2858
|
+
this.encryptedRooms.add(roomId);
|
|
2859
|
+
await this.ctx.storage.put(key, true);
|
|
2860
|
+
}
|
|
2861
|
+
return encrypted;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2748
2864
|
async deleteSession(
|
|
2749
2865
|
identity: TurnIdentity,
|
|
2750
2866
|
sessionId: string,
|
|
@@ -4646,16 +4762,11 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
|
|
|
4646
4762
|
record: RunRecord,
|
|
4647
4763
|
outcome: RunOutcome,
|
|
4648
4764
|
): Promise<void> {
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
stored.turn,
|
|
4655
|
-
record.runId,
|
|
4656
|
-
outcome.text,
|
|
4657
|
-
);
|
|
4658
|
-
}
|
|
4765
|
+
// A channel run's reply (or the end of a run without one) is mirrored
|
|
4766
|
+
// into the Companion room in the background: never awaited here, so
|
|
4767
|
+
// the session's next queued run starts at once. Without the handle (a
|
|
4768
|
+
// reset dropped it mid-run) the next poll or alarm mirrors it.
|
|
4769
|
+
if (record.client === 'channel') this.channelTurns?.deliverReply();
|
|
4659
4770
|
if (!record.taskRunId || !this.taskScheduler) return;
|
|
4660
4771
|
if (this.taskScheduler.isRunActive(record.taskRunId)) return;
|
|
4661
4772
|
if (outcome.status === 'finished')
|
package/src/shell/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
authenticateChannel,
|
|
3
|
-
|
|
3
|
+
assertActiveChannelBindingCached,
|
|
4
4
|
channelAuthConfig,
|
|
5
5
|
} from '../channels/auth';
|
|
6
6
|
import {
|
|
@@ -212,7 +212,7 @@ export function isDidShaped(value: string): boolean {
|
|
|
212
212
|
*/
|
|
213
213
|
export function rateLimitKey(
|
|
214
214
|
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
215
|
-
scope: 'user' | 'artifact' | 'socket' | 'status' | 'start',
|
|
215
|
+
scope: 'user' | 'channel' | 'artifact' | 'socket' | 'status' | 'start',
|
|
216
216
|
subject: string,
|
|
217
217
|
): string {
|
|
218
218
|
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
@@ -442,21 +442,28 @@ export function createShell(
|
|
|
442
442
|
);
|
|
443
443
|
// Limited as soon as the user is proven, before the Auth Hub round
|
|
444
444
|
// trip: a flood or a tight poll must not cost one call there each.
|
|
445
|
+
// Channel traffic has its own budget (`channel` scope), apart from
|
|
446
|
+
// the user's Portal requests: polls never push the Portal into 429,
|
|
447
|
+
// nor the other way round.
|
|
445
448
|
if (
|
|
446
449
|
c.env.RATE_LIMIT &&
|
|
447
450
|
!(
|
|
448
451
|
await c.env.RATE_LIMIT.limit({
|
|
449
|
-
key: rateLimitKey(c.env, '
|
|
452
|
+
key: rateLimitKey(c.env, 'channel', identity.userDid),
|
|
450
453
|
})
|
|
451
454
|
).success
|
|
452
455
|
)
|
|
453
456
|
throw new ChannelError(429, 'Too many requests', 'rate_limited');
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
);
|
|
457
|
+
const stub = userStub(c.env, identity.userDid);
|
|
458
|
+
const requestHash = await channelRequestHash(raw);
|
|
459
|
+
// A poll of a run that exists is answered by the (warm) object with no
|
|
460
|
+
// Auth Hub call; anything else is checked with the Auth Hub (an
|
|
461
|
+
// "active" verdict is cached per isolate for 60 s) and admitted.
|
|
462
|
+
let outcome = await stub.channelPoll(identity, parsed.data, requestHash);
|
|
463
|
+
if (!outcome) {
|
|
464
|
+
await assertActiveChannelBindingCached(identity, c.env);
|
|
465
|
+
outcome = await stub.channelTurn(identity, parsed.data, requestHash);
|
|
466
|
+
}
|
|
460
467
|
if (!outcome.ok)
|
|
461
468
|
return c.json(
|
|
462
469
|
{ code: outcome.code, message: outcome.message },
|