@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
@@ -0,0 +1,189 @@
1
+ import { z } from 'zod';
2
+ import { tool } from '../../plugin-api/tool-helper';
3
+ import type {
4
+ PluginSubAgent,
5
+ PluginTool,
6
+ RuntimeContext,
7
+ } from '../../plugin-api/types';
8
+ import type { BlueprintSection } from './blueprint-types';
9
+ import type { BlueprintStore, BlueprintStoreFor } from './blueprint-store';
10
+ import type { CapsuleContentClient } from './capsule-content-client';
11
+ import {
12
+ DESIGN_POD_ROLES,
13
+ type DesignPodRole,
14
+ type DesignPodStage,
15
+ } from './design-pod-roles';
16
+ import { deriveStage, sectionsView } from './stage';
17
+
18
+ const isGateRole = (role: DesignPodRole): boolean =>
19
+ role.stage === 'evaluate' || role.stage === 'gate';
20
+
21
+ /** What each lifecycle stage is accountable for, for the fallback prompts. */
22
+ const STAGE_DUTIES: Record<DesignPodStage, string> = {
23
+ qualify:
24
+ 'Score the incoming service intent for viability and fit, and give a clear go / no-go rationale the later stages can rely on.',
25
+ architect:
26
+ 'Design the POD structure for your discipline — service roles, workspaces and services, claim schemas and the UDID model, or the UCAN rights model — precisely enough for the build stage to implement without guessing.',
27
+ build:
28
+ 'Produce the executable artifacts for your discipline — Flow pages for the workflow and UX, or the operating playbooks and rule cards — grounded in the architect sections.',
29
+ evaluate:
30
+ 'Judge the recorded design from your lens (automation feasibility, governance and risk, or the outcome contract). Be adversarial: name what breaks, and fail the gate when the blueprint does not hold up.',
31
+ package:
32
+ 'Package the POD for the market — the commercial offer and marketplace listing draft, or a runnable demo — from the completed design sections.',
33
+ gate: 'Run final QA across every prior section and issue the launch-readiness verdict. Pass only a blueprint you would stand behind going on-chain.',
34
+ };
35
+
36
+ function sectionInstruction(role: DesignPodRole): string {
37
+ const verdict = isGateRole(role)
38
+ ? ' Include a pass|fail verdict (and blockers when failing).'
39
+ : '';
40
+ return `Use read_blueprint for the sections recorded so far (pass roles=[...] when you need a section's full content), then call submit_section exactly once with your section content.${verdict}`;
41
+ }
42
+
43
+ /** The role's registry instructions, framed for the pipeline. */
44
+ function composePrompt(role: DesignPodRole, skillMarkdown: string): string {
45
+ return `${skillMarkdown}\n\n---\nYou are the \`${role.id}\` specialist in the POD design pipeline (stage: ${role.stage}). ${sectionInstruction(role)}`;
46
+ }
47
+
48
+ /** Built-in prompt used when no registry instructions are loaded for the role. */
49
+ function fallbackPrompt(role: DesignPodRole): string {
50
+ return [
51
+ `You are the \`${role.id}\` specialist in the POD design pipeline (stage: ${role.stage}).`,
52
+ role.description,
53
+ '',
54
+ STAGE_DUTIES[role.stage],
55
+ 'Ground everything in the POD brief and the sections recorded before yours; produce concrete, structured content — not commentary.',
56
+ '',
57
+ sectionInstruction(role),
58
+ '',
59
+ '(Operating from the built-in summary of this role; its registry-published instructions are not loaded.)',
60
+ ].join('\n');
61
+ }
62
+
63
+ const submitSchema = z.object({
64
+ content: z
65
+ .unknown()
66
+ .describe('Your section content to record into the POD blueprint.'),
67
+ verdict: z
68
+ .enum(['pass', 'fail'])
69
+ .optional()
70
+ .describe('For gate roles, your gate verdict.'),
71
+ blockers: z
72
+ .array(z.string())
73
+ .optional()
74
+ .describe('Blocking issues to surface when your verdict is fail.'),
75
+ });
76
+
77
+ const readSchema = z.object({
78
+ roles: z
79
+ .array(z.string())
80
+ .optional()
81
+ .describe(
82
+ 'Role ids whose full section content to include. Omit for the compact per-section summary.',
83
+ ),
84
+ });
85
+
86
+ /** The narrow tool set each specialist gets: read prior context, submit once. */
87
+ function roleTools(
88
+ role: DesignPodRole,
89
+ storeFor: BlueprintStoreFor,
90
+ ): PluginTool[] {
91
+ const readBlueprint = tool(
92
+ async (args, ctx) => {
93
+ const { roles } = readSchema.parse(args);
94
+ const bp = await storeFor(ctx).get(ctx.session.id);
95
+ if (!bp) {
96
+ return { brief: undefined, sections: [] };
97
+ }
98
+ return { brief: bp.brief, ...sectionsView(bp, roles) };
99
+ },
100
+ {
101
+ name: 'read_blueprint',
102
+ description:
103
+ 'Read the POD brief and a compact summary of the sections recorded so far. Pass roles=[...] to include the full content of specific sections.',
104
+ schema: readSchema,
105
+ effect: 'read',
106
+ },
107
+ );
108
+
109
+ const submitSection = tool(
110
+ async (args, ctx) => {
111
+ const input = submitSchema.parse(args);
112
+ const section: BlueprintSection = {
113
+ role: role.id,
114
+ stage: role.stage,
115
+ content: input.content,
116
+ recordedAt: new Date().toISOString(),
117
+ ...(input.verdict !== undefined ? { verdict: input.verdict } : {}),
118
+ ...(input.blockers !== undefined ? { blockers: input.blockers } : {}),
119
+ };
120
+ await storeFor(ctx).putSection(ctx.session.id, section);
121
+ return { submitted: role.id, stage: role.stage };
122
+ },
123
+ {
124
+ name: 'submit_section',
125
+ description: `Record your (${role.id}) section into the POD blueprint. Call exactly once.`,
126
+ schema: submitSchema,
127
+ },
128
+ );
129
+
130
+ return [readBlueprint, submitSection];
131
+ }
132
+
133
+ async function resolvePrompt(
134
+ role: DesignPodRole,
135
+ rt: RuntimeContext,
136
+ capsules: CapsuleContentClient,
137
+ ): Promise<string> {
138
+ const markdown = await capsules.getSkillMarkdown(role.capsule, rt);
139
+ return markdown === undefined
140
+ ? fallbackPrompt(role)
141
+ : composePrompt(role, markdown);
142
+ }
143
+
144
+ async function buildSubAgent(
145
+ role: DesignPodRole,
146
+ rt: RuntimeContext,
147
+ storeFor: BlueprintStoreFor,
148
+ capsules: CapsuleContentClient,
149
+ ): Promise<PluginSubAgent> {
150
+ return {
151
+ name: role.id,
152
+ description: role.description,
153
+ systemPrompt: await resolvePrompt(role, rt, capsules),
154
+ tools: roleTools(role, storeFor),
155
+ model: 'subagent',
156
+ forwardTools: true,
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Build the specialist sub-agents for the current request, gated to the
162
+ * blueprint's current stage. Returns only that stage's specialist(s) — never
163
+ * all twelve — so the conductor's tool surface stays small and the pipeline
164
+ * order is enforced, and none at all before a blueprint exists. Each
165
+ * sub-agent's system prompt is the role's registry `SKILL.md`, fetched via the
166
+ * capsule client, with a built-in fallback when there is no fetcher or the
167
+ * registry is unavailable.
168
+ *
169
+ * The specialists' tools resolve the store from THEIR call's context (the
170
+ * sub-agent runs later in the turn), never from the `rt` that built them.
171
+ */
172
+ export async function buildStageSubAgents(
173
+ rt: RuntimeContext,
174
+ storeFor: BlueprintStoreFor,
175
+ capsules: CapsuleContentClient,
176
+ ): Promise<PluginSubAgent[]> {
177
+ const store: BlueprintStore = storeFor(rt);
178
+ const bp = await store.get(rt.session.id);
179
+ // No design in this thread: nothing to offer. This hook runs on every turn
180
+ // of every user whether or not the capability is loaded, so it must stay a
181
+ // single local read until `start_pod_design` has opened a blueprint (the
182
+ // qualify specialist appears on the following turn).
183
+ if (!bp) return [];
184
+ const stage = deriveStage(bp);
185
+ const roles = DESIGN_POD_ROLES.filter((role) => role.stage === stage);
186
+ return Promise.all(
187
+ roles.map((role) => buildSubAgent(role, rt, storeFor, capsules)),
188
+ );
189
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The Node runtime's `logActionToMatrix`: a best-effort `ixo.action.log`
3
3
  * room event recording a frontend action (browser tool / AG-UI action) and
4
- * its outcome, posted only when the turn has a room. Never throws.
4
+ * its outcome, posted only when the turn has a room. Never throws. Frontend
5
+ * calls log through `logFrontendAction`, which keeps their content out.
5
6
  *
6
7
  * Retried across a gateway restart (the gateway object is replaced on every
7
8
  * deploy and can be drained mid-turn) and handed to the host to keep alive
@@ -10,6 +11,7 @@
10
11
  * response lost between the homeserver's ack and the reply to the gateway
11
12
  * is deduplicated instead of logged twice.
12
13
  */
14
+ import { summarizeFrontendResult } from '@ixo/common/ai/frontend-bridge';
13
15
  import { retryGateway, type RetryGatewayOptions } from '../../do/gateway-retry';
14
16
  import { retryTxnId } from '../../matrix/txn-id';
15
17
  import type { RuntimeContext } from '../../plugin-api/types';
@@ -59,3 +61,21 @@ export function logActionToMatrix(
59
61
  );
60
62
  ctx.background?.(work);
61
63
  }
64
+
65
+ /**
66
+ * Log a finished frontend call (browser tool / AG-UI action). The room event
67
+ * is a diagnostic trail, readable by everyone in the room: it records the
68
+ * invocation id, a Portal command id when the result carries one, and
69
+ * whether the outcome is unknown — never the arguments or the result body,
70
+ * which carry the user's content.
71
+ */
72
+ export function logFrontendAction(
73
+ ctx: Pick<RuntimeContext, 'matrix' | 'session' | 'logger' | 'background'>,
74
+ call: { name: string; invocationId: string | undefined; result: unknown },
75
+ ): void {
76
+ logActionToMatrix(ctx, {
77
+ name: call.name,
78
+ args: {},
79
+ ...summarizeFrontendResult(call.result, call.invocationId),
80
+ });
81
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Validation for the tools a browser declares in a turn body (the Portal's
3
+ * `tools[]` and AG-UI's `agActions[]`). The declarations come from the
4
+ * client on every request, so each request is checked on its own: the boot
5
+ * time collision check never sees them.
6
+ *
7
+ * An entry is dropped (never repaired) when its name is not a valid tool
8
+ * name, repeats an earlier entry's name, or is reserved (`reservedNames`,
9
+ * e.g. the server's own tool names), or when its description or schema is
10
+ * over the size cap. At most {@link MAX_DECLARED_TOOLS} entries are kept.
11
+ */
12
+
13
+ /** Tool names the model providers accept, and nothing else. */
14
+ export const DECLARED_TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
15
+ /** Declared tools kept per request; further entries are dropped. */
16
+ export const MAX_DECLARED_TOOLS = 64;
17
+ /** Characters of one declared tool's description. */
18
+ export const MAX_DECLARED_DESCRIPTION_CHARS = 2_048;
19
+ /** Characters of one declared tool's JSON schema, serialised. */
20
+ export const MAX_DECLARED_SCHEMA_CHARS = 16_384;
21
+
22
+ export interface DeclaredTool {
23
+ name: string;
24
+ description: string;
25
+ schema: Record<string, unknown>;
26
+ }
27
+
28
+ export interface DroppedDeclaration {
29
+ name: string;
30
+ reason: string;
31
+ }
32
+
33
+ function schemaLength(schema: Record<string, unknown>): number {
34
+ try {
35
+ return JSON.stringify(schema).length;
36
+ } catch {
37
+ return Number.POSITIVE_INFINITY;
38
+ }
39
+ }
40
+
41
+ /** The entries that may become tools this turn, plus why the others did not. */
42
+ export function sanitizeDeclaredTools<T extends DeclaredTool>(
43
+ entries: readonly T[],
44
+ reservedNames: ReadonlySet<string> = new Set(),
45
+ ): { kept: T[]; dropped: DroppedDeclaration[] } {
46
+ const kept: T[] = [];
47
+ const dropped: DroppedDeclaration[] = [];
48
+ const seen = new Set<string>();
49
+ for (const entry of entries) {
50
+ const { name } = entry;
51
+ let reason: string | undefined;
52
+ if (!DECLARED_TOOL_NAME_PATTERN.test(name)) reason = 'invalid name';
53
+ else if (seen.has(name)) reason = 'duplicate name';
54
+ else if (reservedNames.has(name)) reason = 'name taken by a server tool';
55
+ else if (entry.description.length > MAX_DECLARED_DESCRIPTION_CHARS)
56
+ reason = `description over ${MAX_DECLARED_DESCRIPTION_CHARS} characters`;
57
+ else if (schemaLength(entry.schema) > MAX_DECLARED_SCHEMA_CHARS)
58
+ reason = `schema over ${MAX_DECLARED_SCHEMA_CHARS} characters`;
59
+ else if (kept.length >= MAX_DECLARED_TOOLS)
60
+ reason = `more than ${MAX_DECLARED_TOOLS} tools declared`;
61
+ if (DECLARED_TOOL_NAME_PATTERN.test(name)) seen.add(name);
62
+ if (reason) dropped.push({ name: name.slice(0, 64), reason });
63
+ else kept.push(entry);
64
+ }
65
+ return { kept, dropped };
66
+ }
67
+
68
+ /** Dropped entries named in {@link describeDropped}; the rest are counted. */
69
+ const MAX_DROPPED_NAMED = 5;
70
+
71
+ /**
72
+ * One log line for a request's dropped declarations (`undefined` when none
73
+ * were dropped): the count plus the first few names and reasons, so a client
74
+ * declaring thousands of bad entries still costs one bounded line.
75
+ */
76
+ export function describeDropped(
77
+ kind: string,
78
+ dropped: readonly DroppedDeclaration[],
79
+ ): string | undefined {
80
+ if (dropped.length === 0) return undefined;
81
+ const named = dropped
82
+ .slice(0, MAX_DROPPED_NAMED)
83
+ .map(({ name, reason }) => `"${name}" (${reason})`)
84
+ .join(', ');
85
+ const more =
86
+ dropped.length > MAX_DROPPED_NAMED
87
+ ? ` and ${dropped.length - MAX_DROPPED_NAMED} more`
88
+ : '';
89
+ return `Ignoring ${dropped.length} declared ${kind}: ${named}${more}`;
90
+ }
@@ -23,7 +23,8 @@ import type {
23
23
  PluginTool,
24
24
  RuntimeContext,
25
25
  } from '../../plugin-api/types';
26
- import { logActionToMatrix } from './action-log';
26
+ import { logFrontendAction } from './action-log';
27
+ import { describeDropped, sanitizeDeclaredTools } from './declared-tools';
27
28
 
28
29
  const manifest: PluginManifest = {
29
30
  title: 'Portal',
@@ -53,6 +54,20 @@ const manifest: PluginManifest = {
53
54
 
54
55
  export const BROWSER_TOOL_TIMEOUT_MS = 15_000;
55
56
 
57
+ /**
58
+ * The Portal's Topic write tool. Its commands can take a while to settle in
59
+ * the browser (several Matrix writes), so it gets a longer window before the
60
+ * call is reported as an unknown outcome.
61
+ */
62
+ export const TOPIC_MUTATION_TOOL = 'mutate_topic';
63
+ export const TOPIC_MUTATION_TIMEOUT_MS = 120_000;
64
+
65
+ export function browserToolTimeoutMs(toolName: string): number {
66
+ return toolName === TOPIC_MUTATION_TOOL
67
+ ? TOPIC_MUTATION_TIMEOUT_MS
68
+ : BROWSER_TOOL_TIMEOUT_MS;
69
+ }
70
+
56
71
  const BROWSER_TOOL_SHAPE = z.object({
57
72
  name: z.string(),
58
73
  description: z.string(),
@@ -61,12 +76,16 @@ const BROWSER_TOOL_SHAPE = z.object({
61
76
 
62
77
  const ARGS_RECORD_SHAPE = z.record(z.string(), z.unknown());
63
78
 
79
+ /** The client's browser tools; a name declared twice keeps its first descriptor. */
64
80
  export function parseBrowserTools(value: unknown): BrowserToolCall[] {
65
81
  if (!Array.isArray(value)) return [];
66
82
  const out: BrowserToolCall[] = [];
83
+ const names = new Set<string>();
67
84
  for (const entry of value) {
68
85
  const parsed = BROWSER_TOOL_SHAPE.safeParse(entry);
69
- if (parsed.success) out.push(parsed.data);
86
+ if (!parsed.success || names.has(parsed.data.name)) continue;
87
+ names.add(parsed.data.name);
88
+ out.push(parsed.data);
70
89
  }
71
90
  return out;
72
91
  }
@@ -154,36 +173,43 @@ export function buildBrowserTool(
154
173
  descriptor,
155
174
  ctx.config,
156
175
  );
157
- // Node uses `tc-<requestId>`; the suffix keeps two browser tool calls in
158
- // one turn from sharing an id (the client echoes it back verbatim).
159
- const toolCallId = `tc-${ctx.session.requestId || 'noreq'}-${crypto
160
- .randomUUID()
161
- .slice(0, 8)}`;
176
+ // The bridge makes every invocation unique (`tc-<requestId>:<uuid>`).
177
+ let invocationId: string | undefined;
162
178
  const result = await frontend.callBrowserTool({
163
179
  sessionId,
164
- toolCallId,
180
+ toolCallId: `tc-${ctx.session.requestId || 'noreq'}`,
165
181
  toolName: descriptor.name,
166
182
  args,
167
- timeoutMs: BROWSER_TOOL_TIMEOUT_MS,
168
- });
169
- logActionToMatrix(ctx, {
170
- name: descriptor.name,
171
- args,
172
- result,
173
- success: true,
183
+ timeoutMs: browserToolTimeoutMs(descriptor.name),
184
+ signal: ctx.abortSignal,
185
+ onInvocation: (id) => {
186
+ invocationId = id;
187
+ },
174
188
  });
189
+ logFrontendAction(ctx, { name: descriptor.name, invocationId, result });
175
190
  return result;
176
191
  },
177
192
  {
178
193
  name: descriptor.name,
179
194
  description: descriptor.description,
180
195
  schema,
196
+ // A browser step (scroll, page, click) repeated is a new step.
197
+ repeatable: true,
181
198
  },
182
199
  );
183
200
  }
184
201
 
202
+ /**
203
+ * The browser tools declared on this request that may become tools: invalid,
204
+ * duplicate and oversized declarations are dropped (`declared-tools.ts`).
205
+ */
185
206
  function readBrowserTools(rtCtx: RuntimeContext): BrowserToolCall[] {
186
- return parseBrowserTools(rtCtx.history.state.browserTools);
207
+ const { kept, dropped } = sanitizeDeclaredTools(
208
+ parseBrowserTools(rtCtx.history.state.browserTools),
209
+ );
210
+ const summary = describeDropped('browser tools', dropped);
211
+ if (summary) rtCtx.logger.warn(summary);
212
+ return kept;
187
213
  }
188
214
 
189
215
  export class PortalPlugin extends OraclePlugin {
@@ -1,6 +1,7 @@
1
1
  import { MultiServerMCPClient } from '@langchain/mcp-adapters';
2
2
  import type { RuntimeContext } from '../../plugin-api/types';
3
3
  import { base64ToBytes } from '../base64';
4
+ import { withCallTimeout } from '../mcp-call-timeout';
4
5
  import { adaptMcpClientTools } from '../mcp-tool-adapter';
5
6
  import { createDefaultAuthBuilder, SANDBOX_RUN_TOOL_NAME } from './sandbox-mcp';
6
7
  import type {
@@ -12,8 +13,21 @@ import type {
12
13
  /** Upstream tool name the sandbox MCP surfaces for byte-perfect file writes. */
13
14
  export const SANDBOX_WRITE_FILE_TOOL_NAME = 'sandbox_write_file';
14
15
 
15
- /** Per-call timeout for the sandbox MCP client (matches sandbox.plugin.ts). */
16
- const SANDBOX_MCP_TIMEOUT_MS = 180_000;
16
+ /**
17
+ * Per-call timeout for every sandbox MCP tool call. It is the MCP request's
18
+ * own timeout (as `metadata.timeoutMs`, see `mcp-tool-adapter.ts`) and is
19
+ * also enforced with {@link withCallTimeout}, which closes the client on
20
+ * expiry; never as the client's `defaultToolTimeout`.
21
+ */
22
+ export const SANDBOX_MCP_TIMEOUT_MS = 180_000;
23
+
24
+ /**
25
+ * Largest file the bridge moves in either direction. A transfer is buffered
26
+ * whole in the isolate several times over (base64 text in the MCP result,
27
+ * its decoded bytes, the upload copy), so the cap keeps one transfer far
28
+ * below the 128 MB isolate limit. Equal to the VFS vision-read cap.
29
+ */
30
+ export const MAX_SANDBOX_TRANSFER_BYTES = 10 * 1024 * 1024;
17
31
 
18
32
  /** Only destination the sandbox accepts for `sandbox_write_file` writes. */
19
33
  export const WORKSPACE_DATA_PREFIX = '/workspace/data/';
@@ -21,6 +35,12 @@ export const WORKSPACE_DATA_PREFIX = '/workspace/data/';
21
35
  /** Sentinel echoed by the read command when the source file is absent. */
22
36
  export const SANDBOX_NO_FILE_SENTINEL = '__SANDBOX_NOFILE__';
23
37
 
38
+ /**
39
+ * Sentinel echoed by the read command, followed by the size in bytes, when
40
+ * the source file exceeds {@link MAX_SANDBOX_TRANSFER_BYTES}.
41
+ */
42
+ export const SANDBOX_TOO_LARGE_SENTINEL = '__SANDBOX_TOOLARGE__';
43
+
24
44
  /**
25
45
  * Shown when the user hasn't authorized the oracle to use the sandbox — the
26
46
  * default auth builder can't mint an `ixo:sandbox` invocation, so there's no
@@ -41,7 +61,10 @@ export const defaultSandboxMcpClientFactory: SandboxMcpClientFactory = (
41
61
  ) => {
42
62
  const client = new MultiServerMCPClient(config);
43
63
  const wrapper: SandboxMcpClientLike = {
44
- getTools: async () => adaptMcpClientTools(await client.getTools(), console),
64
+ getTools: async () =>
65
+ adaptMcpClientTools(await client.getTools(), console, {
66
+ requestTimeoutMs: SANDBOX_MCP_TIMEOUT_MS,
67
+ }),
45
68
  close: () => client.close(),
46
69
  };
47
70
  return wrapper;
@@ -201,7 +224,6 @@ export async function getSandboxBridge(
201
224
  headers,
202
225
  },
203
226
  },
204
- defaultToolTimeout: SANDBOX_MCP_TIMEOUT_MS,
205
227
  useStandardContentBlocks: true,
206
228
  });
207
229
 
@@ -236,23 +258,57 @@ export async function getSandboxBridge(
236
258
  };
237
259
  }
238
260
 
239
- return { run, writeFile, close };
261
+ // Each invocation is bounded by a timer that is always cleared; on timeout
262
+ // the client is closed so the abandoned request cannot keep the object
263
+ // busy.
264
+ const bounded = (t: SandboxMcpTool): SandboxMcpTool => ({
265
+ ...t,
266
+ invoke: (input: unknown) =>
267
+ withCallTimeout(
268
+ () => t.invoke(input),
269
+ SANDBOX_MCP_TIMEOUT_MS,
270
+ `sandbox tool ${t.name}`,
271
+ close,
272
+ ),
273
+ });
274
+
275
+ return { run: bounded(run), writeFile: bounded(writeFile), close };
276
+ }
277
+
278
+ /**
279
+ * Shell command that prints the file as one base64 line, or a sentinel when
280
+ * it is missing or larger than {@link MAX_SANDBOX_TRANSFER_BYTES}. The size
281
+ * is checked inside the sandbox so an oversized file is never encoded or
282
+ * sent. `sandboxPath` must already have passed {@link hasShellUnsafeChars}.
283
+ */
284
+ export function sandboxReadCommand(sandboxPath: string): string {
285
+ const p = `'${sandboxPath}'`;
286
+ return [
287
+ `if [ ! -f ${p} ]; then echo ${SANDBOX_NO_FILE_SENTINEL};`,
288
+ `else s=$(stat -c%s ${p});`,
289
+ `if [ "$s" -gt ${MAX_SANDBOX_TRANSFER_BYTES} ]; then echo ${SANDBOX_TOO_LARGE_SENTINEL}:$s;`,
290
+ `else base64 -w0 ${p}; fi; fi`,
291
+ ].join(' ');
240
292
  }
241
293
 
242
294
  /**
243
295
  * Read a file out of the sandbox as bytes, base64-hopping over `sandbox_run`
244
296
  * so binary content survives the transport. Non-throwing — a missing file, a
245
- * failed command, and undecodable output all come back as `{ error }` with an
246
- * agent-facing message.
297
+ * file over {@link MAX_SANDBOX_TRANSFER_BYTES}, a failed command, and
298
+ * undecodable output all come back as `{ error }` with an agent-facing
299
+ * message.
247
300
  */
248
301
  export async function readSandboxFile(
249
302
  bridge: Pick<SandboxBridge, 'run'>,
250
303
  sandboxPath: string,
251
304
  ): Promise<{ bytes: Uint8Array } | { error: string }> {
305
+ if (hasShellUnsafeChars(sandboxPath)) {
306
+ return {
307
+ error: `The sandbox path contains characters I can't safely handle (quotes or newlines).`,
308
+ };
309
+ }
252
310
  const read = readSandboxResult(
253
- await bridge.run.invoke({
254
- code: `test -f '${sandboxPath}' && base64 -w0 '${sandboxPath}' || echo ${SANDBOX_NO_FILE_SENTINEL}`,
255
- }),
311
+ await bridge.run.invoke({ code: sandboxReadCommand(sandboxPath) }),
256
312
  );
257
313
  if (!read.ok) {
258
314
  return {
@@ -263,6 +319,19 @@ export async function readSandboxFile(
263
319
  if (stdout === SANDBOX_NO_FILE_SENTINEL) {
264
320
  return { error: `No file at \`${sandboxPath}\` in the sandbox.` };
265
321
  }
322
+ if (stdout.startsWith(`${SANDBOX_TOO_LARGE_SENTINEL}:`)) {
323
+ const size = stdout.slice(SANDBOX_TOO_LARGE_SENTINEL.length + 1);
324
+ return {
325
+ error: `\`${sandboxPath}\` is ${size} bytes; files moved out of the sandbox are limited to ${MAX_SANDBOX_TRANSFER_BYTES} bytes.`,
326
+ };
327
+ }
328
+ // Defence in depth for a sandbox that ignored the size check: base64
329
+ // inflates by 4/3, so anything longer decodes past the cap.
330
+ if (stdout.length > Math.ceil(MAX_SANDBOX_TRANSFER_BYTES / 3) * 4) {
331
+ return {
332
+ error: `\`${sandboxPath}\` is larger than the ${MAX_SANDBOX_TRANSFER_BYTES}-byte limit for files moved out of the sandbox.`,
333
+ };
334
+ }
266
335
  try {
267
336
  return { bytes: base64ToBytes(stdout) };
268
337
  } catch {
@@ -1,4 +1,5 @@
1
1
  import type { RuntimeContext } from '../../plugin-api/types';
2
+ import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
2
3
 
3
4
  /** Tool name surfaced by the upstream sandbox MCP. */
4
5
  export const SANDBOX_RUN_TOOL_NAME = 'sandbox_run';
@@ -70,10 +71,10 @@ export function createDefaultAuthBuilder(): SandboxAuthBuilder {
70
71
  if (sandboxDid) {
71
72
  try {
72
73
  const invocation = await runCtx.ucan.mintInvocation(
73
- { did: sandboxDid, capability: 'ixo:sandbox' },
74
+ { did: sandboxDid, capability: SANDBOX_CAPABILITY.resource },
74
75
  // Claim the ability the user's delegation actually grants. A `'*'`
75
76
  // claim is satisfiable only by a `'*'` grant.
76
- { can: 'sandbox/*' },
77
+ { can: SANDBOX_CAPABILITY.action },
77
78
  );
78
79
  if (invocation) {
79
80
  headers.Authorization = `Bearer ${invocation}`;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { tool as pluginTool } from '../../plugin-api/tool-helper';
3
3
  import type { PluginTool, RuntimeContext } from '../../plugin-api/types';
4
+ import { readSandboxResult } from './sandbox-bridge';
4
5
  import type { SandboxMcpTool } from './sandbox.plugin';
5
6
 
6
7
  const sandboxWriteBlobSchema = z.object({
@@ -80,10 +81,19 @@ export function createSandboxWriteBlobTool(
80
81
  content: blob.value,
81
82
  encoding: 'utf8',
82
83
  });
84
+ // The sandbox reports a rejected write (bad path, quota) as a
85
+ // returned `success:false` envelope, not a throw.
86
+ const outcome = readSandboxResult(result);
87
+ if (!outcome.ok) {
88
+ return JSON.stringify({
89
+ success: false,
90
+ error: `sandbox_write_file failed: ${outcome.text}`,
91
+ });
92
+ }
83
93
  return JSON.stringify({
84
94
  success: true,
85
95
  path,
86
- bytesWritten: blob.value.length,
96
+ bytesWritten: new TextEncoder().encode(blob.value).byteLength,
87
97
  blobName: blob.name,
88
98
  note: typeof result === 'string' ? result : undefined,
89
99
  });
@@ -7,13 +7,17 @@ import type {
7
7
  PluginTool,
8
8
  RuntimeContext,
9
9
  } from '../../plugin-api/types';
10
- import { defaultSandboxMcpClientFactory } from './sandbox-bridge';
10
+ import {
11
+ defaultSandboxMcpClientFactory,
12
+ SANDBOX_MCP_TIMEOUT_MS,
13
+ } from './sandbox-bridge';
11
14
  import {
12
15
  createDefaultAuthBuilder,
13
16
  parseOracleSecrets,
14
17
  type SandboxAuthBuilder,
15
18
  } from './sandbox-mcp';
16
19
  import { createSandboxWriteBlobTool } from './sandbox-write-blob';
20
+ import { SANDBOX_CAPABILITY } from '../delegated-capabilities';
17
21
 
18
22
  /**
19
23
  * Env this plugin owns. Unlike the Node runtime — whose Tier-0 base schema
@@ -131,6 +135,7 @@ const manifest: PluginManifest = {
131
135
  stability: 'stable',
132
136
  category: 'core',
133
137
  tags: ['sandbox', 'execution', 'workspace', 'artifacts'],
138
+ requires: [SANDBOX_CAPABILITY],
134
139
  };
135
140
 
136
141
  /** Minimal MCP-client surface — declared structurally so tests can stub it. */
@@ -151,9 +156,6 @@ export type SandboxMcpClientFactory = (
151
156
  config: ClientConfig,
152
157
  ) => SandboxMcpClientLike;
153
158
 
154
- /** Per-tool timeout for sandbox MCP calls (matches the Node runtime wiring). */
155
- const SANDBOX_MCP_TIMEOUT_MS = 180_000;
156
-
157
159
  /**
158
160
  * How long a request's lazily-connected MCP client survives after its last
159
161
  * invocation before being closed (a later call reconnects).
@@ -349,12 +351,7 @@ export class SandboxPlugin extends OraclePlugin {
349
351
  return this.toPluginTools(lazyUpstream, rtCtx);
350
352
  }
351
353
 
352
- const userSecretIndex = await rtCtx.secrets.getIndex();
353
- const userSecretKeys = Object.keys(userSecretIndex);
354
- const userSecrets: Record<string, string> =
355
- userSecretKeys.length > 0
356
- ? await rtCtx.secrets.getValues(userSecretKeys)
357
- : {};
354
+ const userSecrets = await rtCtx.secrets.getAll();
358
355
 
359
356
  const headers = await this.authBuilder(
360
357
  {
@@ -524,12 +521,7 @@ export class SandboxPlugin extends OraclePlugin {
524
521
  rtCtx: RuntimeContext;
525
522
  }): Promise<{ client: SandboxMcpClientLike; tools: SandboxMcpTool[] }> {
526
523
  const { sandboxMcpUrl, skillsServiceUrl, oracleSecrets, rtCtx } = args;
527
- const userSecretIndex = await rtCtx.secrets.getIndex();
528
- const userSecretKeys = Object.keys(userSecretIndex);
529
- const userSecrets: Record<string, string> =
530
- userSecretKeys.length > 0
531
- ? await rtCtx.secrets.getValues(userSecretKeys)
532
- : {};
524
+ const userSecrets = await rtCtx.secrets.getAll();
533
525
 
534
526
  const headers = await this.authBuilder(
535
527
  { sandboxMcpUrl, skillsServiceUrl, oracleSecrets, userSecrets },