@dobby.ai/dobby 0.1.1 → 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 (136) hide show
  1. package/README.md +20 -7
  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/program.js +0 -6
  5. package/dist/src/core/types.js +2 -0
  6. package/dist/src/cron/config.js +2 -2
  7. package/dist/src/cron/service.js +87 -23
  8. package/dist/src/cron/store.js +1 -1
  9. package/package.json +9 -3
  10. package/.env.example +0 -8
  11. package/AGENTS.md +0 -267
  12. package/ROADMAP.md +0 -34
  13. package/config/cron.example.json +0 -9
  14. package/config/gateway.example.json +0 -132
  15. package/dist/plugins/connector-discord/src/mapper.js +0 -75
  16. package/dist/src/cli/tests/config-command.test.js +0 -42
  17. package/dist/src/cli/tests/config-io.test.js +0 -64
  18. package/dist/src/cli/tests/config-mutators.test.js +0 -47
  19. package/dist/src/cli/tests/discord-mapper.test.js +0 -90
  20. package/dist/src/cli/tests/doctor.test.js +0 -252
  21. package/dist/src/cli/tests/init-catalog.test.js +0 -134
  22. package/dist/src/cli/tests/program-options.test.js +0 -78
  23. package/dist/src/cli/tests/routing-config.test.js +0 -254
  24. package/dist/src/core/tests/control-command.test.js +0 -17
  25. package/dist/src/core/tests/runtime-registry.test.js +0 -116
  26. package/dist/src/core/tests/typing-controller.test.js +0 -103
  27. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +0 -175
  28. package/docs/CRON_SCHEDULER_DESIGN.md +0 -374
  29. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +0 -77
  30. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +0 -119
  31. package/docs/MVP.md +0 -135
  32. package/docs/RUNBOOK.md +0 -243
  33. package/docs/TEAMWORK_HANDOFF_DESIGN.md +0 -440
  34. package/plugins/connector-discord/dobby.manifest.json +0 -18
  35. package/plugins/connector-discord/index.js +0 -1
  36. package/plugins/connector-discord/package-lock.json +0 -360
  37. package/plugins/connector-discord/package.json +0 -38
  38. package/plugins/connector-discord/src/connector.ts +0 -345
  39. package/plugins/connector-discord/src/contribution.ts +0 -21
  40. package/plugins/connector-discord/src/mapper.ts +0 -101
  41. package/plugins/connector-discord/tsconfig.json +0 -19
  42. package/plugins/connector-feishu/dobby.manifest.json +0 -18
  43. package/plugins/connector-feishu/index.js +0 -1
  44. package/plugins/connector-feishu/package-lock.json +0 -618
  45. package/plugins/connector-feishu/package.json +0 -38
  46. package/plugins/connector-feishu/src/connector.ts +0 -343
  47. package/plugins/connector-feishu/src/contribution.ts +0 -26
  48. package/plugins/connector-feishu/src/mapper.ts +0 -401
  49. package/plugins/connector-feishu/tsconfig.json +0 -19
  50. package/plugins/plugin-sdk/index.d.ts +0 -261
  51. package/plugins/plugin-sdk/index.js +0 -1
  52. package/plugins/plugin-sdk/package-lock.json +0 -12
  53. package/plugins/plugin-sdk/package.json +0 -22
  54. package/plugins/provider-claude/dobby.manifest.json +0 -17
  55. package/plugins/provider-claude/index.js +0 -1
  56. package/plugins/provider-claude/package-lock.json +0 -3398
  57. package/plugins/provider-claude/package.json +0 -39
  58. package/plugins/provider-claude/src/contribution.ts +0 -1018
  59. package/plugins/provider-claude/tsconfig.json +0 -19
  60. package/plugins/provider-claude-cli/dobby.manifest.json +0 -17
  61. package/plugins/provider-claude-cli/index.js +0 -1
  62. package/plugins/provider-claude-cli/package-lock.json +0 -2898
  63. package/plugins/provider-claude-cli/package.json +0 -38
  64. package/plugins/provider-claude-cli/src/contribution.ts +0 -1673
  65. package/plugins/provider-claude-cli/tsconfig.json +0 -19
  66. package/plugins/provider-pi/dobby.manifest.json +0 -17
  67. package/plugins/provider-pi/index.js +0 -1
  68. package/plugins/provider-pi/package-lock.json +0 -3877
  69. package/plugins/provider-pi/package.json +0 -40
  70. package/plugins/provider-pi/src/contribution.ts +0 -606
  71. package/plugins/provider-pi/tsconfig.json +0 -19
  72. package/plugins/sandbox-core/boxlite.js +0 -1
  73. package/plugins/sandbox-core/dobby.manifest.json +0 -17
  74. package/plugins/sandbox-core/docker.js +0 -1
  75. package/plugins/sandbox-core/package-lock.json +0 -136
  76. package/plugins/sandbox-core/package.json +0 -39
  77. package/plugins/sandbox-core/src/boxlite-context.ts +0 -2
  78. package/plugins/sandbox-core/src/boxlite-contribution.ts +0 -53
  79. package/plugins/sandbox-core/src/boxlite-executor.ts +0 -911
  80. package/plugins/sandbox-core/src/docker-contribution.ts +0 -43
  81. package/plugins/sandbox-core/src/docker-executor.ts +0 -217
  82. package/plugins/sandbox-core/tsconfig.json +0 -19
  83. package/scripts/local-extensions.mjs +0 -168
  84. package/src/agent/event-forwarder.ts +0 -414
  85. package/src/cli/commands/config.ts +0 -328
  86. package/src/cli/commands/configure.ts +0 -92
  87. package/src/cli/commands/cron.ts +0 -410
  88. package/src/cli/commands/doctor.ts +0 -331
  89. package/src/cli/commands/extension.ts +0 -207
  90. package/src/cli/commands/init.ts +0 -211
  91. package/src/cli/commands/start.ts +0 -223
  92. package/src/cli/commands/topology.ts +0 -415
  93. package/src/cli/index.ts +0 -9
  94. package/src/cli/program.ts +0 -314
  95. package/src/cli/shared/config-io.ts +0 -245
  96. package/src/cli/shared/config-mutators.ts +0 -470
  97. package/src/cli/shared/config-schema.ts +0 -228
  98. package/src/cli/shared/config-types.ts +0 -129
  99. package/src/cli/shared/configure-sections.ts +0 -595
  100. package/src/cli/shared/discord-config.ts +0 -14
  101. package/src/cli/shared/init-catalog.ts +0 -249
  102. package/src/cli/shared/local-extension-specs.ts +0 -108
  103. package/src/cli/shared/runtime.ts +0 -33
  104. package/src/cli/shared/schema-prompts.ts +0 -443
  105. package/src/cli/tests/config-command.test.ts +0 -56
  106. package/src/cli/tests/config-io.test.ts +0 -92
  107. package/src/cli/tests/config-mutators.test.ts +0 -59
  108. package/src/cli/tests/discord-mapper.test.ts +0 -128
  109. package/src/cli/tests/doctor.test.ts +0 -269
  110. package/src/cli/tests/init-catalog.test.ts +0 -144
  111. package/src/cli/tests/program-options.test.ts +0 -95
  112. package/src/cli/tests/routing-config.test.ts +0 -281
  113. package/src/core/control-command.ts +0 -12
  114. package/src/core/dedup-store.ts +0 -103
  115. package/src/core/gateway.ts +0 -609
  116. package/src/core/routing.ts +0 -404
  117. package/src/core/runtime-registry.ts +0 -141
  118. package/src/core/tests/control-command.test.ts +0 -20
  119. package/src/core/tests/runtime-registry.test.ts +0 -140
  120. package/src/core/tests/typing-controller.test.ts +0 -129
  121. package/src/core/types.ts +0 -324
  122. package/src/core/typing-controller.ts +0 -119
  123. package/src/cron/config.ts +0 -154
  124. package/src/cron/schedule.ts +0 -61
  125. package/src/cron/service.ts +0 -249
  126. package/src/cron/store.ts +0 -155
  127. package/src/cron/types.ts +0 -60
  128. package/src/extension/loader.ts +0 -145
  129. package/src/extension/manager.ts +0 -355
  130. package/src/extension/manifest.ts +0 -26
  131. package/src/extension/registry.ts +0 -229
  132. package/src/main.ts +0 -8
  133. package/src/sandbox/executor.ts +0 -44
  134. package/src/sandbox/host-executor.ts +0 -118
  135. package/src/shared/dobby-repo.ts +0 -48
  136. 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
- }