@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,599 @@
1
+ import { type ClientConfig } from '@langchain/mcp-adapters';
2
+ import { withCallTimeout } from '../mcp-call-timeout';
3
+ import { z } from 'zod';
4
+ import { OraclePlugin, type PluginEnv } from '../../plugin-api/oracle-plugin';
5
+ import type {
6
+ PluginManifest,
7
+ PluginTool,
8
+ RuntimeContext,
9
+ } from '../../plugin-api/types';
10
+ import { defaultSandboxMcpClientFactory } from './sandbox-bridge';
11
+ import {
12
+ createDefaultAuthBuilder,
13
+ parseOracleSecrets,
14
+ type SandboxAuthBuilder,
15
+ } from './sandbox-mcp';
16
+ import { createSandboxWriteBlobTool } from './sandbox-write-blob';
17
+
18
+ /**
19
+ * Env this plugin owns. Unlike the Node runtime — whose Tier-0 base schema
20
+ * declared `ORACLE_SECRETS` — the Workers base env schema deliberately omits
21
+ * it, and unknown keys are stripped from the validated config. Declaring it
22
+ * here (optional) makes the sandbox plugin its owner so the `x-os-*` headers
23
+ * keep working.
24
+ */
25
+ const configSchema = z.object({
26
+ SANDBOX_MCP_URL: z.string().url(),
27
+ /** Operator secrets injected as `x-os-*` headers: `KEY1=v1,KEY2=v2`. */
28
+ ORACLE_SECRETS: z.string().optional(),
29
+ });
30
+
31
+ /**
32
+ * Sibling env vars the plugin reads at boot but does not own:
33
+ * `SKILLS_CAPSULES_BASE_URL` is owned by the skills plugin. When set, the
34
+ * sandbox plugin mints a parallel `ixo:skills` invocation and forwards it
35
+ * as `X-Skills-Invocation` so the sandbox can call the skills service on
36
+ * the user's behalf. Optional — a missing value simply skips the header.
37
+ */
38
+ const siblingEnvSchema = z.object({
39
+ SKILLS_CAPSULES_BASE_URL: z.string().url().optional(),
40
+ });
41
+
42
+ const manifest: PluginManifest = {
43
+ title: 'Sandbox',
44
+ summary:
45
+ 'Per-user Linux box for code execution. `sandbox_run` runs shell/python (writes anywhere via shell — incl. `/tmp` for scratch). `sandbox_write_file` writes raw bytes BUT only under `/workspace/data/` — paths like `/tmp/...` or `/workspace/tmp/...` are rejected; use `sandbox_run` for those. STORAGE: the box is a scratch disk, not your file store. Files under `/workspace/data/` are pushed UP to durable storage after each call, but NOTHING comes back DOWN automatically and the box is wiped after a few idle minutes. Any file not created in the current call must be fetched with `load_artifact` before you can read it.',
46
+ whenToUse: [
47
+ "Execute a skill — call `sandbox_run` with `cid` so user + oracle secrets are injected; the skill folder mounts read-only at `/workspace/skills/<skill-name>/` (use the absolute paths from `load_skill`'s `skillFiles`).",
48
+ 'Read a skill file (SKILL.md, scripts, configs) — call `sandbox_run` with `cid` and shell: `cat /workspace/skills/<skill-name>/SKILL.md`, `ls /workspace/skills/<skill-name>/`, `grep -r "<pattern>" /workspace/skills/<skill-name>/`, or `sed -n "1,80p" <file>` for a line range. There is no dedicated `read_skill` tool.',
49
+ 'Hit a JSON/REST API — write curl or python in `sandbox_run`. Never use a web scraper for `/api/`, `/v1/`, `/v2/`, `/v3/` endpoints.',
50
+ 'Generate or transform a file the user (or a later turn) will re-read — write it to `/workspace/data/output/<name>` (alias `/workspace/output/`). It is pushed to durable storage after the call; a later turn must `load_artifact` it back before reading.',
51
+ 'Re-read ANY file you did not create in this same call — an attachment the user sent earlier, output from a previous turn, anything `artifact_list` shows — call `load_artifact` with its path FIRST, then read it with `sandbox_run`. Do not assume it is still on disk: the box is recycled after a few idle minutes and comes back empty.',
52
+ 'An `ls`/`find` under `/workspace/data/` came back empty — that means the container is FRESH, not that your files were lost. They are safe in durable storage. Call `artifact_list` to see them and `load_artifact` to pull one back. Never report a file as missing or re-generate it based on an empty directory listing alone.',
53
+ 'Save a large or escape-sensitive blob (multi-line markdown, structured data) byte-perfect to `/workspace/data/...` — use `sandbox_write_file` so quoting bugs do not corrupt it.',
54
+ "Write a scratch / throwaway file (build artefacts, temp scripts you will execute then delete) — use `sandbox_run` with a here-doc: `cat > /tmp/<name> <<'EOF'\\n<content>\\nEOF`. Never use `sandbox_write_file` for `/tmp` or anywhere outside `/workspace/data/` — it will be rejected.",
55
+ 'Always check the result envelope: `success === true` AND `exitCode === 0` before trusting `output`. On failure, READ the `error` text and change your approach — do not retry the same call with the same args.',
56
+ ],
57
+ whenNotToUse: [
58
+ 'The value is already inline in chat — just use it; opening the sandbox to echo it back wastes a turn.',
59
+ 'Fetching a URL the user just mentioned — prefer `process_file`, which archives it as an artifact (pull it into the box with `load_artifact` when you need to read it).',
60
+ 'A long human-readable page (blog, article, news) — use the Firecrawl agent.',
61
+ 'Installing native deps in cwd (`pip install -e .`, `bun install`) — `.venv`/`node_modules` get uploaded to R2 by the post-run sync, bloating storage and slowing every subsequent call (they are NOT restored for you, so it buys nothing). Install under `/tmp` (via `sandbox_run`) or inside the skill folder.',
62
+ '`sandbox_write_file` with a path outside `/workspace/data/` (e.g. `/tmp/foo`, `/workspace/tmp/foo`, `/workspace/output-only-if-data-prefix-missing`) — the validator hard-rejects this. For temp/scratch writes, switch to `sandbox_run`.',
63
+ ],
64
+ examples: [
65
+ {
66
+ user: "Read the pptx skill's instructions before generating slides.",
67
+ thought:
68
+ "After `load_skill`, the SKILL.md path is the absolute path the response returned (e.g. /workspace/skills/pptx/SKILL.md). Cat it directly through sandbox_run — there's no separate read tool. Pass `cid` so the same skill context is in place.",
69
+ tool: 'sandbox_run',
70
+ args: {
71
+ code: 'cat /workspace/skills/pptx/SKILL.md',
72
+ cid: 'cid returned by list_skills / search_skills',
73
+ },
74
+ },
75
+ {
76
+ user: 'Run the price-forecast skill on the Q3 sales data.',
77
+ thought:
78
+ 'The CSV came from an earlier turn, so it is in durable storage but almost certainly NOT on the box — pull it down first. Then run the skill with its CID so user + oracle secrets are injected, and write the forecast to /workspace/data/output/ so it is archived for later.',
79
+ tool: 'load_artifact',
80
+ args: {
81
+ path: '/output/q3-sales.csv',
82
+ },
83
+ },
84
+ {
85
+ user: '(same turn, after load_artifact returned the absolute path)',
86
+ thought:
87
+ 'Now the file really is on disk at /workspace/data/output/q3-sales.csv — safe to read it.',
88
+ tool: 'sandbox_run',
89
+ args: {
90
+ code: 'cd /workspace/skills/price-forecast && bash run.sh "/workspace/data/output/q3-sales.csv" > /workspace/data/output/forecast.json',
91
+ cid: 'cid from list/search skills',
92
+ },
93
+ },
94
+ {
95
+ user: 'Compute monthly totals from the CSV I attached.',
96
+ thought:
97
+ 'The attachment was archived as an artifact, not left on the box. load_artifact it first (artifact_list if I do not know the exact path), THEN compute. Use uv (preferred over pip in this sandbox) to materialize pandas. Write the result so the user can reuse it next turn.',
98
+ tool: 'sandbox_run',
99
+ args: {
100
+ code: "uv run --with pandas python -c \"import pandas as pd, json; df=pd.read_csv('/workspace/data/output/sales.csv'); out=df.groupby('month').sum().reset_index(); out.to_csv('/workspace/data/output/monthly_totals.csv', index=False); print(json.dumps({'rows': len(out)}))\"",
101
+ },
102
+ },
103
+ {
104
+ user: 'Where did the chart you made yesterday go? The folder looks empty.',
105
+ thought:
106
+ 'An empty /workspace/data/output means the container was recycled, NOT that the file is gone. Do not re-generate it and do not tell the user it was lost. List what is actually in durable storage, then pull the one I need back down.',
107
+ tool: 'artifact_list',
108
+ args: {},
109
+ },
110
+ {
111
+ user: 'Save this draft report so I can come back to it tomorrow.',
112
+ thought:
113
+ 'Multi-line markdown with quotes and code fences → write byte-perfect via sandbox_write_file (no shell-escaping). Writing under /workspace/data archives it durably — tomorrow it will need a load_artifact to come back onto the box.',
114
+ tool: 'sandbox_write_file',
115
+ args: {
116
+ path: '/workspace/data/output/draft-report.md',
117
+ content: '# Q3 Report\n\n## Findings\n\n...',
118
+ },
119
+ },
120
+ {
121
+ user: 'Build a one-off JS script to transform some data and run it.',
122
+ thought:
123
+ 'Scratch script — runs once, then discarded. /tmp is the right home, but sandbox_write_file refuses anything outside /workspace/data/. Use sandbox_run with a here-doc to write + execute in one shot.',
124
+ tool: 'sandbox_run',
125
+ args: {
126
+ code: "cat > /tmp/transform.js <<'EOF'\nconst fs = require('fs');\nconst rows = JSON.parse(fs.readFileSync('/workspace/data/output/data.json'));\nconsole.log(JSON.stringify(rows.map(r => ({...r, normalized: r.value / 100}))));\nEOF\nnode /tmp/transform.js > /workspace/data/output/transformed.json",
127
+ },
128
+ },
129
+ ],
130
+ visibility: 'always',
131
+ stability: 'stable',
132
+ category: 'core',
133
+ tags: ['sandbox', 'execution', 'workspace', 'artifacts'],
134
+ };
135
+
136
+ /** Minimal MCP-client surface — declared structurally so tests can stub it. */
137
+ export interface SandboxMcpClientLike {
138
+ getTools(): Promise<SandboxMcpTool[]>;
139
+ close(): Promise<void>;
140
+ }
141
+
142
+ /** Shape of one upstream MCP tool (schema normalised to Zod). */
143
+ export interface SandboxMcpTool {
144
+ name: string;
145
+ description: string;
146
+ schema: z.ZodType;
147
+ invoke(input: unknown): Promise<unknown>;
148
+ }
149
+
150
+ export type SandboxMcpClientFactory = (
151
+ config: ClientConfig,
152
+ ) => SandboxMcpClientLike;
153
+
154
+ /** Per-tool timeout for sandbox MCP calls (matches the Node runtime wiring). */
155
+ const SANDBOX_MCP_TIMEOUT_MS = 180_000;
156
+
157
+ /**
158
+ * How long a request's lazily-connected MCP client survives after its last
159
+ * invocation before being closed (a later call reconnects).
160
+ */
161
+ const SANDBOX_IDLE_CLIENT_CLOSE_MS = 5 * 60 * 1000;
162
+
163
+ export interface SandboxPluginOptions {
164
+ /**
165
+ * Override the auth header builder. Tests inject a stub here to skip the
166
+ * did:web resolution + UCAN mint; production code lets the default builder
167
+ * do the network work.
168
+ */
169
+ authBuilder?: SandboxAuthBuilder;
170
+ /**
171
+ * Override the MCP-client constructor. Tests inject a stub so they can
172
+ * intercept the headers the plugin sends and the tools it gets back without
173
+ * standing up a real `MultiServerMCPClient`.
174
+ */
175
+ mcpClientFactory?: SandboxMcpClientFactory;
176
+ /**
177
+ * Opt-in to the upstream `oracle_*` management tools (`oracle_list`,
178
+ * `oracle_get`, `oracle_health`, `oracle_stop`, `oracle_restart`,
179
+ * `oracle_get_logs`). Off by default — these are operator-grade controls
180
+ * that most user-facing oracles should not surface to the agent. Flip this
181
+ * on for admin / dev-tooling oracles.
182
+ */
183
+ includeOracleManagementTools?: boolean;
184
+ }
185
+
186
+ /** Prefix the upstream sandbox MCP uses for operator-grade oracle controls. */
187
+ const ORACLE_MANAGEMENT_TOOL_PREFIX = 'oracle_';
188
+
189
+ /**
190
+ * Upstream tool *definition* — the user-independent slice of a
191
+ * {@link SandboxMcpTool}. Auth (UCAN invocation + secret headers) is applied
192
+ * per request at invoke time, so definitions are safe to share.
193
+ */
194
+ type SandboxToolDef = Pick<SandboxMcpTool, 'name' | 'description' | 'schema'>;
195
+
196
+ interface CachedSandboxDefs {
197
+ defs: SandboxToolDef[];
198
+ expiresAt: number;
199
+ }
200
+
201
+ /**
202
+ * Definitions change only on upstream deploys; a short TTL keeps them fresh
203
+ * while removing the per-turn secrets fetch + MCP connect + tools/list chain
204
+ * from the chat hot path.
205
+ */
206
+ const SANDBOX_TOOL_DEFS_TTL_MS = 5 * 60 * 1000;
207
+
208
+ /**
209
+ * Sandbox plugin.
210
+ *
211
+ * Surfaces every upstream sandbox MCP tool — `sandbox_run`, `sandbox_write_file`,
212
+ * the `artifact_*` family, `load_skill`, and the `oracle_*`
213
+ * management tools — to the main agent. Each tool flows through verbatim
214
+ * (name, description, schema all from upstream); the plugin's only job is to
215
+ * authenticate the MCP connection and forward operator + per-user secrets in
216
+ * the request headers.
217
+ *
218
+ * Headers are minted ONCE per request and include:
219
+ * - `Authorization: Bearer <ixo:sandbox UCAN invocation>` + `X-Auth-Type: ucan`
220
+ * - `X-Skills-Invocation` (when `SKILLS_CAPSULES_BASE_URL` is configured)
221
+ * - `x-os-<name>` for each entry in `ORACLE_SECRETS`
222
+ * - `x-us-<name>` for each per-room secret loaded from `ctx.secrets`
223
+ *
224
+ * Tools are discovered per request via {@link getRequestTools} because the
225
+ * MCP client has to be authenticated as the in-flight user.
226
+ */
227
+ export class SandboxPlugin extends OraclePlugin {
228
+ /**
229
+ * Static handle other plugins use to test `availablePlugins.has(...)`
230
+ * without hardcoding the string. Mirrors the precedent set by `MemoryPlugin`.
231
+ */
232
+ static readonly NAME = 'sandbox';
233
+
234
+ readonly name = SandboxPlugin.NAME;
235
+ readonly version = '1.0.0';
236
+ readonly manifest = manifest;
237
+ override readonly configSchema = configSchema;
238
+ override readonly autoDetectHint = 'SANDBOX_MCP_URL';
239
+
240
+ private readonly authBuilder: SandboxAuthBuilder;
241
+ private readonly mcpClientFactory: SandboxMcpClientFactory;
242
+ private readonly includeOracleManagementTools: boolean;
243
+
244
+ constructor(opts: SandboxPluginOptions = {}) {
245
+ super();
246
+ this.authBuilder = opts.authBuilder ?? createDefaultAuthBuilder();
247
+ // Shared with the bridge: a cast-free wrapper that keeps `close()`
248
+ // reachable — the raw `MultiServerMCPClient` cast this used to be made
249
+ // every connect unclosable.
250
+ this.mcpClientFactory =
251
+ opts.mcpClientFactory ?? defaultSandboxMcpClientFactory;
252
+ this.includeOracleManagementTools =
253
+ opts.includeOracleManagementTools ?? false;
254
+ }
255
+
256
+ override autoDetect(env: PluginEnv): boolean {
257
+ return (
258
+ typeof env.SANDBOX_MCP_URL === 'string' && env.SANDBOX_MCP_URL.length > 0
259
+ );
260
+ }
261
+
262
+ /** Cached upstream tool definitions, keyed by sandbox MCP URL. */
263
+ private readonly toolDefsCache = new Map<string, CachedSandboxDefs>();
264
+
265
+ /**
266
+ * URLs with a background definition refresh in flight — guards a burst of
267
+ * turns from each spawning its own connect when an entry expires.
268
+ */
269
+ private readonly defsRefreshInFlight = new Set<string>();
270
+
271
+ override async getRequestTools(rtCtx: RuntimeContext): Promise<PluginTool[]> {
272
+ const parsed = configSchema.safeParse(rtCtx.config);
273
+ if (!parsed.success) {
274
+ throw new Error(
275
+ `sandbox: invalid configuration: ${parsed.error.issues
276
+ .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
277
+ .join('; ')}`,
278
+ );
279
+ }
280
+
281
+ const siblings = siblingEnvSchema.safeParse(rtCtx.config);
282
+ const skillsServiceUrl = siblings.success
283
+ ? siblings.data.SKILLS_CAPSULES_BASE_URL
284
+ : undefined;
285
+
286
+ const oracleSecrets = parseOracleSecrets(parsed.data.ORACLE_SECRETS ?? '');
287
+ const sandboxMcpUrl = parsed.data.SANDBOX_MCP_URL;
288
+
289
+ const cached = this.toolDefsCache.get(sandboxMcpUrl);
290
+ if (cached) {
291
+ // Warm path — definitions are known, so skip the per-turn secrets
292
+ // fetch and MCP connect. The auth *gate* still runs every request:
293
+ // minting is local (service-DID resolution is cached upstream), so an
294
+ // unauthorized user sees no sandbox tools, exactly as before. An
295
+ // expired entry is served as-is (definitions only change on upstream
296
+ // deploys) while a background refresh re-snapshots it, so TTL expiry
297
+ // never puts the secrets fetch + connect chain back on a chat turn.
298
+ const gateHeaders = await this.authBuilder(
299
+ { sandboxMcpUrl, skillsServiceUrl, oracleSecrets: {}, userSecrets: {} },
300
+ rtCtx,
301
+ );
302
+ if (!gateHeaders.Authorization) {
303
+ rtCtx.logger.log(
304
+ '[sandbox] skipping — no UCAN invocation (user not authorized); not connecting to the sandbox MCP server.',
305
+ );
306
+ return [];
307
+ }
308
+ if (
309
+ cached.expiresAt <= Date.now() &&
310
+ !this.defsRefreshInFlight.has(sandboxMcpUrl)
311
+ ) {
312
+ this.defsRefreshInFlight.add(sandboxMcpUrl);
313
+ void this.connectWithFullHeaders({
314
+ sandboxMcpUrl,
315
+ skillsServiceUrl,
316
+ oracleSecrets,
317
+ rtCtx,
318
+ })
319
+ .then(async ({ client, tools }) => {
320
+ // Defs are plain data — the refresh connection has nothing left
321
+ // to serve once they're snapshotted.
322
+ await client.close().catch(() => undefined);
323
+ this.toolDefsCache.set(sandboxMcpUrl, {
324
+ defs: tools.map(({ name, description, schema }) => ({
325
+ name,
326
+ description,
327
+ schema,
328
+ })),
329
+ expiresAt: Date.now() + SANDBOX_TOOL_DEFS_TTL_MS,
330
+ });
331
+ })
332
+ .catch((err: unknown) => {
333
+ // Keep serving the stale defs; the next expired-cache turn
334
+ // retries the refresh.
335
+ rtCtx.logger.warn(
336
+ `[sandbox] background tool-defs refresh failed: ${err instanceof Error ? err.message : String(err)}`,
337
+ );
338
+ })
339
+ .finally(() => {
340
+ this.defsRefreshInFlight.delete(sandboxMcpUrl);
341
+ });
342
+ }
343
+ const lazyUpstream = this.buildLazyUpstreamTools(cached.defs, {
344
+ sandboxMcpUrl,
345
+ skillsServiceUrl,
346
+ oracleSecrets,
347
+ rtCtx,
348
+ });
349
+ return this.toPluginTools(lazyUpstream, rtCtx);
350
+ }
351
+
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
+ : {};
358
+
359
+ const headers = await this.authBuilder(
360
+ {
361
+ sandboxMcpUrl,
362
+ skillsServiceUrl,
363
+ oracleSecrets,
364
+ userSecrets,
365
+ },
366
+ rtCtx,
367
+ );
368
+
369
+ // No UCAN invocation could be minted (the user hasn't authorized) → the
370
+ // sandbox MCP server would reject us with 401. Skip connecting entirely
371
+ // and contribute no tools, rather than letting the auth error crash the
372
+ // turn. Mirrors composio/memory's graceful degradation.
373
+ if (!headers.Authorization) {
374
+ rtCtx.logger.log(
375
+ '[sandbox] skipping — no UCAN invocation (user not authorized); not connecting to the sandbox MCP server.',
376
+ );
377
+ return [];
378
+ }
379
+
380
+ const client = this.mcpClientFactory({
381
+ mcpServers: {
382
+ sandbox: {
383
+ type: 'http',
384
+ url: sandboxMcpUrl,
385
+ transport: 'http',
386
+ headers,
387
+ },
388
+ },
389
+ useStandardContentBlocks: true,
390
+ });
391
+
392
+ // The cold path only needs the definitions: close the listing client and
393
+ // bind lazy tools, so the first actual invocation reconnects exactly like
394
+ // the warm path — no connection outlives the listing.
395
+ let upstream: SandboxMcpTool[];
396
+ try {
397
+ upstream = await client.getTools();
398
+ } finally {
399
+ await client.close().catch(() => undefined);
400
+ }
401
+ const defs = upstream.map(({ name, description, schema }) => ({
402
+ name,
403
+ description,
404
+ schema,
405
+ }));
406
+ this.toolDefsCache.set(sandboxMcpUrl, {
407
+ defs,
408
+ expiresAt: Date.now() + SANDBOX_TOOL_DEFS_TTL_MS,
409
+ });
410
+
411
+ return this.toPluginTools(
412
+ this.buildLazyUpstreamTools(defs, {
413
+ sandboxMcpUrl,
414
+ skillsServiceUrl,
415
+ oracleSecrets,
416
+ rtCtx,
417
+ }),
418
+ rtCtx,
419
+ );
420
+ }
421
+
422
+ /**
423
+ * Bind cached definitions to lazily-connected upstream tools. The full
424
+ * connection chain (secrets fetch → header mint → MCP connect →
425
+ * tools/list) runs once, on the first actual invocation, and is shared by
426
+ * every sandbox tool in the request — turns that never call the sandbox
427
+ * pay zero sandbox round-trips.
428
+ */
429
+ private buildLazyUpstreamTools(
430
+ defs: SandboxToolDef[],
431
+ args: {
432
+ sandboxMcpUrl: string;
433
+ skillsServiceUrl: string | undefined;
434
+ oracleSecrets: Record<string, string>;
435
+ rtCtx: RuntimeContext;
436
+ },
437
+ ): SandboxMcpTool[] {
438
+ let connection: Promise<{
439
+ client: SandboxMcpClientLike;
440
+ byName: Map<string, SandboxMcpTool>;
441
+ }> | null = null;
442
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
443
+
444
+ const closeConnection = (): void => {
445
+ const current = connection;
446
+ connection = null;
447
+ if (idleTimer !== null) {
448
+ clearTimeout(idleTimer);
449
+ idleTimer = null;
450
+ }
451
+ if (!current) return;
452
+ void current.then(({ client }) => client.close()).catch(() => undefined);
453
+ };
454
+
455
+ // There is no request-end hook on the tool path, so idle-close is what
456
+ // bounds the client's lifetime — without it every turn that touched the
457
+ // sandbox leaked a connected client for the life of the isolate. A later
458
+ // invocation simply reconnects.
459
+ // Close when the turn ends; the idle timer is only the fallback for
460
+ // hosts without a turn-end hook (a pending timer keeps a Durable Object
461
+ // resident and blocks hibernation).
462
+ const onTurnEnd = args.rtCtx.onTurnEnd;
463
+ const scheduleIdleClose = (): void => {
464
+ if (onTurnEnd) return;
465
+ if (idleTimer !== null) clearTimeout(idleTimer);
466
+ idleTimer = setTimeout(closeConnection, SANDBOX_IDLE_CLIENT_CLOSE_MS);
467
+ };
468
+ let closeRegistered = false;
469
+ const registerTurnEndClose = (): void => {
470
+ if (!onTurnEnd || closeRegistered) return;
471
+ closeRegistered = true;
472
+ onTurnEnd(() => closeConnection());
473
+ };
474
+
475
+ const connect = (): NonNullable<typeof connection> => {
476
+ if (!connection) {
477
+ registerTurnEndClose();
478
+ connection = this.connectWithFullHeaders(args).then(
479
+ ({ client, tools }) => ({
480
+ client,
481
+ byName: new Map(tools.map((t) => [t.name, t])),
482
+ }),
483
+ );
484
+ // A failed connect must not poison the rest of the run — clear the
485
+ // memo so a later invocation retries with a fresh client.
486
+ connection.catch(() => {
487
+ connection = null;
488
+ });
489
+ }
490
+ return connection;
491
+ };
492
+
493
+ return defs.map((def) => ({
494
+ name: def.name,
495
+ description: def.description,
496
+ schema: def.schema,
497
+ invoke: async (input: unknown) => {
498
+ const { byName } = await connect();
499
+ const upstream = byName.get(def.name);
500
+ if (!upstream) {
501
+ scheduleIdleClose();
502
+ throw new Error(
503
+ `sandbox MCP no longer exposes "${def.name}" — cached definition is stale, retry shortly.`,
504
+ );
505
+ }
506
+ try {
507
+ return await withCallTimeout(
508
+ () => upstream.invoke(input),
509
+ SANDBOX_MCP_TIMEOUT_MS,
510
+ `sandbox tool ${def.name}`,
511
+ closeConnection,
512
+ );
513
+ } finally {
514
+ scheduleIdleClose();
515
+ }
516
+ },
517
+ }));
518
+ }
519
+
520
+ private async connectWithFullHeaders(args: {
521
+ sandboxMcpUrl: string;
522
+ skillsServiceUrl: string | undefined;
523
+ oracleSecrets: Record<string, string>;
524
+ rtCtx: RuntimeContext;
525
+ }): Promise<{ client: SandboxMcpClientLike; tools: SandboxMcpTool[] }> {
526
+ 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
+ : {};
533
+
534
+ const headers = await this.authBuilder(
535
+ { sandboxMcpUrl, skillsServiceUrl, oracleSecrets, userSecrets },
536
+ rtCtx,
537
+ );
538
+ if (!headers.Authorization) {
539
+ throw new Error(
540
+ 'sandbox: no UCAN invocation could be minted for this call — the user is not authorized.',
541
+ );
542
+ }
543
+
544
+ const client = this.mcpClientFactory({
545
+ mcpServers: {
546
+ sandbox: {
547
+ type: 'http',
548
+ url: sandboxMcpUrl,
549
+ transport: 'http',
550
+ headers,
551
+ },
552
+ },
553
+ useStandardContentBlocks: true,
554
+ });
555
+ try {
556
+ const tools = await client.getTools();
557
+ return { client, tools };
558
+ } catch (error) {
559
+ await client.close().catch(() => undefined);
560
+ throw error;
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Shared tail of both paths: visibility filter, PluginTool mapping, and
566
+ * the synthetic `sandbox_write_blob` companion.
567
+ */
568
+ private toPluginTools(
569
+ upstream: SandboxMcpTool[],
570
+ rtCtx: RuntimeContext,
571
+ ): PluginTool[] {
572
+ const filtered = this.includeOracleManagementTools
573
+ ? upstream
574
+ : upstream.filter(
575
+ (t) => !t.name.startsWith(ORACLE_MANAGEMENT_TOOL_PREFIX),
576
+ );
577
+
578
+ const upstreamTools: PluginTool[] = filtered.map((tool) => ({
579
+ name: tool.name,
580
+ description: tool.description,
581
+ schema: tool.schema,
582
+ handler: async (args) => tool.invoke(args),
583
+ }));
584
+
585
+ // sandbox_write_blob — companion to mint_invocation. Synthetic wrapper
586
+ // that takes a server-stored blobId + a sandbox path, looks the value up
587
+ // server-side, and forwards it to sandbox_write_file. Only registered
588
+ // when sandbox_write_file is present in the upstream toolset AND the
589
+ // request has a known user DID (the blob store is user-DID-namespaced).
590
+ const sandboxWriteFileTool = filtered.find(
591
+ (t) => t.name === 'sandbox_write_file',
592
+ );
593
+ if (sandboxWriteFileTool && rtCtx.user.did) {
594
+ upstreamTools.push(createSandboxWriteBlobTool({ sandboxWriteFileTool }));
595
+ }
596
+
597
+ return upstreamTools;
598
+ }
599
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ createTasksPlugin,
3
+ TasksPlugin,
4
+ TASKS_PLUGIN_NAME,
5
+ tasksConfigSchema,
6
+ } from './tasks.plugin';
7
+ export { tasksManifest } from './manifest';
8
+ export { createTaskTools } from './tasks-tools';
9
+ export {
10
+ classifyReplyFast,
11
+ computeApprovalHint,
12
+ createTaskApprovalGateMiddleware,
13
+ lastHumanText,
14
+ userDidFromContext,
15
+ type FastReply,
16
+ type TaskApprovalGateOptions,
17
+ } from './middleware';