@capybara-hq/kernel 0.1.0-rc.8 → 0.1.0-rc.9

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 (110) hide show
  1. package/dist/background/finish.js +2 -1
  2. package/dist/background/launch.d.ts +13 -7
  3. package/dist/background/launch.js +15 -8
  4. package/dist/background/poll.job.d.ts +1 -1
  5. package/dist/background/poll.job.js +8 -3
  6. package/dist/background/session.d.ts +2 -2
  7. package/dist/background/session.js +3 -2
  8. package/dist/background/status.d.ts +1 -1
  9. package/dist/background/status.js +1 -1
  10. package/dist/background/stop.js +4 -3
  11. package/dist/background/store.d.ts +6 -1
  12. package/dist/background/store.js +5 -1
  13. package/dist/background/tasks.service.d.ts +1 -1
  14. package/dist/background/tasks.service.js +4 -4
  15. package/dist/catalog/marketplaces.service.d.ts +1 -1
  16. package/dist/config/docs.js +2 -0
  17. package/dist/config/schema.d.ts +7 -0
  18. package/dist/config/schema.js +45 -0
  19. package/dist/conversations/event-log.d.ts +4 -3
  20. package/dist/conversations/event-log.js +4 -3
  21. package/dist/conversations/events.d.ts +9 -0
  22. package/dist/conversations/events.js +14 -0
  23. package/dist/db/kernel-schema.d.ts +26 -1
  24. package/dist/devices/device-auth.controller.d.ts +42 -0
  25. package/dist/devices/device-auth.controller.js +116 -0
  26. package/dist/devices/device-auth.d.ts +119 -0
  27. package/dist/devices/device-auth.js +403 -0
  28. package/dist/devices/device-auth.module.d.ts +7 -0
  29. package/dist/devices/device-auth.module.js +25 -0
  30. package/dist/devices/device-gateway.d.ts +128 -0
  31. package/dist/devices/device-gateway.js +530 -0
  32. package/dist/devices/device-provider.d.ts +89 -0
  33. package/dist/devices/device-provider.js +151 -0
  34. package/dist/devices/device-routes.d.ts +36 -0
  35. package/dist/devices/device-routes.js +90 -0
  36. package/dist/devices/devices.module.d.ts +16 -0
  37. package/dist/devices/devices.module.js +44 -0
  38. package/dist/devices/dto.d.ts +66 -0
  39. package/dist/devices/dto.js +44 -0
  40. package/dist/devices/keys.d.ts +25 -0
  41. package/dist/devices/keys.js +25 -0
  42. package/dist/devices/machines.d.ts +106 -0
  43. package/dist/devices/machines.js +301 -0
  44. package/dist/devices/relay.d.ts +100 -0
  45. package/dist/devices/relay.js +373 -0
  46. package/dist/devices/targets.d.ts +28 -0
  47. package/dist/devices/targets.js +73 -0
  48. package/dist/environments/access.d.ts +11 -6
  49. package/dist/environments/access.js +23 -8
  50. package/dist/environments/conversation-environments.d.ts +20 -6
  51. package/dist/environments/conversation-environments.js +70 -9
  52. package/dist/environments/environments.controller.d.ts +4 -1
  53. package/dist/environments/environments.controller.js +15 -1
  54. package/dist/environments/environments.module.js +3 -1
  55. package/dist/environments/environments.service.d.ts +54 -7
  56. package/dist/environments/environments.service.js +104 -20
  57. package/dist/environments/legacy-cleanup.job.d.ts +1 -1
  58. package/dist/environments/legacy-cleanup.job.js +4 -4
  59. package/dist/errors.d.ts +24 -0
  60. package/dist/errors.js +24 -0
  61. package/dist/evals/recorded-tools.d.ts +9 -0
  62. package/dist/evals/recorded-tools.js +27 -0
  63. package/dist/http/api.module.js +4 -0
  64. package/dist/http/gateway-routes.d.ts +7 -3
  65. package/dist/http/gateway-routes.js +6 -3
  66. package/dist/infra/config/kernel-config.service.js +2 -0
  67. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  68. package/dist/infra/throttler/named-throttlers.js +38 -0
  69. package/dist/kernel.module.js +3 -0
  70. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  71. package/dist/platform/exclusions.js +33 -0
  72. package/dist/platform/ops/environments.ops.js +21 -3
  73. package/dist/roles/gateway-role.d.ts +14 -3
  74. package/dist/roles/gateway-role.js +25 -5
  75. package/dist/roles/gateway.module.d.ts +2 -1
  76. package/dist/roles/gateway.module.js +6 -3
  77. package/dist/runtime/context/build.d.ts +2 -0
  78. package/dist/runtime/context/build.js +14 -0
  79. package/dist/runtime/context/compaction.js +1 -0
  80. package/dist/runtime/context/environment-paths.d.ts +40 -0
  81. package/dist/runtime/context/environment-paths.js +48 -0
  82. package/dist/runtime/model-call.js +2 -0
  83. package/dist/sandbox/providers.d.ts +29 -7
  84. package/dist/sandbox/providers.js +43 -8
  85. package/dist/sandbox/sandbox.service.d.ts +70 -18
  86. package/dist/sandbox/sandbox.service.js +176 -59
  87. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  88. package/dist/tools/builtins/sandbox/bash.js +39 -27
  89. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  90. package/dist/tools/builtins/sandbox/common.js +121 -30
  91. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  92. package/dist/tools/builtins/sandbox/edit.js +40 -37
  93. package/dist/tools/builtins/sandbox/glob.js +13 -8
  94. package/dist/tools/builtins/sandbox/grep.js +10 -6
  95. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  96. package/dist/tools/builtins/sandbox/index.js +6 -4
  97. package/dist/tools/builtins/sandbox/read.js +9 -6
  98. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  99. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  100. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  101. package/dist/tools/builtins/sandbox/write.js +33 -30
  102. package/dist/tools/builtins/skill-load.d.ts +8 -5
  103. package/dist/tools/builtins/skill-load.js +21 -15
  104. package/dist/tools/builtins/skill-write.js +2 -2
  105. package/dist/tools/pipeline.js +2 -0
  106. package/dist/tools/registry.d.ts +21 -4
  107. package/dist/tools/registry.js +10 -2
  108. package/package.json +5 -2
  109. package/src/migrations/0093_connected_environments.sql +37 -0
  110. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -8,7 +8,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
10
  import { randomUUID } from "node:crypto";
11
- import { isFileSystemGoneError, isSandboxGoneError, } from "@capybara-hq/plugin-sdk";
11
+ import { isFileSystemGoneError, isSandboxGoneError, rootsOf, } from "@capybara-hq/plugin-sdk";
12
12
  import { Inject, Injectable } from "@nestjs/common";
13
13
  import { sql, } from "kysely";
14
14
  import { BackgroundTaskStore } from "../background/store.js";
@@ -21,7 +21,7 @@ import { KERNEL_LOGGER, REDIS } from "../infra/tokens.js";
21
21
  import { effectiveSandboxEgress, effectiveSandboxLimits, readOrgSettings, } from "../orgs/settings.service.js";
22
22
  import { canonicalJson } from "../tools/canonical-json.js";
23
23
  import { SandboxLeases, SandboxLockLostError, sandboxBusy, } from "./leases.js";
24
- import { SandboxProviders } from "./providers.js";
24
+ import { DEVICE_PROVIDER, providerForEnvironment, SandboxProviders, } from "./providers.js";
25
25
  // Moved to leases.ts (phase C); re-exported for existing importers
26
26
  export { SANDBOX_LOCK_PREFIX, SANDBOX_LOCK_TTL_MS, SandboxLockLostError, sandboxBusy, } from "./leases.js";
27
27
  /** Margin (ms) of a holder record's TTL beyond the longest command: by then the in-sandbox limit has ended its commands. */
@@ -47,8 +47,14 @@ const opSignal = (lease) => AbortSignal.any([lease.signal, AbortSignal.timeout(S
47
47
  export const environmentResourceName = (environmentId) => `cap-env-${environmentId}`;
48
48
  /** The environment is missing, deleted, or not in the caller's org (404; ids cannot be probed). */
49
49
  export const environmentNotFound = () => new ApiError("environment.not_found", "This environment does not exist or was deleted.");
50
- /** The environment's file system is gone at the provider (409); never replaced silently. */
51
- export const environmentFilesLost = () => new ApiError("environment.files_lost", "The environment's files are gone; reset its files from the environments page to start with an empty workspace.");
50
+ /**
51
+ * The environment's file system is gone at the provider (409); never replaced silently. On a
52
+ * connected machine (`device`, details `[{ source: "device" }]`) the directory is the machine
53
+ * owner's: `capybara-device logout`, then `login` into the environment, recreates it (ruling R24).
54
+ */
55
+ export const environmentFilesLost = (device = false) => device
56
+ ? new ApiError("environment.files_lost", "The environment's directory is gone from its connected machine; run capybara-device logout, then capybara-device login into this environment, on that machine to recreate it.", [{ source: "device" }])
57
+ : new ApiError("environment.files_lost", "The environment's files are gone; reset its files from the environments page to start with an empty workspace.");
52
58
  /**
53
59
  * The row still records the instance `gone` (same handle and same `created_at`). Handles compare
54
60
  * canonically: a jsonb round trip reorders a multi-key handle's keys (final review m9).
@@ -185,11 +191,18 @@ async function inPool(items, limit, fn) {
185
191
  * row's `created_at` (set by the fenced write after each `create`); the handle alone is the same
186
192
  * after a rebuild on providers whose handle is the instance name (sandbox-docker).
187
193
  *
194
+ * - **Provider per environment** (environments spec §6): each call resolves the provider from
195
+ * the environment's `environments.provider` ({@link providerForEnvironment}): a `device`
196
+ * environment runs on the kernel-internal `@device` provider (never the cloud one), a cloud
197
+ * environment on the loaded `sandbox@2` extension. A `@device` instance is never suspended or
198
+ * destroyed for inactivity; a deleted `@device` environment (or one of a deleted org) is
199
+ * destroyed by the sweep like any other.
200
+ *
188
201
  * Files kept by another provider than the current one make the environment unusable
189
202
  * (`environment.unavailable`) until that provider is back; deleting it still works while that
190
- * provider is loaded ({@link SandboxProviders.lookup}). Without a provider {@link available} is false and
191
- * uses throw `sandbox.unavailable` (503). No in-process state: replicas share it through PG and
192
- * Redis.
203
+ * provider is loaded ({@link SandboxProviders.forName}). Without the environment's provider
204
+ * {@link availableFor} is false and uses throw `sandbox.unavailable` (503). No in-process state:
205
+ * replicas share it through PG and Redis.
193
206
  */
194
207
  let SandboxService = class SandboxService {
195
208
  db;
@@ -202,7 +215,7 @@ let SandboxService = class SandboxService {
202
215
  constructor(db,
203
216
  /** The record of the instance each holder last used (`capk:sandbox-seen:…`). */
204
217
  redis, leases,
205
- /** The `sandbox@2` provider (read at every call: providers are ready after the extension load). */
218
+ /** The providers (read at every call: providers are ready after the extension load). */
206
219
  providers, config, log,
207
220
  /** Background tasks lost while their process may live on (killed after a resume). */
208
221
  background) {
@@ -218,9 +231,38 @@ let SandboxService = class SandboxService {
218
231
  get pollMs() {
219
232
  return this.leases.pollMs;
220
233
  }
221
- /** Whether there is a sandbox provider. */
222
- get available() {
223
- return this.providers.current() !== undefined;
234
+ /**
235
+ * Whether there is a cloud sandbox provider (a loaded `sandbox@2` extension). For gates that have
236
+ * no environment yet (tool listing, skill files); a call on an environment asks
237
+ * {@link availableFor}.
238
+ */
239
+ get cloudAvailable() {
240
+ return this.providers.cloud() !== undefined;
241
+ }
242
+ /** Whether the environment's provider is there (`@device` registered, or a cloud provider). */
243
+ async availableFor(environmentId) {
244
+ return (await this.providerOf(environmentId)) !== undefined;
245
+ }
246
+ /**
247
+ * The provider the environment works with, read from its row ({@link providerForEnvironment});
248
+ * a missing row (or one of another org, with `orgId`) reads as a cloud environment, whose use
249
+ * then fails as not found.
250
+ */
251
+ async providerOf(environmentId, orgId) {
252
+ const row = await this.db
253
+ .selectFrom("environments")
254
+ .select("provider")
255
+ .where("id", "=", environmentId)
256
+ .$if(orgId !== undefined, (q) => q.where("org_id", "=", orgId))
257
+ .executeTakeFirst();
258
+ return providerForEnvironment(this.providers, row?.provider ?? null);
259
+ }
260
+ /** {@link providerOf}, or `sandbox.unavailable` (503) without it. */
261
+ async require(environmentId, orgId) {
262
+ const p = await this.providerOf(environmentId, orgId);
263
+ if (!p)
264
+ throw sandboxUnavailable();
265
+ return p;
224
266
  }
225
267
  /** Concurrent holders of one environment (`sandbox.maxConcurrentCommands`, read at every use). */
226
268
  get cap() {
@@ -290,7 +332,7 @@ let SandboxService = class SandboxService {
290
332
  * too) are thrown as they are.
291
333
  */
292
334
  async ifRunning(environmentId, instance, fn, signal = new AbortController().signal) {
293
- const p = this.require();
335
+ const p = await this.require(environmentId);
294
336
  const env = environmentId;
295
337
  const deadline = Date.now() + this.config.get().sandbox.lockWaitSeconds * 1000;
296
338
  const token = randomUUID();
@@ -319,7 +361,14 @@ let SandboxService = class SandboxService {
319
361
  try {
320
362
  const impl = tagged(p.impl, lease.token, undefined, () => this.register(env, lease.token));
321
363
  const sig = AbortSignal.any([signal, lease.signal]);
322
- return { kind: "ran", value: await fn(impl, e.handle, sig) };
364
+ try {
365
+ return { kind: "ran", value: await fn(impl, e.handle, sig) };
366
+ }
367
+ catch (err) {
368
+ if (isFileSystemGoneError(err))
369
+ throw await this.filesGone(env, p, e);
370
+ throw err;
371
+ }
323
372
  }
324
373
  finally {
325
374
  await this.leave(env, e);
@@ -353,12 +402,13 @@ let SandboxService = class SandboxService {
353
402
  * - When `fn` throws `SandboxGoneError` (the instance is gone) the instance is rebuilt on the
354
403
  * kept files (or the one another holder rebuilt is taken) and `fn` runs once more with
355
404
  * `restarted: true`; a second `gone` is thrown. Other errors are thrown as they are.
356
- * - Lost files throw `environment.files_lost` (409).
405
+ * - Lost files throw `environment.files_lost` (409), also when `fn` throws
406
+ * `FileSystemGoneError` (the files are then recorded lost, ruling R24).
357
407
  * - When `ctx.holderConversationId` last used another instance (another holder rebuilt it
358
408
  * meanwhile), `info.restarted` is true, so it learns its processes are gone (final review m5).
359
409
  */
360
410
  async withSandbox(ctx, fn, signal) {
361
- const p = this.require();
411
+ const p = await this.require(ctx.environmentId, ctx.orgId);
362
412
  const env = ctx.environmentId;
363
413
  const holder = ctx.holderConversationId;
364
414
  let e = await this.seenBy(env, holder, await this.enter(ctx, p, signal, null));
@@ -367,6 +417,8 @@ let SandboxService = class SandboxService {
367
417
  return await this.runAs(e, ctx, p, fn, signal);
368
418
  }
369
419
  catch (err) {
420
+ if (isFileSystemGoneError(err))
421
+ throw await this.filesGone(env, p, e);
370
422
  if (!isSandboxGoneError(err))
371
423
  throw err;
372
424
  this.log.info("sandbox instance is gone during use; recreating", { environmentId: env });
@@ -374,13 +426,47 @@ let SandboxService = class SandboxService {
374
426
  const prev = e;
375
427
  await this.leave(env, prev);
376
428
  e = await this.seenBy(env, holder, await this.enter(ctx, p, signal, gone));
377
- return await this.runAs({ ...e, info: { fresh: true, restarted: true } }, ctx, p, fn, signal);
429
+ try {
430
+ return await this.runAs({ ...e, info: { fresh: true, restarted: true } }, ctx, p, fn, signal);
431
+ }
432
+ catch (err2) {
433
+ if (isFileSystemGoneError(err2))
434
+ throw await this.filesGone(env, p, e);
435
+ throw err2;
436
+ }
378
437
  }
379
438
  }
380
439
  finally {
381
440
  await this.leave(env, e);
382
441
  }
383
442
  }
443
+ /**
444
+ * Ruling R24: an operation on the held instance `e` found its file system gone. Records
445
+ * `fs_status = 'lost'` while the row still records that instance on its file system (the
446
+ * instance handle stays, so a reset still destroys it), moving the fence on, and returns
447
+ * `environment.files_lost` to throw. Uses then fail until the files are reset (a connected
448
+ * machine: until a new login, which resets the row). A failed write only logs.
449
+ */
450
+ async filesGone(env, p, e) {
451
+ this.log.warn("environment file system is gone at the provider; its files are lost", {
452
+ environmentId: env,
453
+ provider: p.plugin,
454
+ });
455
+ try {
456
+ await this.db
457
+ .updateTable("environment_instances")
458
+ .set((eb) => ({ fs_status: "lost", generation: eb("generation", "+", 1) }))
459
+ .where("environment_id", "=", env)
460
+ .where("fs_status", "=", "ready")
461
+ // `createdAt` is the row's value read back in milliseconds
462
+ .where(sql `date_trunc('milliseconds', created_at)`, "=", new Date(e.createdAt))
463
+ .execute();
464
+ }
465
+ catch (err) {
466
+ this.log.warn("cannot record lost environment files", { environmentId: env, err });
467
+ }
468
+ return environmentFilesLost(p.plugin === DEVICE_PROVIDER);
469
+ }
384
470
  /**
385
471
  * Records that `holder` uses the instance of `e` (`capk:sandbox-seen:<env>:<holder>` =
386
472
  * `<created_at>:<file system id>`, with a TTL of the inactivity destruction plus a day). When the
@@ -415,7 +501,7 @@ let SandboxService = class SandboxService {
415
501
  const sig = AbortSignal.any([signal, e.lease.signal]);
416
502
  const env = ctx.environmentId;
417
503
  const impl = tagged(p.impl, e.lease.token, ctx.actor, () => this.register(env, e.lease.token));
418
- return fn(e.handle, impl, e.info, sig);
504
+ return fn(e.handle, impl, { ...e.info, roots: rootsOf(e.handle) }, sig);
419
505
  }
420
506
  /**
421
507
  * Becomes a holder: loops until it has a shared lease on a running instance, or `sandbox.busy`.
@@ -480,10 +566,14 @@ let SandboxService = class SandboxService {
480
566
  foreign(_ctx) {
481
567
  return environmentNotFound();
482
568
  }
483
- /** Recorded running with the current provider, and not the instance found gone. */
569
+ /**
570
+ * Recorded running with the current provider on files not lost (lost ones go through the
571
+ * exclusive path, which refuses them), and not the instance found gone.
572
+ */
484
573
  usable(row, p, gone) {
485
574
  return (row !== undefined &&
486
575
  row.status === "running" &&
576
+ row.fs_status !== "lost" &&
487
577
  row.provider === p.plugin &&
488
578
  row.handle !== null &&
489
579
  !isInstance(row, gone));
@@ -624,7 +714,8 @@ let SandboxService = class SandboxService {
624
714
  * many it suspended. Called every minute by the `sandbox-suspend-idle` cron.
625
715
  */
626
716
  async suspendIdle(now) {
627
- const p = this.providers.current();
717
+ // cloud only: suspendDue keeps to this provider's rows, so `@device` is never suspended here
718
+ const p = this.providers.cloud();
628
719
  if (!p)
629
720
  return 0;
630
721
  const due = this.suspendDue(now, p.plugin);
@@ -666,31 +757,45 @@ let SandboxService = class SandboxService {
666
757
  * (the instance only; the file system is kept and the next use builds a new instance on it); an
667
758
  * environment that was deleted, or whose org was deleted, loses its instance and its file system
668
759
  * and its row is deleted (as {@link destroyEnvironment} does). Environments with any holder are
669
- * skipped. Returns how many it destroyed. Called hourly by the `sandbox-destroy-inactive` cron.
760
+ * skipped. Inactivity never destroys a `@device` instance; a gone `@device` environment is
761
+ * destroyed like any other while `@device` is registered. Returns how many it destroyed. Called
762
+ * hourly by the `sandbox-destroy-inactive` cron.
670
763
  */
671
764
  async destroyInactive(now) {
672
- const p = this.providers.current();
673
- if (!p)
674
- return 0;
765
+ const p = this.providers.cloud();
766
+ const device = this.providers.forName(DEVICE_PROVIDER) !== undefined;
675
767
  let n = 0;
676
- const gone = (eb) => this.environmentGone(eb);
677
- await inPool(await this.candidates(gone), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
678
- const done = await this.tryLocked(id, async (lease) => {
679
- const row = await this.db
680
- .selectFrom("environment_instances")
681
- .selectAll()
682
- .where("environment_id", "=", id)
683
- .where(gone)
684
- .executeTakeFirst();
685
- if (!row)
686
- return false;
687
- await this.destroyRow(row, p, lease, "environment");
688
- return true;
768
+ // Gone environments (deleted, or of a deleted org) on every provider that is here, `@device`
769
+ // too (its destroy calls never need the machine online); rows whose provider is missing wait.
770
+ const here = (eb) => eb.or([
771
+ ...(p ? [this.cloudRow(eb)] : []),
772
+ ...(device ? [eb("provider", "=", DEVICE_PROVIDER)] : []),
773
+ ]);
774
+ const gone = (eb) => eb.and([here(eb), this.environmentGone(eb)]);
775
+ if (p || device) {
776
+ await inPool(await this.candidates(gone), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
777
+ const done = await this.tryLocked(id, async (lease) => {
778
+ const row = await this.db
779
+ .selectFrom("environment_instances")
780
+ .selectAll()
781
+ .where("environment_id", "=", id)
782
+ .where(gone)
783
+ .executeTakeFirst();
784
+ const owner = row && this.rowProvider(row);
785
+ if (!row || !owner)
786
+ return false;
787
+ await this.destroyRow(row, owner, lease, "environment");
788
+ return true;
789
+ });
790
+ if (done)
791
+ n++;
689
792
  });
690
- if (done)
691
- n++;
692
- });
693
- const idle = this.instanceDue(now);
793
+ }
794
+ // inactivity destroys cloud instances only: `@device` instances are never swept
795
+ if (!p)
796
+ return n;
797
+ const due = this.instanceDue(now);
798
+ const idle = (eb) => eb.and([this.cloudRow(eb), due(eb)]);
694
799
  await inPool(await this.candidates(idle), SWEEP_CONCURRENCY, async ({ environment_id: id }) => {
695
800
  const done = await this.tryLocked(id, async (lease) => {
696
801
  const row = await this.db
@@ -712,9 +817,11 @@ let SandboxService = class SandboxService {
712
817
  /**
713
818
  * Destroys the environment's instance, then its file system, then deletes its instance row
714
819
  * (environment deletion, offboarding); nothing to do without a row. Waits for the exclusive lease
715
- * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without a
716
- * provider it only warns (the destruction sweep finishes a deleted environment later). Files kept
717
- * by another provider that is not loaded cannot be destroyed: it throws and the row stays.
820
+ * (with intent, so running commands finish and new ones wait) up to 10 minutes. Without the
821
+ * environment's provider it throws and the row stays, marked `sweep_failed_at` (the job is
822
+ * retried, and the destruction sweep finishes a deleted environment once the provider is back).
823
+ * Files kept by another provider that is not loaded cannot be destroyed: it throws and the row
824
+ * stays.
718
825
  */
719
826
  async destroyEnvironment(environmentId) {
720
827
  const row = await this.db
@@ -724,10 +831,15 @@ let SandboxService = class SandboxService {
724
831
  .executeTakeFirst();
725
832
  if (!row)
726
833
  return;
727
- const p = this.providers.current();
834
+ const p = await this.providerOf(environmentId);
728
835
  if (!p) {
729
- this.log.warn("cannot destroy environment: no sandbox provider", { environmentId });
730
- return;
836
+ await this.db
837
+ .updateTable("environment_instances")
838
+ .set({ sweep_failed_at: sql `now()` })
839
+ .where("environment_id", "=", environmentId)
840
+ .execute();
841
+ throw new Error(`cannot destroy environment ${environmentId}: its sandbox provider is not there; ` +
842
+ "the destruction sweep retries it");
731
843
  }
732
844
  const lease = await this.leases.acquireExclusive(environmentId, AbortSignal.timeout(EXCLUSIVE_WAIT_MS), undefined, { intent: true });
733
845
  try {
@@ -750,7 +862,7 @@ let SandboxService = class SandboxService {
750
862
  * creates a new one. Nothing to do without a row. Throws `sandbox.unavailable` without a provider.
751
863
  */
752
864
  async resetFileSystem(environmentId) {
753
- const p = this.require();
865
+ const p = await this.require(environmentId);
754
866
  const lease = await this.leases.acquireExclusive(environmentId, AbortSignal.timeout(EXCLUSIVE_WAIT_MS), undefined, { intent: true });
755
867
  try {
756
868
  const row = await this.db
@@ -839,12 +951,6 @@ let SandboxService = class SandboxService {
839
951
  }
840
952
  await this.background.clearKill(cleared.map((r) => r.id));
841
953
  }
842
- require() {
843
- const p = this.providers.current();
844
- if (!p)
845
- throw sandboxUnavailable();
846
- return p;
847
- }
848
954
  /**
849
955
  * A fenced write: only while the lease holds and the row's generation is still the one read
850
956
  * under it (and incremented); otherwise {@link SandboxLockLostError}.
@@ -968,7 +1074,7 @@ let SandboxService = class SandboxService {
968
1074
  // kept running (a stalled sweep) then fails its next write.
969
1075
  await this.fenced(held, id, fence, {});
970
1076
  if (row.fs_status === "lost")
971
- throw environmentFilesLost();
1077
+ throw environmentFilesLost(p.plugin === DEVICE_PROVIDER);
972
1078
  if (row.provider !== null &&
973
1079
  row.provider !== p.plugin &&
974
1080
  (row.fs_status !== "none" || row.handle !== null)) {
@@ -1080,7 +1186,7 @@ let SandboxService = class SandboxService {
1080
1186
  provider: p.plugin,
1081
1187
  });
1082
1188
  await this.fenced(held, id, fence, { fs_status: "lost", status: "none" });
1083
- throw environmentFilesLost();
1189
+ throw environmentFilesLost(p.plugin === DEVICE_PROVIDER);
1084
1190
  }
1085
1191
  let createdAt;
1086
1192
  try {
@@ -1115,7 +1221,7 @@ let SandboxService = class SandboxService {
1115
1221
  * usable; an abort is thrown.
1116
1222
  */
1117
1223
  async dropUnrecordedFiles(id, provider, signal) {
1118
- const old = this.providers.lookup(provider);
1224
+ const old = this.providers.forName(provider)?.impl;
1119
1225
  if (!old) {
1120
1226
  this.log.warn("environment file system may have been made by a provider that is not loaded; it may leak", { environmentId: id, provider });
1121
1227
  return;
@@ -1138,7 +1244,7 @@ let SandboxService = class SandboxService {
1138
1244
  * false; otherwise warns and returns true (the record must be kept).
1139
1245
  */
1140
1246
  async retire(id, provider, handle, signal) {
1141
- const old = this.providers.lookup(provider);
1247
+ const old = this.providers.forName(provider)?.impl;
1142
1248
  if (old) {
1143
1249
  try {
1144
1250
  await old.destroy(handle, AbortSignal.any([signal, AbortSignal.timeout(SWEEP_OP_TIMEOUT_MS)]));
@@ -1175,7 +1281,7 @@ let SandboxService = class SandboxService {
1175
1281
  }
1176
1282
  else if (!row.handle && row.status === "creating") {
1177
1283
  // the provider may have built it already: only the name finds it
1178
- const impl = owner === p.plugin ? p.impl : this.providers.lookup(owner);
1284
+ const impl = owner === p.plugin ? p.impl : this.providers.forName(owner)?.impl;
1179
1285
  await impl?.destroyByName(environmentResourceName(id), signal);
1180
1286
  }
1181
1287
  if (row.prev_handle && row.prev_provider && !("prev_handle" in prev)) {
@@ -1194,7 +1300,7 @@ let SandboxService = class SandboxService {
1194
1300
  async destroyFiles(row, p, signal, lenient = false) {
1195
1301
  const id = row.environment_id;
1196
1302
  const owner = row.provider ?? p.plugin;
1197
- const impl = owner === p.plugin ? p.impl : this.providers.lookup(owner);
1303
+ const impl = owner === p.plugin ? p.impl : this.providers.forName(owner)?.impl;
1198
1304
  if (!impl) {
1199
1305
  if (row.fs_handle === null)
1200
1306
  return;
@@ -1256,6 +1362,17 @@ let SandboxService = class SandboxService {
1256
1362
  eb.or([eb("last_used_at", "<", idleBefore), eb("running_since", "<", runningBefore)]),
1257
1363
  ]);
1258
1364
  }
1365
+ /** The provider of an instance row: an internal one (`@device`) by name, else the cloud one. */
1366
+ rowProvider(row) {
1367
+ return providerForEnvironment(this.providers, row.provider);
1368
+ }
1369
+ /**
1370
+ * A row the idle sweeps may touch: not on a kernel-internal provider (`@device` instances live on
1371
+ * people's machines and are never suspended or destroyed for inactivity).
1372
+ */
1373
+ cloudRow(eb) {
1374
+ return eb.or([eb("provider", "is", null), eb("provider", "not like", "@%")]);
1375
+ }
1259
1376
  /** The environment was deleted, or its org was: the instance and the files go. */
1260
1377
  environmentGone(eb) {
1261
1378
  return eb.or([
@@ -9,8 +9,8 @@ export declare const BASH_DESCRIPTION_MAX_CHARS = 120;
9
9
  export declare const BASH_SPILL_CHARS = 30000;
10
10
  /**
11
11
  * 命令结果 → 给模型的文本。`budgetClamped`:运行的时限把超时收紧到了 `seconds`(Ruling M20),超时说明改为
12
- * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。
12
+ * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。`workspace`: the instance's workspace root.
13
13
  */
14
- export declare function formatExec(r: ExecResult, seconds: number, budgetClamped?: boolean): string;
14
+ export declare function formatExec(r: ExecResult, seconds: number, budgetClamped?: boolean, workspace?: string): string;
15
15
  /** 内置工具 `Bash`:在本对话的沙箱里执行命令。 */
16
16
  export declare function sandboxBashTool(deps: SandboxToolDeps): BuiltinTool;
@@ -1,4 +1,4 @@
1
- import { ToolError } from "@capybara-hq/plugin-sdk";
1
+ import { DEFAULT_SANDBOX_ROOTS, ToolError, } from "@capybara-hq/plugin-sdk";
2
2
  import { backgroundMaxSeconds, LAUNCH_EXEC_TIMEOUT_MS, launchScript, newTaskId, startedText, taskOutputPath, taskSidPath, } from "../../../background/launch.js";
3
3
  import { KILL_SESSION_TIMEOUT_MS, killSession, SID_RE } from "../../../background/session.js";
4
4
  import { clip, display, inSandbox, mapSandboxError, noSandboxError, runBudgetNote, runTimeLeftMs, sandboxBaseTimeoutMs, sandboxConversationOf, unavailableWhenNoSandbox, withNotices, } from "./common.js";
@@ -14,14 +14,14 @@ const SPEC = {
14
14
  name: "Bash",
15
15
  // Display only (the tool row; not in the fingerprint, never sent to the model).
16
16
  keyArgs: ["command"],
17
- description: "Run a shell command (bash) in this conversation's sandbox, an isolated Linux machine. " +
17
+ description: "Run a shell command (bash) in this conversation's sandbox, an isolated machine. " +
18
18
  "Use it to run programs and scripts, install packages, process files and inspect the " +
19
- "filesystem. The working directory is /workspace; files persist between calls in the same " +
19
+ "filesystem. The working directory is the workspace directory; files persist between calls in the same " +
20
20
  "conversation, but background processes may not: when the sandbox is suspended after a period " +
21
- "without use they can be stopped and /tmp cleared, so keep results in files under /workspace. " +
22
- "Files the user attached to the conversation are copied to /workspace/attachments/<conversation>/ " +
23
- "(the result names the paths), " +
24
- "and skill files under /skills (read-only). Each call runs in a fresh shell (no state such as " +
21
+ "without use they can be stopped and the temporary files directory cleared, so keep results in " +
22
+ "files under the workspace directory. Files the user attached to the conversation are copied to " +
23
+ "attachments/<conversation>/ in the workspace directory (the result names the paths), " +
24
+ "and skill files to the skills directory (read-only). Each call runs in a fresh shell (no state such as " +
25
25
  "cd or environment variables carries over). The result has stdout, stderr and the exit code; " +
26
26
  "long output is saved and can be paged with ReadToolOutput. A command that runs longer than " +
27
27
  "timeout milliseconds is stopped. Prefer Read, Write, Edit, Glob and " +
@@ -32,13 +32,13 @@ const SPEC = {
32
32
  "You can use the run_in_background parameter to run the command in the background, which " +
33
33
  "allows you to continue working while the command runs, e.g. a dev server, a long build or a " +
34
34
  "test suite. The call returns at once with a task id and the command's output goes to a file " +
35
- "under /workspace/.capybara/tasks. Read the output with TaskOutput (or Read) and stop the " +
35
+ "under .capybara/tasks in the workspace directory. Read the output with TaskOutput (or Read) and stop the " +
36
36
  "task with TaskStop; you will be told when it finishes. You do not need to use '&' at the end " +
37
37
  "of the command when using this parameter. In the background, timeout defaults to the " +
38
38
  "deployment's background maximum (7200000 by default). A background task ends when its " +
39
39
  "command exits, and processes it left running are stopped. A background task is lost if the " +
40
40
  "sandbox is suspended (it is suspended after it has been running for a while, " +
41
- "sandbox.limits.maxRunningMinutes), so keep its results in files under /workspace.",
41
+ "sandbox.limits.maxRunningMinutes), so keep its results in files under the workspace directory.",
42
42
  inputSchema: {
43
43
  type: "object",
44
44
  properties: {
@@ -71,9 +71,9 @@ const SPEC = {
71
71
  const msFor = (input, maxSeconds) => Math.min(input.timeout ?? BASH_DEFAULT_TIMEOUT_SECONDS * 1000, maxSeconds * 1000);
72
72
  /**
73
73
  * 命令结果 → 给模型的文本。`budgetClamped`:运行的时限把超时收紧到了 `seconds`(Ruling M20),超时说明改为
74
- * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。
74
+ * 指向 `runtime.maxRunSeconds`,而不是让模型加大 timeout。`workspace`: the instance's workspace root.
75
75
  */
76
- export function formatExec(r, seconds, budgetClamped = false) {
76
+ export function formatExec(r, seconds, budgetClamped = false, workspace = DEFAULT_SANDBOX_ROOTS.workspace) {
77
77
  const parts = [];
78
78
  if (r.stdout !== "")
79
79
  parts.push(r.stdout.replace(/\n$/, ""));
@@ -82,10 +82,10 @@ export function formatExec(r, seconds, budgetClamped = false) {
82
82
  if (r.truncated)
83
83
  parts.push(`[Output truncated: only the first ${MAX_OUTPUT_BYTES} bytes were kept.]`);
84
84
  if (r.timedOut && budgetClamped) {
85
- parts.push(`[timedOut: the command did not finish within ${seconds}s and was stopped. ${runBudgetNote(seconds)} The sandbox is still usable; split the work into shorter steps that save progress to files under /workspace, or run long jobs in the background and check on them in a later run.]`);
85
+ parts.push(`[timedOut: the command did not finish within ${seconds}s and was stopped. ${runBudgetNote(seconds)} The sandbox is still usable; split the work into shorter steps that save progress to files under ${workspace}, or run long jobs in the background and check on them in a later run.]`);
86
86
  }
87
87
  else if (r.timedOut) {
88
- parts.push(`[timedOut: the command did not finish within ${seconds}s and was stopped. The sandbox is still usable; pass a larger timeout in milliseconds (up to the deployment maximum), or split the work into shorter steps that save progress to files under /workspace.]`);
88
+ parts.push(`[timedOut: the command did not finish within ${seconds}s and was stopped. The sandbox is still usable; pass a larger timeout in milliseconds (up to the deployment maximum), or split the work into shorter steps that save progress to files under ${workspace}.]`);
89
89
  }
90
90
  else if (r.exitCode === null) {
91
91
  parts.push("[The command was stopped before it finished; its effects may be partial. Check the sandbox state before retrying.]");
@@ -136,8 +136,6 @@ async function runInBackground(deps, ctx, input) {
136
136
  if (ctx.runId === undefined) {
137
137
  throw new ToolError("Background tasks can only be started from an agent run; call Bash without run_in_background.");
138
138
  }
139
- if (!sandbox.available)
140
- throw noSandboxError();
141
139
  const currentInstance = sandbox.currentInstance.bind(sandbox);
142
140
  const cfg = deps.config.get();
143
141
  const maxMs = backgroundMaxSeconds(cfg) * 1000;
@@ -152,6 +150,8 @@ async function runInBackground(deps, ctx, input) {
152
150
  catch (e) {
153
151
  throw mapSandboxError(e);
154
152
  }
153
+ if (!(await sandbox.availableFor(environmentId)))
154
+ throw noSandboxError();
155
155
  const maxTasks = cfg.sandbox.background.maxTasks;
156
156
  const id = newTaskId();
157
157
  const description = input.description ?? clip(input.command, 120);
@@ -185,22 +185,26 @@ async function runInBackground(deps, ctx, input) {
185
185
  await deps.eventLog.publishChanged(c, "background");
186
186
  }
187
187
  };
188
- const large = Math.ceil(Buffer.byteLength(input.command, "utf8") / 3) * 4 > LARGE_COMMAND_BASE64_BYTES
189
- ? " A very large command (over about 96 KB after base64) cannot be started in the background; write it to a script under /workspace and run that instead."
188
+ // the instance's workspace once the call entered it (the default until then)
189
+ let workspace = DEFAULT_SANDBOX_ROOTS.workspace;
190
+ const isLarge = Math.ceil(Buffer.byteLength(input.command, "utf8") / 3) * 4 > LARGE_COMMAND_BASE64_BYTES;
191
+ const largeText = () => isLarge
192
+ ? ` A very large command (over about 96 KB after base64) cannot be started in the background; write it to a script under ${workspace} and run that instead.`
190
193
  : "";
191
194
  // set once the row has its sid: from then on the task runs and is tracked, whatever follows
192
195
  let started = false;
193
196
  let launch;
194
197
  let notices;
195
198
  try {
196
- ({ value: launch, notices } = await inSandbox(deps, ctx, async ({ h, p, signal }) => {
199
+ ({ value: launch, notices } = await inSandbox(deps, ctx, async ({ h, roots, p, signal }) => {
200
+ workspace = roots.workspace;
197
201
  // right after the launch a process may fork while it is killed: killSession repeats
198
202
  const kill = (sid) => killSession(p, h, sid);
199
203
  // the session id the launch wrote before printing it: the launch may have ended (aborted,
200
204
  // timed out, thrown) after starting the command but before its output came back
201
205
  const sidFile = async () => {
202
206
  const f = await p
203
- .readFile(h, `/workspace/${taskSidPath(id)}`, {
207
+ .readFile(h, `${roots.workspace}/${taskSidPath(id)}`, {
204
208
  maxBytes: 64,
205
209
  signal: AbortSignal.timeout(KILL_SESSION_TIMEOUT_MS),
206
210
  })
@@ -212,7 +216,7 @@ async function runInBackground(deps, ctx, input) {
212
216
  try {
213
217
  r = await p.exec(h, {
214
218
  command: launchScript(id, input.command, seconds),
215
- cwd: "/workspace",
219
+ cwd: roots.workspace,
216
220
  timeoutMs: LAUNCH_EXEC_TIMEOUT_MS,
217
221
  maxOutputBytes: LAUNCH_MAX_OUTPUT_BYTES,
218
222
  signal,
@@ -240,6 +244,7 @@ async function runInBackground(deps, ctx, input) {
240
244
  const marked = await background.markStarted(id, {
241
245
  sid,
242
246
  sandboxCreatedAt: instance.createdAt,
247
+ outputPath: `${roots.workspace}/${taskOutputPath(id)}`,
243
248
  });
244
249
  if (!marked)
245
250
  throw new Error("its task record was finished meanwhile");
@@ -275,15 +280,15 @@ async function runInBackground(deps, ctx, input) {
275
280
  const why = e instanceof Error ? e.message : String(e);
276
281
  await background.failLaunch(id, `launch failed: ${why}`).catch(() => undefined);
277
282
  await announce();
278
- if (large === "" || e instanceof ToolError)
283
+ if (!isLarge || e instanceof ToolError)
279
284
  throw e;
280
- throw new ToolError(`The background task was not started: ${clip(why, 500).replace(/\.$/, "")}. Check the sandbox, or call Bash without run_in_background.${large}`);
285
+ throw new ToolError(`The background task was not started: ${clip(why, 500).replace(/\.$/, "")}. Check the sandbox, or call Bash without run_in_background.${largeText()}`);
281
286
  }
282
287
  }
283
288
  if (launch.kind === "not_started") {
284
289
  await background.failLaunch(id, launch.why);
285
290
  await announce();
286
- throw new ToolError(`The background task was not started: ${launch.why}. Check the sandbox, or call Bash without run_in_background.${large}`);
291
+ throw new ToolError(`The background task was not started: ${launch.why}. Check the sandbox, or call Bash without run_in_background.${largeText()}`);
287
292
  }
288
293
  if (launch.kind === "stopped") {
289
294
  await announce();
@@ -303,7 +308,12 @@ async function runInBackground(deps, ctx, input) {
303
308
  : "";
304
309
  const shown = clip(input.command, 120);
305
310
  return {
306
- content: [{ type: "text", text: withNotices(notices, startedText(id, description) + lowered) }],
311
+ content: [
312
+ {
313
+ type: "text",
314
+ text: withNotices(notices, startedText(id, description, workspace) + lowered),
315
+ },
316
+ ],
307
317
  isError: false,
308
318
  view: { kind: "command", description, exitCode: null, timedOut: false },
309
319
  display: display(`后台运行命令:${shown}`, `Started in the background: ${shown}`),
@@ -338,12 +348,14 @@ export function sandboxBashTool(deps) {
338
348
  // The run's deadline makes the command time out itself first (Ruling M20): counted after the
339
349
  // sandbox is obtained, so the time spent waiting for the lock and resuming is deducted
340
350
  let ms = askedMs;
341
- const { value: r, notices } = await inSandbox(deps, ctx, ({ h, p, signal }) => {
351
+ let workspace = DEFAULT_SANDBOX_ROOTS.workspace;
352
+ const { value: r, notices } = await inSandbox(deps, ctx, ({ h, roots, p, signal }) => {
342
353
  const left = Math.floor(runTimeLeftMs(ctx.runDeadline) / 1000) * 1000;
343
354
  ms = Math.max(1000, Math.min(askedMs, left));
355
+ workspace = roots.workspace;
344
356
  return p.exec(h, {
345
357
  command,
346
- cwd: "/workspace",
358
+ cwd: roots.workspace,
347
359
  timeoutMs: ms,
348
360
  maxOutputBytes: MAX_OUTPUT_BYTES,
349
361
  signal,
@@ -355,7 +367,7 @@ export function sandboxBashTool(deps) {
355
367
  const lowered = requested !== undefined && requested > maxMs
356
368
  ? `\n[timeout ${requested} ms is over the deployment maximum; it was lowered to ${maxMs} ms.]`
357
369
  : "";
358
- const text = formatExec(r, seconds, clamped) + lowered;
370
+ const text = formatExec(r, seconds, clamped, workspace) + lowered;
359
371
  return {
360
372
  content: [{ type: "text", text: withNotices(notices, text) }],
361
373
  isError: r.timedOut || r.exitCode === null,