@nifrajs/coding-agent 3.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 (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -0,0 +1,177 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { publicErrorDetails } from "./errors.ts"
3
+ import type { ExtensionContext, ExtensionTool } from "./extensions.ts"
4
+ import { readBoundedText } from "./process.ts"
5
+
6
+ const modulePath = process.argv[2]
7
+ const cwd = process.argv[3]
8
+ if (modulePath === undefined || cwd === undefined)
9
+ throw new Error("isolated extension worker: modulePath and cwd are required")
10
+
11
+ const token = process.env.NIFRA_EXTENSION_TOKEN
12
+ const maxMessageBytes = Number(process.env.NIFRA_EXTENSION_MAX_MESSAGE_BYTES)
13
+ const exposeErrorStacks = process.env.NIFRA_EXTENSION_EXPOSE_ERROR_STACKS === "1"
14
+ if (
15
+ token === undefined ||
16
+ token.length < 32 ||
17
+ !Number.isSafeInteger(maxMessageBytes) ||
18
+ maxMessageBytes < 1024
19
+ ) {
20
+ post({ type: "fatal", error: "isolated extension worker: invalid control configuration" })
21
+ process.exit(1)
22
+ }
23
+
24
+ const tools = new Map<string, ExtensionTool>()
25
+ let ready = false
26
+ interface WorkerRequest {
27
+ readonly type?: unknown
28
+ readonly id?: unknown
29
+ readonly name?: unknown
30
+ readonly input?: unknown
31
+ }
32
+
33
+ interface WorkerResult {
34
+ readonly type: "result"
35
+ readonly id: string
36
+ readonly error?: string
37
+ readonly stack?: string
38
+ readonly output?: unknown
39
+ }
40
+
41
+ const context: ExtensionContext = {
42
+ cwd,
43
+ registerCommand(name) {
44
+ post({ type: "command", name })
45
+ },
46
+ registerTool(tool) {
47
+ tools.set(tool.name, tool)
48
+ post({
49
+ type: "registration",
50
+ name: tool.name,
51
+ description: tool.description,
52
+ capabilities: tool.capabilities ?? [],
53
+ })
54
+ },
55
+ registerWorkflow(name) {
56
+ post({ type: "workflow", name })
57
+ },
58
+ registerSubagent(role) {
59
+ post({ type: "subagent", name: role.name })
60
+ },
61
+ registerProvider(provider) {
62
+ post({ type: "provider", name: provider.name })
63
+ },
64
+ on(event) {
65
+ post({ type: "event", name: event })
66
+ },
67
+ }
68
+
69
+ let server: ReturnType<typeof Bun.serve> | undefined
70
+
71
+ try {
72
+ // Bind only to loopback and require the per-worker token. The endpoint is a transport seam, not
73
+ // a security boundary: the extension already executes inside this process by design.
74
+ server = Bun.serve({
75
+ hostname: "127.0.0.1",
76
+ port: 0,
77
+ async fetch(request) {
78
+ if (request.method !== "POST" || new URL(request.url).pathname !== "/invoke")
79
+ return new Response("", { status: 404 })
80
+ if (request.headers.get("authorization") !== `Bearer ${token}`)
81
+ return new Response("", { status: 401 })
82
+ const body = await readBoundedText(request.body, maxMessageBytes)
83
+ if (body.truncated) return new Response("", { status: 413 })
84
+ let message: unknown
85
+ try {
86
+ message = JSON.parse(body.text)
87
+ } catch {
88
+ return new Response("", { status: 400 })
89
+ }
90
+ if (!isWorkerRequest(message) || message.type !== "invoke")
91
+ return new Response("", { status: 400 })
92
+ if (!ready) return new Response("", { status: 503 })
93
+ const result = await handle(message)
94
+ return result === undefined ? new Response("", { status: 400 }) : jsonResponse(result)
95
+ },
96
+ })
97
+ const module = (await import(`${realpathSync(modulePath)}?isolated=${Date.now()}`)) as {
98
+ default?: (context: ExtensionContext) => unknown | PromiseLike<unknown>
99
+ }
100
+ if (typeof module.default !== "function") throw new Error("extension has no default factory")
101
+ await module.default(context)
102
+ post({ type: "ready", port: server.port })
103
+ ready = true
104
+ } catch (error) {
105
+ server?.stop()
106
+ const details = publicErrorDetails(
107
+ error,
108
+ "isolated extension worker failed to start",
109
+ exposeErrorStacks,
110
+ )
111
+ post({
112
+ type: "fatal",
113
+ error: details.message,
114
+ ...(details.stack === undefined ? {} : { stack: details.stack }),
115
+ })
116
+ process.exit(1)
117
+ }
118
+
119
+ async function handle(request: WorkerRequest): Promise<WorkerResult | undefined> {
120
+ if (request.type === "shutdown") {
121
+ server?.stop()
122
+ process.exit(0)
123
+ return undefined
124
+ }
125
+ if (
126
+ request.type !== "invoke" ||
127
+ typeof request.id !== "string" ||
128
+ typeof request.name !== "string"
129
+ )
130
+ return undefined
131
+ const tool = tools.get(request.name)
132
+ if (tool === undefined) {
133
+ return { type: "result", id: request.id, error: `unknown tool: ${request.name}` }
134
+ }
135
+ try {
136
+ const output = await tool.execute(request.input, context)
137
+ return { type: "result", id: request.id, output }
138
+ } catch (error) {
139
+ const details = publicErrorDetails(error, "tool execution failed", exposeErrorStacks)
140
+ return {
141
+ type: "result",
142
+ id: request.id,
143
+ error: details.message,
144
+ ...(details.stack === undefined ? {} : { stack: details.stack }),
145
+ }
146
+ }
147
+ }
148
+
149
+ function isWorkerRequest(message: unknown): message is WorkerRequest {
150
+ return message !== null && typeof message === "object" && !Array.isArray(message)
151
+ }
152
+
153
+ function jsonResponse(message: WorkerResult): Response {
154
+ let text: string
155
+ try {
156
+ text = JSON.stringify(
157
+ message,
158
+ exposeErrorStacks
159
+ ? undefined
160
+ : (key: string, nested: unknown) => (key === "stack" ? undefined : nested),
161
+ )
162
+ } catch {
163
+ text = JSON.stringify({ type: "result", id: message.id, error: "tool output is not JSON-safe" })
164
+ }
165
+ if (Buffer.byteLength(text, "utf8") > maxMessageBytes)
166
+ text = JSON.stringify({ type: "result", id: message.id, error: "tool output is too large" })
167
+ // lgtm [js/stack-trace-exposure] stacks are emitted only when the parent explicitly enables local
168
+ // diagnostics; the default protocol response contains only the bounded public message.
169
+ return new Response(text, {
170
+ status: 200,
171
+ headers: { "content-type": "application/json" },
172
+ })
173
+ }
174
+
175
+ function post(message: Record<string, unknown>): void {
176
+ process.stdout.write(`${JSON.stringify(message)}\n`)
177
+ }
@@ -0,0 +1,344 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { isAbsolute, relative, resolve } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { readBoundedText } from "./process.ts"
5
+
6
+ export interface IsolatedExtensionWorkerOptions {
7
+ readonly modulePath: string
8
+ readonly cwd: string
9
+ readonly timeoutMs?: number
10
+ readonly maxMessageBytes?: number
11
+ /** Include bounded worker exception stacks for trusted local debugging. */
12
+ readonly exposeErrorStacks?: boolean
13
+ readonly trustedCapabilities?: readonly string[]
14
+ }
15
+
16
+ export interface IsolatedExtensionTool {
17
+ readonly name: string
18
+ readonly description: string
19
+ readonly capabilities: readonly string[]
20
+ }
21
+
22
+ export interface IsolatedExtensionSnapshot {
23
+ readonly tools: readonly IsolatedExtensionTool[]
24
+ readonly commands: readonly string[]
25
+ readonly events: readonly string[]
26
+ }
27
+
28
+ interface WorkerMessage {
29
+ readonly type?: string
30
+ readonly id?: string
31
+ readonly port?: number
32
+ readonly name?: string
33
+ readonly description?: string
34
+ readonly capabilities?: readonly string[]
35
+ readonly error?: string
36
+ readonly stack?: string
37
+ readonly output?: unknown
38
+ readonly commands?: readonly string[]
39
+ readonly tools?: readonly IsolatedExtensionTool[]
40
+ readonly events?: readonly string[]
41
+ }
42
+
43
+ interface PendingCall {
44
+ readonly resolve: (value: unknown) => void
45
+ readonly reject: (reason?: unknown) => void
46
+ readonly timer: ReturnType<typeof setTimeout>
47
+ readonly abort: AbortController
48
+ }
49
+
50
+ /**
51
+ * Optional process-backed extension runner. A worker crash rejects callers instead of taking down
52
+ * the host. It is intentionally not advertised as a hostile-code sandbox: use an OS sandbox and a
53
+ * capability decision before loading code you do not trust.
54
+ */
55
+ export class IsolatedExtensionWorker {
56
+ private readonly options: Required<
57
+ Pick<IsolatedExtensionWorkerOptions, "timeoutMs" | "maxMessageBytes">
58
+ > &
59
+ IsolatedExtensionWorkerOptions
60
+ private process: Bun.Subprocess | undefined
61
+ private readonly pending = new Map<string, PendingCall>()
62
+ private readonly tools = new Map<string, IsolatedExtensionTool>()
63
+ private readonly commands = new Set<string>()
64
+ private readonly events = new Set<string>()
65
+ private buffer = ""
66
+ private readonly token: string
67
+ private port: number | undefined
68
+ private started = false
69
+ private closed = false
70
+ private readyResolve: ((snapshot: IsolatedExtensionSnapshot) => void) | undefined
71
+ private readyReject: ((reason?: unknown) => void) | undefined
72
+ private readonly ready = new Promise<IsolatedExtensionSnapshot>((resolveReady, rejectReady) => {
73
+ this.readyResolve = resolveReady
74
+ this.readyReject = rejectReady
75
+ })
76
+
77
+ constructor(options: IsolatedExtensionWorkerOptions) {
78
+ this.options = {
79
+ ...options,
80
+ timeoutMs: options.timeoutMs ?? 30_000,
81
+ maxMessageBytes: options.maxMessageBytes ?? 256 * 1024,
82
+ }
83
+ this.token = crypto.randomUUID().replaceAll("-", "")
84
+ if (!isAbsolute(options.modulePath))
85
+ throw new TypeError("isolated extension: modulePath must be absolute")
86
+ if (!isAbsolute(options.cwd)) throw new TypeError("isolated extension: cwd must be absolute")
87
+ if (
88
+ !Number.isSafeInteger(this.options.timeoutMs) ||
89
+ this.options.timeoutMs < 1 ||
90
+ this.options.timeoutMs > 24 * 60 * 60_000
91
+ )
92
+ throw new RangeError("isolated extension: timeoutMs is invalid")
93
+ if (!Number.isSafeInteger(this.options.maxMessageBytes) || this.options.maxMessageBytes < 1024)
94
+ throw new RangeError("isolated extension: maxMessageBytes must be at least 1024")
95
+ const modulePath = realpathSync(options.modulePath)
96
+ if (!isWithin(options.cwd, modulePath))
97
+ throw new Error("isolated extension: module escapes cwd")
98
+ }
99
+
100
+ async start(): Promise<IsolatedExtensionSnapshot> {
101
+ if (this.started) return this.ready
102
+ this.started = true
103
+ const workerPath = fileURLToPath(new URL("./isolated-worker.ts", import.meta.url))
104
+ // Bun 1.4's Windows child IPC can start the worker but drop parent→child messages. The
105
+ // loopback endpoint keeps the command channel cross-platform without exposing it remotely.
106
+ this.process = Bun.spawn(
107
+ [process.execPath, workerPath, realpathSync(this.options.modulePath), this.options.cwd],
108
+ {
109
+ cwd: this.options.cwd,
110
+ env: {
111
+ ...filteredEnv(),
112
+ NIFRA_EXTENSION_TOKEN: this.token,
113
+ NIFRA_EXTENSION_MAX_MESSAGE_BYTES: String(this.options.maxMessageBytes),
114
+ NIFRA_EXTENSION_EXPOSE_ERROR_STACKS: this.options.exposeErrorStacks === true ? "1" : "0",
115
+ },
116
+ stdin: "ignore",
117
+ stdout: "pipe",
118
+ stderr: "pipe",
119
+ },
120
+ )
121
+ void this.readOutput()
122
+ void this.readErrors()
123
+ void this.process.exited.then((status) => {
124
+ if (!this.closed && status !== 0)
125
+ this.fail(new Error(`isolated extension worker exited with code ${status}`))
126
+ })
127
+ return this.ready
128
+ }
129
+
130
+ get snapshot(): IsolatedExtensionSnapshot {
131
+ return Object.freeze({
132
+ tools: Object.freeze([...this.tools.values()]),
133
+ commands: Object.freeze([...this.commands].sort()),
134
+ events: Object.freeze([...this.events].sort()),
135
+ })
136
+ }
137
+
138
+ async invokeTool(name: string, input: unknown): Promise<unknown> {
139
+ await this.start()
140
+ if (!this.tools.has(name)) throw new Error(`isolated extension: unknown tool ${name}`)
141
+ const id = crypto.randomUUID().replaceAll("-", "")
142
+ const abort = new AbortController()
143
+ const result = new Promise<unknown>((resolveResult, rejectResult) => {
144
+ const timer = setTimeout(() => {
145
+ this.pending.delete(id)
146
+ abort.abort()
147
+ rejectResult(new Error(`isolated extension: tool ${name} timed out`))
148
+ }, this.options.timeoutMs)
149
+ ;(timer as unknown as { unref?: () => void }).unref?.()
150
+ this.pending.set(id, { resolve: resolveResult, reject: rejectResult, timer, abort })
151
+ })
152
+ void this.sendInvoke(id, name, input, abort.signal).catch((error: unknown) => {
153
+ const pending = this.pending.get(id)
154
+ if (pending === undefined) return
155
+ clearTimeout(pending.timer)
156
+ this.pending.delete(id)
157
+ pending.reject(error)
158
+ })
159
+ return result
160
+ }
161
+
162
+ async close(): Promise<void> {
163
+ if (this.closed) return
164
+ this.closed = true
165
+ this.fail(new Error("isolated extension worker closed"))
166
+ this.process?.kill()
167
+ await this.process?.exited
168
+ }
169
+
170
+ private async sendInvoke(
171
+ id: string,
172
+ name: string,
173
+ input: unknown,
174
+ signal: AbortSignal,
175
+ ): Promise<void> {
176
+ const port = this.port
177
+ if (!isValidPort(port))
178
+ throw new Error("isolated extension: worker control endpoint unavailable")
179
+ const text = JSON.stringify({ type: "invoke", id, name, input })
180
+ if (Buffer.byteLength(text, "utf8") > this.options.maxMessageBytes)
181
+ throw new RangeError("isolated extension: message is too large")
182
+ const response = await fetch(`http://127.0.0.1:${port}/invoke`, {
183
+ method: "POST",
184
+ headers: {
185
+ authorization: `Bearer ${this.token}`,
186
+ "content-type": "application/json",
187
+ },
188
+ body: text,
189
+ signal,
190
+ })
191
+ const body = await readBoundedText(response.body, this.options.maxMessageBytes)
192
+ if (body.truncated) throw new Error("isolated extension: worker response exceeded limit")
193
+ if (!response.ok)
194
+ throw new Error(`isolated extension: worker request failed (${response.status})`)
195
+ let message: WorkerMessage
196
+ try {
197
+ message = JSON.parse(body.text) as WorkerMessage
198
+ } catch {
199
+ throw new Error("isolated extension: worker returned invalid JSON")
200
+ }
201
+ if (message.type !== "result" || message.id !== id)
202
+ throw new Error("isolated extension: worker returned an invalid result")
203
+ const pending = this.pending.get(id)
204
+ if (pending === undefined) return
205
+ clearTimeout(pending.timer)
206
+ this.pending.delete(id)
207
+ if (message.error !== undefined) pending.reject(workerError(message))
208
+ else pending.resolve(message.output)
209
+ }
210
+
211
+ private async readOutput(): Promise<void> {
212
+ const stdout = this.process?.stdout
213
+ if (stdout === undefined || stdout === null || typeof stdout === "number") return
214
+ const reader = stdout.getReader()
215
+ const decoder = new TextDecoder()
216
+ try {
217
+ for (;;) {
218
+ const { done, value } = await reader.read()
219
+ if (done) break
220
+ if (value !== undefined) this.consume(decoder.decode(value, { stream: true }))
221
+ }
222
+ this.consume(decoder.decode())
223
+ } catch (error) {
224
+ this.fail(error)
225
+ } finally {
226
+ reader.releaseLock()
227
+ }
228
+ }
229
+
230
+ private async readErrors(): Promise<void> {
231
+ const stderr = this.process?.stderr
232
+ if (stderr === undefined || stderr === null || typeof stderr === "number") return
233
+ await readBoundedText(stderr, this.options.maxMessageBytes).catch(() => {})
234
+ }
235
+
236
+ private consume(text: string): void {
237
+ this.buffer += text
238
+ if (Buffer.byteLength(this.buffer, "utf8") > this.options.maxMessageBytes * 2) {
239
+ this.fail(new Error("isolated extension: worker output exceeded limit"))
240
+ return
241
+ }
242
+ for (;;) {
243
+ const newline = this.buffer.indexOf("\n")
244
+ if (newline < 0) return
245
+ const line = this.buffer.slice(0, newline)
246
+ this.buffer = this.buffer.slice(newline + 1)
247
+ if (line.length === 0) continue
248
+ let message: WorkerMessage
249
+ try {
250
+ message = JSON.parse(line) as WorkerMessage
251
+ } catch {
252
+ this.fail(new Error("isolated extension: worker emitted invalid JSON"))
253
+ return
254
+ }
255
+ this.handle(message)
256
+ }
257
+ }
258
+
259
+ private handle(message: WorkerMessage): void {
260
+ if (message.type === "ready") {
261
+ const port = message.port
262
+ if (!isValidPort(port)) {
263
+ this.fail(new Error("isolated extension: worker control endpoint is invalid"))
264
+ this.process?.kill()
265
+ return
266
+ }
267
+ this.port = port
268
+ this.readyResolve?.(this.snapshot)
269
+ this.readyResolve = undefined
270
+ this.readyReject = undefined
271
+ return
272
+ }
273
+ if (message.type === "registration") {
274
+ const denied = (message.capabilities ?? []).find(
275
+ (capability) => !(this.options.trustedCapabilities ?? []).includes(capability),
276
+ )
277
+ if (denied !== undefined) {
278
+ this.fail(new Error(`isolated extension: untrusted capability ${denied}`))
279
+ this.process?.kill()
280
+ return
281
+ }
282
+ if (typeof message.name === "string")
283
+ this.tools.set(
284
+ message.name,
285
+ Object.freeze({
286
+ name: message.name,
287
+ description: message.description ?? "",
288
+ capabilities: Object.freeze([...(message.capabilities ?? [])]),
289
+ }),
290
+ )
291
+ return
292
+ }
293
+ if (message.type === "fatal") {
294
+ this.fail(workerError(message))
295
+ return
296
+ }
297
+ if (message.type === "command" && typeof message.name === "string")
298
+ this.commands.add(message.name)
299
+ if (message.type === "event" && typeof message.name === "string") this.events.add(message.name)
300
+ if (message.type !== "result" || typeof message.id !== "string") return
301
+ const pending = this.pending.get(message.id)
302
+ if (pending === undefined) return
303
+ clearTimeout(pending.timer)
304
+ this.pending.delete(message.id)
305
+ if (message.error !== undefined) pending.reject(workerError(message))
306
+ else pending.resolve(message.output)
307
+ }
308
+
309
+ private fail(error: unknown): void {
310
+ this.readyReject?.(error)
311
+ this.readyReject = undefined
312
+ this.readyResolve = undefined
313
+ for (const pending of this.pending.values()) {
314
+ clearTimeout(pending.timer)
315
+ pending.abort.abort()
316
+ pending.reject(error)
317
+ }
318
+ this.pending.clear()
319
+ }
320
+ }
321
+
322
+ function isWithin(root: string, candidate: string): boolean {
323
+ const path = relative(resolve(root), resolve(candidate))
324
+ return path === "" || (!path.startsWith("..") && !path.includes("/.."))
325
+ }
326
+
327
+ function isValidPort(value: unknown): value is number {
328
+ return Number.isSafeInteger(value) && (value as number) >= 1 && (value as number) <= 65_535
329
+ }
330
+
331
+ function workerError(message: WorkerMessage): Error {
332
+ const error = new Error(message.error ?? "isolated extension worker failed")
333
+ if (message.stack !== undefined) error.stack = message.stack
334
+ return error
335
+ }
336
+
337
+ function filteredEnv(): Record<string, string> {
338
+ const result: Record<string, string> = {}
339
+ for (const name of ["PATH", "HOME", "LANG", "LC_ALL", "TERM"]) {
340
+ const value = process.env[name]
341
+ if (value !== undefined) result[name] = value
342
+ }
343
+ return result
344
+ }