@dobby.ai/dobby 0.1.0 → 0.1.2

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 (156) hide show
  1. package/README.md +84 -39
  2. package/dist/src/agent/event-forwarder.js +185 -16
  3. package/dist/src/cli/commands/cron.js +39 -35
  4. package/dist/src/cli/commands/doctor.js +81 -2
  5. package/dist/src/cli/commands/extension.js +3 -1
  6. package/dist/src/cli/commands/init.js +43 -173
  7. package/dist/src/cli/commands/topology.js +38 -14
  8. package/dist/src/cli/program.js +15 -137
  9. package/dist/src/cli/shared/config-io.js +3 -31
  10. package/dist/src/cli/shared/config-mutators.js +33 -9
  11. package/dist/src/cli/shared/configure-sections.js +52 -12
  12. package/dist/src/cli/shared/init-catalog.js +89 -46
  13. package/dist/src/cli/shared/local-extension-specs.js +85 -0
  14. package/dist/src/cli/shared/schema-prompts.js +26 -2
  15. package/dist/src/core/gateway.js +3 -1
  16. package/dist/src/core/routing.js +53 -38
  17. package/dist/src/core/types.js +2 -0
  18. package/dist/src/cron/config.js +2 -2
  19. package/dist/src/cron/service.js +87 -23
  20. package/dist/src/cron/store.js +1 -1
  21. package/dist/src/main.js +0 -0
  22. package/dist/src/shared/dobby-repo.js +40 -0
  23. package/package.json +11 -4
  24. package/.env.example +0 -9
  25. package/AGENTS.md +0 -267
  26. package/ROADMAP.md +0 -34
  27. package/config/cron.example.json +0 -9
  28. package/config/gateway.example.json +0 -128
  29. package/config/models.custom.example.json +0 -27
  30. package/dist/src/agent/tests/event-forwarder.test.js +0 -113
  31. package/dist/src/cli/shared/config-path.js +0 -207
  32. package/dist/src/cli/shared/init-models-file.js +0 -65
  33. package/dist/src/cli/shared/presets.js +0 -86
  34. package/dist/src/cli/tests/config-command.test.js +0 -42
  35. package/dist/src/cli/tests/config-io.test.js +0 -64
  36. package/dist/src/cli/tests/config-mutators.test.js +0 -47
  37. package/dist/src/cli/tests/config-path.test.js +0 -21
  38. package/dist/src/cli/tests/discord-config.test.js +0 -23
  39. package/dist/src/cli/tests/doctor.test.js +0 -107
  40. package/dist/src/cli/tests/init-catalog.test.js +0 -87
  41. package/dist/src/cli/tests/presets.test.js +0 -41
  42. package/dist/src/cli/tests/program-options.test.js +0 -92
  43. package/dist/src/cli/tests/routing-config.test.js +0 -199
  44. package/dist/src/cli/tests/routing-legacy.test.js +0 -191
  45. package/dist/src/core/tests/control-command.test.js +0 -17
  46. package/dist/src/core/tests/gateway-update-strategy.test.js +0 -167
  47. package/dist/src/core/tests/runtime-registry.test.js +0 -116
  48. package/dist/src/core/tests/typing-controller.test.js +0 -103
  49. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +0 -175
  50. package/docs/CRON_SCHEDULER_DESIGN.md +0 -374
  51. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +0 -77
  52. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +0 -119
  53. package/docs/MVP.md +0 -135
  54. package/docs/RUNBOOK.md +0 -242
  55. package/docs/TEAMWORK_HANDOFF_DESIGN.md +0 -440
  56. package/plugins/connector-discord/dobby.manifest.json +0 -18
  57. package/plugins/connector-discord/index.js +0 -1
  58. package/plugins/connector-discord/package-lock.json +0 -360
  59. package/plugins/connector-discord/package.json +0 -38
  60. package/plugins/connector-discord/src/connector.ts +0 -350
  61. package/plugins/connector-discord/src/contribution.ts +0 -21
  62. package/plugins/connector-discord/src/mapper.ts +0 -102
  63. package/plugins/connector-discord/tsconfig.json +0 -19
  64. package/plugins/connector-feishu/dobby.manifest.json +0 -18
  65. package/plugins/connector-feishu/index.js +0 -1
  66. package/plugins/connector-feishu/package-lock.json +0 -618
  67. package/plugins/connector-feishu/package.json +0 -38
  68. package/plugins/connector-feishu/src/connector.ts +0 -343
  69. package/plugins/connector-feishu/src/contribution.ts +0 -26
  70. package/plugins/connector-feishu/src/mapper.ts +0 -401
  71. package/plugins/connector-feishu/tsconfig.json +0 -19
  72. package/plugins/plugin-sdk/index.d.ts +0 -261
  73. package/plugins/plugin-sdk/index.js +0 -1
  74. package/plugins/plugin-sdk/package-lock.json +0 -12
  75. package/plugins/plugin-sdk/package.json +0 -22
  76. package/plugins/provider-claude/dobby.manifest.json +0 -17
  77. package/plugins/provider-claude/index.js +0 -1
  78. package/plugins/provider-claude/package-lock.json +0 -3398
  79. package/plugins/provider-claude/package.json +0 -39
  80. package/plugins/provider-claude/src/contribution.ts +0 -1018
  81. package/plugins/provider-claude/tsconfig.json +0 -19
  82. package/plugins/provider-claude-cli/dobby.manifest.json +0 -17
  83. package/plugins/provider-claude-cli/index.js +0 -1
  84. package/plugins/provider-claude-cli/package-lock.json +0 -2898
  85. package/plugins/provider-claude-cli/package.json +0 -38
  86. package/plugins/provider-claude-cli/src/contribution.ts +0 -1673
  87. package/plugins/provider-claude-cli/tsconfig.json +0 -19
  88. package/plugins/provider-pi/dobby.manifest.json +0 -17
  89. package/plugins/provider-pi/index.js +0 -1
  90. package/plugins/provider-pi/package-lock.json +0 -3877
  91. package/plugins/provider-pi/package.json +0 -40
  92. package/plugins/provider-pi/src/contribution.ts +0 -476
  93. package/plugins/provider-pi/tsconfig.json +0 -19
  94. package/plugins/sandbox-core/boxlite.js +0 -1
  95. package/plugins/sandbox-core/dobby.manifest.json +0 -17
  96. package/plugins/sandbox-core/docker.js +0 -1
  97. package/plugins/sandbox-core/package-lock.json +0 -136
  98. package/plugins/sandbox-core/package.json +0 -39
  99. package/plugins/sandbox-core/src/boxlite-context.ts +0 -2
  100. package/plugins/sandbox-core/src/boxlite-contribution.ts +0 -53
  101. package/plugins/sandbox-core/src/boxlite-executor.ts +0 -911
  102. package/plugins/sandbox-core/src/docker-contribution.ts +0 -43
  103. package/plugins/sandbox-core/src/docker-executor.ts +0 -217
  104. package/plugins/sandbox-core/tsconfig.json +0 -19
  105. package/scripts/local-extensions.mjs +0 -168
  106. package/src/agent/event-forwarder.ts +0 -414
  107. package/src/cli/commands/config.ts +0 -328
  108. package/src/cli/commands/configure.ts +0 -92
  109. package/src/cli/commands/cron.ts +0 -410
  110. package/src/cli/commands/doctor.ts +0 -230
  111. package/src/cli/commands/extension.ts +0 -205
  112. package/src/cli/commands/init.ts +0 -396
  113. package/src/cli/commands/start.ts +0 -223
  114. package/src/cli/commands/topology.ts +0 -383
  115. package/src/cli/index.ts +0 -9
  116. package/src/cli/program.ts +0 -465
  117. package/src/cli/shared/config-io.ts +0 -277
  118. package/src/cli/shared/config-mutators.ts +0 -440
  119. package/src/cli/shared/config-schema.ts +0 -228
  120. package/src/cli/shared/config-types.ts +0 -121
  121. package/src/cli/shared/configure-sections.ts +0 -551
  122. package/src/cli/shared/discord-config.ts +0 -14
  123. package/src/cli/shared/init-catalog.ts +0 -189
  124. package/src/cli/shared/init-models-file.ts +0 -77
  125. package/src/cli/shared/runtime.ts +0 -33
  126. package/src/cli/shared/schema-prompts.ts +0 -414
  127. package/src/cli/tests/config-command.test.ts +0 -56
  128. package/src/cli/tests/config-io.test.ts +0 -92
  129. package/src/cli/tests/config-mutators.test.ts +0 -59
  130. package/src/cli/tests/doctor.test.ts +0 -120
  131. package/src/cli/tests/init-catalog.test.ts +0 -96
  132. package/src/cli/tests/program-options.test.ts +0 -113
  133. package/src/cli/tests/routing-config.test.ts +0 -209
  134. package/src/core/control-command.ts +0 -12
  135. package/src/core/dedup-store.ts +0 -103
  136. package/src/core/gateway.ts +0 -607
  137. package/src/core/routing.ts +0 -379
  138. package/src/core/runtime-registry.ts +0 -141
  139. package/src/core/tests/control-command.test.ts +0 -20
  140. package/src/core/tests/runtime-registry.test.ts +0 -140
  141. package/src/core/tests/typing-controller.test.ts +0 -129
  142. package/src/core/types.ts +0 -318
  143. package/src/core/typing-controller.ts +0 -119
  144. package/src/cron/config.ts +0 -154
  145. package/src/cron/schedule.ts +0 -61
  146. package/src/cron/service.ts +0 -249
  147. package/src/cron/store.ts +0 -155
  148. package/src/cron/types.ts +0 -60
  149. package/src/extension/loader.ts +0 -145
  150. package/src/extension/manager.ts +0 -355
  151. package/src/extension/manifest.ts +0 -26
  152. package/src/extension/registry.ts +0 -229
  153. package/src/main.ts +0 -8
  154. package/src/sandbox/executor.ts +0 -44
  155. package/src/sandbox/host-executor.ts +0 -118
  156. package/tsconfig.json +0 -18
@@ -1,911 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { EventEmitter } from "node:events";
3
- import { resolve, sep } from "node:path";
4
- import { PassThrough, Writable } from "node:stream";
5
- import type { SecurityOptions, SimpleBoxOptions } from "@boxlite-ai/boxlite";
6
- import type { GatewayLogger } from "@dobby.ai/plugin-sdk";
7
- import { BOXLITE_CONTEXT_CONVERSATION_KEY_ENV, BOXLITE_CONTEXT_PROJECT_ROOT_ENV } from "./boxlite-context.js";
8
- import type { ExecOptions, ExecResult, Executor, SpawnOptions, SpawnedProcess } from "@dobby.ai/plugin-sdk";
9
-
10
- export interface BoxliteConfig {
11
- workspaceRoot: string;
12
- image: string;
13
- cpus?: number;
14
- memoryMib?: number;
15
- containerWorkspaceRoot: string;
16
- reuseMode: "conversation" | "workspace";
17
- autoRemove: boolean;
18
- securityProfile: "development" | "standard" | "maximum";
19
- }
20
-
21
- interface BoxEntry {
22
- key: string;
23
- name: string;
24
- projectRoot: string;
25
- box: BoxHandle;
26
- }
27
-
28
- interface BoxExecutionReader {
29
- next: () => Promise<string | null>;
30
- }
31
-
32
- interface BoxExecutionStdin {
33
- write: (chunk: string | Buffer) => Promise<void>;
34
- close?: () => Promise<void>;
35
- end?: () => Promise<void>;
36
- }
37
-
38
- interface BoxExecutionHandle {
39
- stdin?: () => Promise<BoxExecutionStdin>;
40
- stdout: () => Promise<BoxExecutionReader>;
41
- stderr: () => Promise<BoxExecutionReader>;
42
- wait: () => Promise<{ exitCode: number; errorMessage?: string | null }>;
43
- }
44
-
45
- interface BoxHandle {
46
- exec: (
47
- command: string,
48
- args?: string[],
49
- env?: Array<[string, string]>,
50
- tty?: boolean,
51
- ) => Promise<BoxExecutionHandle>;
52
- stop: () => Promise<void>;
53
- }
54
-
55
- type BoxliteVolume = NonNullable<SimpleBoxOptions["volumes"]>[number];
56
-
57
- interface BoxRuntimeCreateOptions {
58
- image: string;
59
- autoRemove: boolean;
60
- workingDir: string;
61
- volumes: BoxliteVolume[];
62
- security: SecurityOptions;
63
- cpus?: number;
64
- memoryMib?: number;
65
- }
66
-
67
- interface NativeRuntime {
68
- create?: (options: BoxRuntimeCreateOptions, name?: string) => Promise<unknown>;
69
- getOrCreate?: (options: BoxRuntimeCreateOptions, name?: string) => Promise<unknown>;
70
- remove?: (idOrName: string, force?: boolean) => Promise<void>;
71
- shutdown?: (timeoutSeconds?: number) => Promise<void>;
72
- close?: () => void;
73
- }
74
-
75
- interface CancellationHandle {
76
- cancelled: Promise<void>;
77
- wasCancelled: () => boolean;
78
- dispose: () => void;
79
- }
80
-
81
- function shellEscape(value: string): string {
82
- return `'${value.replace(/'/g, "'\\''")}'`;
83
- }
84
-
85
- function normalizePrefix(path: string): string {
86
- return path.endsWith(sep) ? path : `${path}${sep}`;
87
- }
88
-
89
- function boxNameFromKey(key: string): string {
90
- const digest = createHash("sha256").update(key).digest("hex").slice(0, 20);
91
- return `im-agent-${digest}`;
92
- }
93
-
94
- function summarizeCommand(command: string, maxLength = 160): string {
95
- if (command.length <= maxLength) return command;
96
- return `${command.slice(0, maxLength - 12)}...(trimmed)`;
97
- }
98
-
99
- function toEnvTuples(env: NodeJS.ProcessEnv | undefined): Array<[string, string]> | undefined {
100
- if (!env) return undefined;
101
-
102
- const tuples: Array<[string, string]> = [];
103
- for (const [key, value] of Object.entries(env)) {
104
- if (value !== undefined) {
105
- tuples.push([key, value]);
106
- }
107
- }
108
-
109
- return tuples.length > 0 ? tuples : undefined;
110
- }
111
-
112
- function formatBoxliteInitError(error: unknown): string {
113
- const message = error instanceof Error ? error.message : String(error);
114
-
115
- return [
116
- "Failed to initialize BoxLite runtime.",
117
- `Root cause: ${message}`,
118
- "Install dependency: npm install @boxlite-ai/boxlite",
119
- "If native binding is missing, reinstall dependencies and add the platform package if needed (example: @boxlite-ai/boxlite-darwin-arm64).",
120
- ].join("\n");
121
- }
122
-
123
- function mapSecurityProfile(profile: BoxliteConfig["securityProfile"]): SecurityOptions {
124
- if (profile === "development") {
125
- return {
126
- jailerEnabled: false,
127
- seccompEnabled: false,
128
- };
129
- }
130
-
131
- if (profile === "standard") {
132
- return {
133
- jailerEnabled: true,
134
- seccompEnabled: true,
135
- };
136
- }
137
-
138
- return {
139
- jailerEnabled: true,
140
- seccompEnabled: true,
141
- maxOpenFiles: 1024,
142
- maxFileSize: 1024 * 1024 * 1024,
143
- maxProcesses: 100,
144
- };
145
- }
146
-
147
- function isRecord(value: unknown): value is Record<string, unknown> {
148
- return Boolean(value) && typeof value === "object";
149
- }
150
-
151
- function isBoxHandle(value: unknown): value is BoxHandle {
152
- if (!isRecord(value)) {
153
- return false;
154
- }
155
-
156
- return typeof value.exec === "function" && typeof value.stop === "function";
157
- }
158
-
159
- function isNativeRuntime(value: unknown): value is NativeRuntime {
160
- if (!isRecord(value)) {
161
- return false;
162
- }
163
-
164
- return (
165
- typeof value.create === "function"
166
- || typeof value.getOrCreate === "function"
167
- || typeof value.remove === "function"
168
- || typeof value.shutdown === "function"
169
- || typeof value.close === "function"
170
- );
171
- }
172
-
173
- export class BoxliteExecutor implements Executor {
174
- private readonly normalizedWorkspaceRoot: string;
175
- private readonly boxes = new Map<string, BoxEntry>();
176
- private readonly stopping = new Map<string, Promise<void>>();
177
- private readonly probedBoxKeys = new Set<string>();
178
- private readonly normalizedContainerWorkspaceRoot: string;
179
- private warnedMissingConversationContext = false;
180
- private closed = false;
181
-
182
- private constructor(
183
- private readonly config: BoxliteConfig,
184
- private readonly runtime: NativeRuntime,
185
- private readonly logger: GatewayLogger,
186
- ) {
187
- this.normalizedWorkspaceRoot = resolve(config.workspaceRoot);
188
- this.normalizedContainerWorkspaceRoot = config.containerWorkspaceRoot.endsWith("/")
189
- ? config.containerWorkspaceRoot.slice(0, -1)
190
- : config.containerWorkspaceRoot;
191
- }
192
-
193
- static async create(config: BoxliteConfig, logger: GatewayLogger): Promise<BoxliteExecutor> {
194
- const runtime = await BoxliteExecutor.loadRuntime();
195
- return new BoxliteExecutor(config, runtime, logger);
196
- }
197
-
198
- async exec(command: string, cwd: string, options: ExecOptions = {}): Promise<ExecResult> {
199
- if (this.closed) {
200
- throw new Error("BoxliteExecutor is closed");
201
- }
202
-
203
- const startedAt = Date.now();
204
- const context = this.parseExecutionContext(cwd, options.env);
205
- const key = this.resolveBoxKey(context.conversationKey, context.projectRoot);
206
- const boxEntry = await this.getOrCreateBox(key, context.projectRoot);
207
- const guestCwd = this.toContainerPath(cwd, context.projectRoot);
208
- const wrapped = `cd ${shellEscape(guestCwd)} && ${command}`;
209
-
210
- this.logger.info(
211
- {
212
- boxKey: key,
213
- boxName: boxEntry.name,
214
- projectRoot: context.projectRoot,
215
- hostCwd: resolve(cwd),
216
- guestCwd,
217
- commandPreview: summarizeCommand(command),
218
- reuseMode: this.config.reuseMode,
219
- },
220
- "BoxLite execution starting",
221
- );
222
-
223
- const execution = await boxEntry.box.exec("sh", ["-lc", wrapped], toEnvTuples(context.commandEnv), false);
224
-
225
- const stdoutTask = this.readExecutionStream(async () => execution.stdout());
226
- const stderrTask = this.readExecutionStream(async () => execution.stderr());
227
-
228
- const cancellation = this.createCancellation(options, async () => {
229
- await this.stopAndInvalidate(key, boxEntry, "execution aborted");
230
- });
231
-
232
- const waitOutcome = execution
233
- .wait()
234
- .then((result) => ({ kind: "result" as const, result }))
235
- .catch((error) => ({ kind: "error" as const, error }));
236
-
237
- let outcome: Awaited<typeof waitOutcome> | null = null;
238
- const first = await Promise.race([waitOutcome, cancellation.cancelled.then(() => null)]);
239
- if (first !== null) {
240
- outcome = first;
241
- } else {
242
- const settled = await Promise.race([
243
- waitOutcome,
244
- new Promise<null>((resolveWaitTimeout) => {
245
- setTimeout(() => resolveWaitTimeout(null), 3000);
246
- }),
247
- ]);
248
- outcome = settled;
249
- }
250
-
251
- cancellation.dispose();
252
-
253
- const [stdout, stderr] = await Promise.all([stdoutTask, stderrTask]);
254
- if (cancellation.wasCancelled()) {
255
- const fallbackCode = outcome?.kind === "result" ? outcome.result.exitCode : -1;
256
- this.logger.info(
257
- {
258
- boxKey: key,
259
- boxName: boxEntry.name,
260
- code: fallbackCode,
261
- killed: true,
262
- durationMs: Date.now() - startedAt,
263
- stdoutBytes: Buffer.byteLength(stdout, "utf-8"),
264
- stderrBytes: Buffer.byteLength(stderr, "utf-8"),
265
- },
266
- "BoxLite execution finished",
267
- );
268
- return { stdout, stderr, code: fallbackCode, killed: true };
269
- }
270
-
271
- if (!outcome) {
272
- throw new Error("BoxLite execution did not finish after cancellation timeout");
273
- }
274
-
275
- if (outcome.kind === "error") {
276
- throw outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
277
- }
278
-
279
- this.logger.info(
280
- {
281
- boxKey: key,
282
- boxName: boxEntry.name,
283
- code: outcome.result.exitCode,
284
- killed: false,
285
- durationMs: Date.now() - startedAt,
286
- stdoutBytes: Buffer.byteLength(stdout, "utf-8"),
287
- stderrBytes: Buffer.byteLength(stderr, "utf-8"),
288
- },
289
- "BoxLite execution finished",
290
- );
291
-
292
- return {
293
- stdout,
294
- stderr,
295
- code: outcome.result.exitCode,
296
- killed: false,
297
- };
298
- }
299
-
300
- spawn(options: SpawnOptions): SpawnedProcess {
301
- if (this.closed) {
302
- throw new Error("BoxliteExecutor is closed");
303
- }
304
-
305
- const stdout = new PassThrough();
306
- const stderr = new PassThrough();
307
- const emitter = new EventEmitter();
308
- emitter.on("error", () => undefined);
309
-
310
- let killed = false;
311
- let exitCode: number | null = null;
312
- let exited = false;
313
- let stopping = false;
314
- let resolvedKey: string | null = null;
315
- let resolvedBox: BoxEntry | null = null;
316
-
317
- const stdinQueue: Buffer[] = [];
318
- let stdinWriter: BoxExecutionStdin | null = null;
319
- let stdinClosed = false;
320
-
321
- const emitExit = (code: number | null, signal: NodeJS.Signals | null) => {
322
- if (exited) return;
323
- exited = true;
324
- exitCode = code;
325
- stdout.end();
326
- stderr.end();
327
- emitter.emit("exit", code, signal);
328
- };
329
-
330
- const emitError = (error: unknown) => {
331
- const normalized = error instanceof Error ? error : new Error(String(error));
332
- emitter.emit("error", normalized);
333
- };
334
-
335
- const closeRemoteStdin = async (): Promise<void> => {
336
- if (!stdinWriter) return;
337
- const closer = stdinWriter.close ?? stdinWriter.end;
338
- if (!closer) return;
339
- await closer.call(stdinWriter);
340
- };
341
-
342
- const stopProcess = async (signal: NodeJS.Signals = "SIGKILL"): Promise<void> => {
343
- if (stopping) return;
344
- stopping = true;
345
-
346
- try {
347
- if (resolvedKey && resolvedBox) {
348
- await this.stopAndInvalidate(resolvedKey, resolvedBox, `spawn terminated (${signal})`);
349
- }
350
- } catch (error) {
351
- emitError(error);
352
- } finally {
353
- emitExit(exitCode ?? -1, signal);
354
- }
355
- };
356
-
357
- const kill = (signal: NodeJS.Signals = "SIGKILL"): boolean => {
358
- if (exited) return false;
359
- if (killed) return true;
360
- killed = true;
361
- void stopProcess(signal);
362
- return true;
363
- };
364
-
365
- const onAbort = () => {
366
- kill("SIGKILL");
367
- };
368
-
369
- if (options.signal) {
370
- if (options.signal.aborted) {
371
- kill("SIGKILL");
372
- } else {
373
- options.signal.addEventListener("abort", onAbort, { once: true });
374
- }
375
- }
376
-
377
- const stdin = new Writable({
378
- write: (chunk, _encoding, callback) => {
379
- if (killed || exited) {
380
- callback(new Error("BoxLite spawned process is not writable"));
381
- return;
382
- }
383
-
384
- const payload = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
385
- if (!stdinWriter) {
386
- stdinQueue.push(payload);
387
- callback();
388
- return;
389
- }
390
-
391
- Promise.resolve()
392
- .then(() => stdinWriter?.write(payload))
393
- .then(() => callback())
394
- .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
395
- },
396
- final: (callback) => {
397
- stdinClosed = true;
398
- if (!stdinWriter) {
399
- callback();
400
- return;
401
- }
402
-
403
- Promise.resolve()
404
- .then(() => closeRemoteStdin())
405
- .then(() => callback())
406
- .catch((error) => callback(error instanceof Error ? error : new Error(String(error))));
407
- },
408
- });
409
-
410
- const spawned: SpawnedProcess = {
411
- stdin,
412
- stdout,
413
- stderr,
414
- get killed() {
415
- return killed;
416
- },
417
- get exitCode() {
418
- return exitCode;
419
- },
420
- kill,
421
- on: (event, listener) => {
422
- emitter.on(event, listener as (...args: unknown[]) => void);
423
- },
424
- once: (event, listener) => {
425
- emitter.once(event, listener as (...args: unknown[]) => void);
426
- },
427
- off: (event, listener) => {
428
- emitter.off(event, listener as (...args: unknown[]) => void);
429
- },
430
- };
431
-
432
- const pump = async (reader: BoxExecutionReader, target: PassThrough): Promise<void> => {
433
- while (!killed && !exited) {
434
- const chunk = await reader.next();
435
- if (chunk === null) break;
436
- target.write(chunk);
437
- }
438
- };
439
-
440
- void (async () => {
441
- try {
442
- const cwd = options.cwd ?? this.config.workspaceRoot;
443
- const context = this.parseExecutionContext(cwd, options.env);
444
- const key = this.resolveBoxKey(context.conversationKey, context.projectRoot);
445
- const boxEntry = await this.getOrCreateBox(key, context.projectRoot);
446
- resolvedKey = key;
447
- resolvedBox = boxEntry;
448
-
449
- if (killed || exited || options.signal?.aborted) {
450
- await stopProcess("SIGKILL");
451
- return;
452
- }
453
-
454
- const guestCwd = this.toContainerPath(cwd, context.projectRoot);
455
- const argv = [options.command, ...options.args].map(shellEscape).join(" ");
456
- const wrapped = `cd ${shellEscape(guestCwd)} && exec ${argv}`;
457
-
458
- this.logger.info(
459
- {
460
- boxKey: key,
461
- boxName: boxEntry.name,
462
- projectRoot: context.projectRoot,
463
- hostCwd: resolve(cwd),
464
- guestCwd,
465
- commandPreview: summarizeCommand(`${options.command} ${options.args.join(" ")}`),
466
- reuseMode: this.config.reuseMode,
467
- },
468
- "BoxLite spawn starting",
469
- );
470
-
471
- const execution = await boxEntry.box.exec("sh", ["-lc", wrapped], toEnvTuples(context.commandEnv), options.tty ?? false);
472
- if (typeof execution.stdin !== "function") {
473
- throw new Error("BoxLite execution handle does not expose stdin; cannot run sandboxed provider process");
474
- }
475
-
476
- stdinWriter = await execution.stdin();
477
- if (!stdinWriter || typeof stdinWriter.write !== "function") {
478
- throw new Error("BoxLite stdin stream is unavailable for sandboxed provider process");
479
- }
480
-
481
- while (stdinQueue.length > 0) {
482
- const chunk = stdinQueue.shift();
483
- if (!chunk) continue;
484
- await stdinWriter.write(chunk);
485
- }
486
- if (stdinClosed) {
487
- await closeRemoteStdin();
488
- }
489
-
490
- const stdoutReader = await execution.stdout();
491
- const stderrReader = await execution.stderr();
492
- const [waitResult] = await Promise.all([
493
- execution.wait(),
494
- pump(stdoutReader, stdout),
495
- pump(stderrReader, stderr),
496
- ]);
497
-
498
- if (!killed && !exited) {
499
- if (waitResult.errorMessage && waitResult.errorMessage.trim().length > 0) {
500
- stderr.write(`${waitResult.errorMessage}\n`);
501
- }
502
- exitCode = waitResult.exitCode;
503
- emitExit(waitResult.exitCode, null);
504
- }
505
- } catch (error) {
506
- if (!exited) {
507
- emitError(error);
508
- emitExit(exitCode ?? 1, null);
509
- }
510
- } finally {
511
- if (options.signal) {
512
- options.signal.removeEventListener("abort", onAbort);
513
- }
514
- }
515
- })();
516
-
517
- return spawned;
518
- }
519
-
520
- async close(): Promise<void> {
521
- this.closed = true;
522
- const keys = [...this.boxes.keys()];
523
- await Promise.all(keys.map((key) => this.stopAndInvalidate(key, undefined, "executor shutdown")));
524
-
525
- if (typeof this.runtime.shutdown === "function") {
526
- try {
527
- await this.runtime.shutdown(10);
528
- } catch (error) {
529
- this.logger.warn({ err: error }, "Failed to shutdown BoxLite runtime cleanly");
530
- }
531
- }
532
-
533
- if (typeof this.runtime.close === "function") {
534
- try {
535
- this.runtime.close();
536
- } catch (error) {
537
- this.logger.warn({ err: error }, "Failed to close BoxLite runtime handle");
538
- }
539
- }
540
- }
541
-
542
- private parseExecutionContext(
543
- cwd: string,
544
- env: NodeJS.ProcessEnv | undefined,
545
- ): {
546
- conversationKey?: string;
547
- projectRoot: string;
548
- commandEnv: NodeJS.ProcessEnv | undefined;
549
- } {
550
- const copiedEnv: NodeJS.ProcessEnv | undefined = env ? { ...env } : undefined;
551
- const conversationKey = copiedEnv?.[BOXLITE_CONTEXT_CONVERSATION_KEY_ENV];
552
- const projectRootRaw = copiedEnv?.[BOXLITE_CONTEXT_PROJECT_ROOT_ENV] ?? this.normalizedWorkspaceRoot;
553
-
554
- if (copiedEnv) {
555
- delete copiedEnv[BOXLITE_CONTEXT_CONVERSATION_KEY_ENV];
556
- delete copiedEnv[BOXLITE_CONTEXT_PROJECT_ROOT_ENV];
557
- }
558
-
559
- const projectRoot = resolve(projectRootRaw);
560
- this.assertWithinRoot(projectRoot, this.normalizedWorkspaceRoot, "project root");
561
- this.assertWithinRoot(cwd, projectRoot, "cwd");
562
-
563
- return {
564
- ...(conversationKey ? { conversationKey } : {}),
565
- projectRoot,
566
- commandEnv: copiedEnv,
567
- };
568
- }
569
-
570
- private resolveBoxKey(conversationKey: string | undefined, projectRoot: string): string {
571
- if (this.config.reuseMode === "conversation") {
572
- if (conversationKey && conversationKey.length > 0) {
573
- return `conversation:${conversationKey}`;
574
- }
575
-
576
- if (!this.warnedMissingConversationContext) {
577
- this.warnedMissingConversationContext = true;
578
- this.logger.warn(
579
- {
580
- envKey: BOXLITE_CONTEXT_CONVERSATION_KEY_ENV,
581
- },
582
- "Missing BoxLite conversation context; falling back to workspace-level box reuse",
583
- );
584
- }
585
- }
586
-
587
- return `workspace:${projectRoot}`;
588
- }
589
-
590
- private async getOrCreateBox(key: string, projectRoot: string): Promise<BoxEntry> {
591
- const existing = this.boxes.get(key);
592
- if (existing) {
593
- if (existing.projectRoot === projectRoot) {
594
- await this.probeBox(existing);
595
- return existing;
596
- }
597
- await this.stopAndInvalidate(key, existing, "project root changed");
598
- }
599
-
600
- const name = boxNameFromKey(key);
601
- // New gateway process with conversation/workspace reuse can hit an old box that
602
- // was created from a previous image/version. Proactively remove same-name stale box
603
- // so creation is deterministic for current config.
604
- await this.removeStaleNamedBox(name);
605
- const boxOptions: BoxRuntimeCreateOptions = {
606
- image: this.config.image,
607
- autoRemove: this.config.autoRemove,
608
- workingDir: this.normalizedContainerWorkspaceRoot,
609
- volumes: [
610
- {
611
- hostPath: projectRoot,
612
- guestPath: this.normalizedContainerWorkspaceRoot,
613
- readOnly: false,
614
- },
615
- ],
616
- security: mapSecurityProfile(this.config.securityProfile),
617
- };
618
-
619
- if (this.config.cpus !== undefined) {
620
- boxOptions.cpus = this.config.cpus;
621
- }
622
- if (this.config.memoryMib !== undefined) {
623
- boxOptions.memoryMib = this.config.memoryMib;
624
- }
625
-
626
- let boxHandle: unknown;
627
- if (typeof this.runtime.getOrCreate === "function") {
628
- const result = await this.runtime.getOrCreate(boxOptions, name);
629
- boxHandle = this.extractBoxFromGetOrCreateResult(result);
630
- } else if (typeof this.runtime.create === "function") {
631
- boxHandle = await this.runtime.create(boxOptions, name);
632
- } else {
633
- throw new Error("BoxLite runtime does not expose create/getOrCreate");
634
- }
635
-
636
- const box = this.assertBoxHandle(boxHandle);
637
- const created: BoxEntry = { key, name, projectRoot, box };
638
- this.boxes.set(key, created);
639
- this.logger.info(
640
- {
641
- boxKey: key,
642
- boxName: name,
643
- image: this.config.image,
644
- projectRoot,
645
- containerWorkspaceRoot: this.normalizedContainerWorkspaceRoot,
646
- autoRemove: this.config.autoRemove,
647
- securityProfile: this.config.securityProfile,
648
- },
649
- "BoxLite box ready",
650
- );
651
- await this.probeBox(created);
652
- return created;
653
- }
654
-
655
- private async removeStaleNamedBox(name: string): Promise<void> {
656
- if (typeof this.runtime.remove !== "function") {
657
- return;
658
- }
659
-
660
- try {
661
- await this.runtime.remove(name, true);
662
- this.logger.info(
663
- {
664
- boxName: name,
665
- },
666
- "Removed stale BoxLite box before create/getOrCreate",
667
- );
668
- } catch (error) {
669
- this.logger.debug(
670
- {
671
- err: error,
672
- boxName: name,
673
- },
674
- "No stale BoxLite box removed (ignored)",
675
- );
676
- }
677
- }
678
-
679
- private async probeBox(entry: BoxEntry): Promise<void> {
680
- if (this.probedBoxKeys.has(entry.key)) return;
681
- this.probedBoxKeys.add(entry.key);
682
-
683
- try {
684
- const execution = await entry.box.exec("sh", ["-lc", "uname -s && pwd"], undefined, false);
685
- const [stdout, stderr, result] = await Promise.all([
686
- this.readExecutionStream(async () => execution.stdout()),
687
- this.readExecutionStream(async () => execution.stderr()),
688
- execution.wait(),
689
- ]);
690
-
691
- const lines = stdout
692
- .split(/\r?\n/)
693
- .map((line) => line.trim())
694
- .filter((line) => line.length > 0);
695
- const kernel = lines[0] ?? "";
696
- const cwd = lines[1] ?? "";
697
- const expected = this.normalizedContainerWorkspaceRoot;
698
- const looksGuestCwd = cwd === expected || cwd.startsWith(`${expected}/`);
699
- const looksIsolated = result.exitCode === 0 && kernel === "Linux" && looksGuestCwd;
700
-
701
- if (looksIsolated) {
702
- this.logger.info(
703
- {
704
- boxKey: entry.key,
705
- boxName: entry.name,
706
- kernel,
707
- cwd,
708
- },
709
- "BoxLite isolation probe passed",
710
- );
711
- return;
712
- }
713
-
714
- this.logger.warn(
715
- {
716
- boxKey: entry.key,
717
- boxName: entry.name,
718
- exitCode: result.exitCode,
719
- kernel,
720
- cwd,
721
- expectedWorkspaceRoot: expected,
722
- stderr: stderr.trim(),
723
- },
724
- "BoxLite isolation probe indicates non-guest execution",
725
- );
726
- } catch (error) {
727
- this.logger.warn(
728
- {
729
- err: error,
730
- boxKey: entry.key,
731
- boxName: entry.name,
732
- },
733
- "BoxLite isolation probe failed",
734
- );
735
- }
736
- }
737
-
738
- private extractBoxFromGetOrCreateResult(result: unknown): unknown {
739
- if (isRecord(result) && "box" in result) {
740
- return result.box;
741
- }
742
-
743
- return result;
744
- }
745
-
746
- private assertBoxHandle(value: unknown): BoxHandle {
747
- if (!isBoxHandle(value)) {
748
- throw new Error("BoxLite runtime returned a box without exec/stop methods");
749
- }
750
-
751
- return value;
752
- }
753
-
754
- private async stopAndInvalidate(key: string, expected: BoxEntry | undefined, reason: string): Promise<void> {
755
- const current = this.boxes.get(key);
756
- if (!current) return;
757
- if (expected && current !== expected) return;
758
-
759
- const existingStop = this.stopping.get(key);
760
- if (existingStop) {
761
- await existingStop;
762
- return;
763
- }
764
-
765
- this.boxes.delete(key);
766
- this.probedBoxKeys.delete(key);
767
- const stopPromise = (async () => {
768
- try {
769
- await current.box.stop();
770
- } catch (error) {
771
- this.logger.warn({ err: error, key, reason }, "Failed to stop BoxLite box");
772
- }
773
-
774
- if (typeof this.runtime.remove === "function") {
775
- try {
776
- await this.runtime.remove(current.name, true);
777
- } catch (error) {
778
- this.logger.debug({ err: error, key, boxName: current.name }, "Failed to remove BoxLite box (ignored)");
779
- }
780
- }
781
- })().finally(() => {
782
- this.stopping.delete(key);
783
- });
784
-
785
- this.stopping.set(key, stopPromise);
786
- await stopPromise;
787
- }
788
-
789
- private createCancellation(options: ExecOptions, onCancel: () => Promise<void>): CancellationHandle {
790
- let cancelled = false;
791
- let resolved = false;
792
- let timeoutHandle: NodeJS.Timeout | undefined;
793
- let abortHandler: (() => void) | undefined;
794
- let resolveCancelled!: () => void;
795
-
796
- const cancelledPromise = new Promise<void>((resolvePromise) => {
797
- resolveCancelled = resolvePromise;
798
- });
799
-
800
- const triggerCancel = async () => {
801
- if (resolved) return;
802
- resolved = true;
803
- cancelled = true;
804
-
805
- try {
806
- await onCancel();
807
- } catch (error) {
808
- this.logger.warn({ err: error }, "Failed during BoxLite cancellation handling");
809
- } finally {
810
- resolveCancelled();
811
- }
812
- };
813
-
814
- if (options.timeoutSeconds && options.timeoutSeconds > 0) {
815
- timeoutHandle = setTimeout(() => {
816
- void triggerCancel();
817
- }, options.timeoutSeconds * 1000);
818
- }
819
-
820
- if (options.signal) {
821
- abortHandler = () => {
822
- void triggerCancel();
823
- };
824
-
825
- if (options.signal.aborted) {
826
- void triggerCancel();
827
- } else {
828
- options.signal.addEventListener("abort", abortHandler, { once: true });
829
- }
830
- }
831
-
832
- return {
833
- cancelled: cancelledPromise,
834
- wasCancelled: () => cancelled,
835
- dispose: () => {
836
- if (timeoutHandle) clearTimeout(timeoutHandle);
837
- if (options.signal && abortHandler) {
838
- options.signal.removeEventListener("abort", abortHandler);
839
- }
840
- },
841
- };
842
- }
843
-
844
- private async readExecutionStream(
845
- streamFactory: () => Promise<BoxExecutionReader>,
846
- ): Promise<string> {
847
- let stream: BoxExecutionReader | null = null;
848
- try {
849
- stream = await streamFactory();
850
- } catch {
851
- return "";
852
- }
853
-
854
- const chunks: string[] = [];
855
- try {
856
- while (true) {
857
- const chunk = await stream.next();
858
- if (chunk === null) break;
859
- chunks.push(chunk);
860
- }
861
- } catch {
862
- // Ignore stream read errors; wait result is the source of truth for execution status.
863
- }
864
-
865
- return chunks.join("");
866
- }
867
-
868
- private toContainerPath(hostPath: string, projectRoot: string): string {
869
- const resolved = resolve(hostPath);
870
- this.assertWithinRoot(resolved, projectRoot, "cwd");
871
-
872
- const relativePrefix = normalizePrefix(projectRoot);
873
- const relative = resolved === projectRoot ? "" : resolved.slice(relativePrefix.length).replaceAll("\\", "/");
874
-
875
- return relative.length > 0 ? `${this.normalizedContainerWorkspaceRoot}/${relative}` : this.normalizedContainerWorkspaceRoot;
876
- }
877
-
878
- private assertWithinRoot(pathToCheck: string, rootDir: string, label: string): void {
879
- const normalizedRoot = resolve(rootDir);
880
- const normalizedPath = resolve(pathToCheck);
881
- const rootPrefix = normalizePrefix(normalizedRoot);
882
-
883
- if (normalizedPath !== normalizedRoot && !normalizedPath.startsWith(rootPrefix)) {
884
- throw new Error(`Resolved ${label} '${normalizedPath}' is outside allowed root '${normalizedRoot}'`);
885
- }
886
- }
887
-
888
- private static async loadRuntime(): Promise<NativeRuntime> {
889
- try {
890
- const boxliteModule = await import("@boxlite-ai/boxlite");
891
- const getNativeModule = (boxliteModule as { getNativeModule?: () => unknown }).getNativeModule;
892
- if (typeof getNativeModule !== "function") {
893
- throw new Error("getNativeModule export not found");
894
- }
895
-
896
- const native = getNativeModule();
897
- if (!isRecord(native) || !isRecord(native.JsBoxlite) || typeof native.JsBoxlite.withDefaultConfig !== "function") {
898
- throw new Error("JsBoxlite.withDefaultConfig is not available");
899
- }
900
-
901
- const runtime = native.JsBoxlite.withDefaultConfig();
902
- if (!isNativeRuntime(runtime)) {
903
- throw new Error("BoxLite runtime handle is invalid");
904
- }
905
-
906
- return runtime;
907
- } catch (error) {
908
- throw new Error(formatBoxliteInitError(error));
909
- }
910
- }
911
- }