@capybara-hq/kernel 0.1.0-rc.7 → 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 (118) 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/identity/me.controller.d.ts +1 -3
  67. package/dist/identity/me.controller.js +1 -9
  68. package/dist/identity/me.schemas.d.ts +14 -0
  69. package/dist/identity/me.schemas.js +17 -0
  70. package/dist/infra/config/kernel-config.service.js +2 -0
  71. package/dist/infra/throttler/named-throttlers.d.ts +5 -0
  72. package/dist/infra/throttler/named-throttlers.js +38 -0
  73. package/dist/kernel.module.js +3 -0
  74. package/dist/lifecycle/kernel-lifecycle.js +5 -0
  75. package/dist/platform/exclusions.js +33 -10
  76. package/dist/platform/operation.js +1 -1
  77. package/dist/platform/ops/environments.ops.js +21 -3
  78. package/dist/platform/ops/platform.ops.d.ts +7 -2
  79. package/dist/platform/ops/platform.ops.js +50 -3
  80. package/dist/platform/route-schemas.js +3 -0
  81. package/dist/roles/gateway-role.d.ts +14 -3
  82. package/dist/roles/gateway-role.js +25 -5
  83. package/dist/roles/gateway.module.d.ts +2 -1
  84. package/dist/roles/gateway.module.js +6 -3
  85. package/dist/runtime/context/build.d.ts +2 -0
  86. package/dist/runtime/context/build.js +14 -0
  87. package/dist/runtime/context/compaction.js +1 -0
  88. package/dist/runtime/context/environment-paths.d.ts +40 -0
  89. package/dist/runtime/context/environment-paths.js +48 -0
  90. package/dist/runtime/model-call.js +2 -0
  91. package/dist/sandbox/providers.d.ts +29 -7
  92. package/dist/sandbox/providers.js +43 -8
  93. package/dist/sandbox/sandbox.service.d.ts +70 -18
  94. package/dist/sandbox/sandbox.service.js +176 -59
  95. package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
  96. package/dist/tools/builtins/sandbox/bash.js +39 -27
  97. package/dist/tools/builtins/sandbox/common.d.ts +60 -25
  98. package/dist/tools/builtins/sandbox/common.js +121 -30
  99. package/dist/tools/builtins/sandbox/deliver.js +9 -6
  100. package/dist/tools/builtins/sandbox/edit.js +40 -37
  101. package/dist/tools/builtins/sandbox/glob.js +13 -8
  102. package/dist/tools/builtins/sandbox/grep.js +10 -6
  103. package/dist/tools/builtins/sandbox/index.d.ts +6 -4
  104. package/dist/tools/builtins/sandbox/index.js +6 -4
  105. package/dist/tools/builtins/sandbox/read.js +9 -6
  106. package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
  107. package/dist/tools/builtins/sandbox/task-output.js +14 -10
  108. package/dist/tools/builtins/sandbox/task-stop.js +3 -3
  109. package/dist/tools/builtins/sandbox/write.js +33 -30
  110. package/dist/tools/builtins/skill-load.d.ts +8 -5
  111. package/dist/tools/builtins/skill-load.js +21 -15
  112. package/dist/tools/builtins/skill-write.js +2 -2
  113. package/dist/tools/pipeline.js +2 -0
  114. package/dist/tools/registry.d.ts +21 -4
  115. package/dist/tools/registry.js +10 -2
  116. package/package.json +6 -3
  117. package/src/migrations/0093_connected_environments.sql +37 -0
  118. package/src/migrations/0094_environment_paths_idx.sql +7 -0
@@ -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,
@@ -1,4 +1,4 @@
1
- import { type SandboxHandle, ToolError } from "@capybara-hq/plugin-sdk";
1
+ import { type SandboxHandle, type SandboxRoots, ToolError } from "@capybara-hq/plugin-sdk";
2
2
  import type { BackgroundTaskFinisher } from "../../../background/finish.js";
3
3
  import type { BackgroundTaskStopper } from "../../../background/stop.js";
4
4
  import type { BackgroundTaskStore } from "../../../background/store.js";
@@ -9,7 +9,7 @@ import { type ConversationEnvironments } from "../../../environments/conversatio
9
9
  import type { KernelConfigService } from "../../../infra/config/kernel-config.service.js";
10
10
  import { type HeldProvider, type SandboxService } from "../../../sandbox/sandbox.service.js";
11
11
  import type { StorageService } from "../../../storage/storage.service.js";
12
- import type { BuiltinCallContext } from "../../registry.js";
12
+ import type { BuiltinCallContext, ToolListingScope } from "../../registry.js";
13
13
  /** 沙箱工具的依赖(都按调用时取,多副本下无进程内状态)。 */
14
14
  export interface SandboxToolDeps {
15
15
  /**
@@ -17,9 +17,13 @@ export interface SandboxToolDeps {
17
17
  * `currentInstance` too: a background `Bash` launch records the instance it started in; and
18
18
  * `ifRunning`, which `TaskOutput` reads a task through.
19
19
  */
20
- sandbox: Pick<SandboxService, "available" | "withSandbox"> & Partial<Pick<SandboxService, "pathLease" | "currentInstance" | "ifRunning">>;
21
- /** The environment a call enters: the root conversation's, checked for its owner every call. */
22
- environments: Pick<ConversationEnvironments, "forTool">;
20
+ sandbox: Pick<SandboxService, "cloudAvailable" | "availableFor" | "withSandbox"> & Partial<Pick<SandboxService, "pathLease" | "currentInstance" | "ifRunning">>;
21
+ /**
22
+ * The environment a call enters: the root conversation's, checked for its owner every call.
23
+ * `usableNow` (absent in test stubs: only the cloud provider counts) tells a listing whether a
24
+ * conversation's environment can run without a cloud provider (ruling R8).
25
+ */
26
+ environments: Pick<ConversationEnvironments, "forTool"> & Partial<Pick<ConversationEnvironments, "usableNow">>;
23
27
  /** Background `Bash` tasks; absent in test stubs, where `run_in_background` is refused. */
24
28
  background?: Pick<BackgroundTaskStore, "reserve" | "markStarted" | "failLaunch" | "finishStoppedLaunch" | "releaseStop" | "get" | "advanceOffset">;
25
29
  /**
@@ -32,8 +36,11 @@ export interface SandboxToolDeps {
32
36
  };
33
37
  attachments: Pick<AttachmentsService, "listForConversation" | "openForConversation">;
34
38
  storage: Pick<StorageService, "kernel" | "deployment" | "available">;
35
- /** `publishChanged`: `Deliver` announces `changed: files` (UI refresh B). */
36
- eventLog: Pick<EventLog, "append" | "latestFileState"> & Partial<Pick<EventLog, "publishChanged">>;
39
+ /**
40
+ * `publishChanged`: `Deliver` announces `changed: files` (UI refresh B). `latestOfType` reads the
41
+ * conversation's latest `environment_paths` (absent in test stubs, which record none).
42
+ */
43
+ eventLog: Pick<EventLog, "append" | "latestFileState"> & Partial<Pick<EventLog, "publishChanged" | "latestOfType">>;
37
44
  config: Pick<KernelConfigService, "get">;
38
45
  }
39
46
  /**
@@ -45,29 +52,37 @@ export interface SandboxSession {
45
52
  /** The environment the call runs in (path leases are taken on it). */
46
53
  environmentId: string;
47
54
  h: SandboxHandle;
55
+ /** The instance's roots: every path the call builds or checks is under one of them. */
56
+ roots: SandboxRoots;
48
57
  p: HeldProvider;
49
58
  signal: AbortSignal;
50
59
  }
51
- /** 没有沙箱提供方时「工具不可用」的原因(进提醒与调用结果)。 */
52
- /** This conversation's first use of its environment. */
53
- export declare const SANDBOX_STARTED_NOTICE = "Environment started (first use in this conversation); the working directory is /workspace, shared with the environment's other conversations.";
60
+ /** This conversation's first use of its environment (in the instance's workspace). */
61
+ export declare const sandboxStartedNotice: (roots: SandboxRoots) => string;
62
+ /** {@link sandboxStartedNotice} of an instance with the default roots. */
63
+ export declare const SANDBOX_STARTED_NOTICE: string;
54
64
  /** A new instance came up on the environment's kept files. */
55
- export declare const SANDBOX_RESTARTED_NOTICE = "The environment's instance was restarted; files are kept, running processes and /tmp are gone.";
65
+ export declare const SANDBOX_RESTARTED_NOTICE = "The environment's instance was restarted; files are kept, running processes are gone and temporary files may be gone.";
56
66
  /** 没有沙箱提供方时「工具不可用」的原因(进提醒,内核在后面补句号):与调用时的错误同一句话。 */
57
67
  export declare const SANDBOX_UNAVAILABLE_REASON: string;
58
68
  /** The error of a sandbox call in a deployment without a sandbox provider. */
59
69
  export declare function noSandboxError(): ToolError;
60
70
  /** The directory of attachments in the environment (each conversation has a subdirectory). */
61
- export declare const ATTACHMENTS_ROOT = "/workspace/attachments";
71
+ export declare const attachmentsRoot: (roots: SandboxRoots) => string;
72
+ /** {@link attachmentsRoot} of an instance with the default roots. */
73
+ export declare const ATTACHMENTS_ROOT: string;
62
74
  /**
63
75
  * One conversation's attachment directory: the first 12 hex digits of the root conversation's
64
76
  * UUID, so conversations sharing an environment do not overwrite each other's files.
65
77
  */
66
- export declare const attachmentsDir: (rootConversationId: string) => string;
78
+ export declare const attachmentsDir: (roots: SandboxRoots, rootConversationId: string) => string;
67
79
  /** The conversation's marker of synced attachments: `{ <attachment id>: <file name> }`. */
68
- export declare const syncMarkerPath: (rootConversationId: string) => string;
69
- /** Whether a path, or a Grep output line starting with one, belongs to a sync marker (hidden from Glob / Grep). */
70
- export declare const isSyncMarkerEntry: (pathOrLine: string) => boolean;
80
+ export declare const syncMarkerPath: (roots: SandboxRoots, rootConversationId: string) => string;
81
+ /**
82
+ * Whether a path, or a Grep output line starting with one, belongs to a sync marker of the
83
+ * instance with `roots` (hidden from Glob / Grep). Build it once per call and test every entry.
84
+ */
85
+ export declare function syncMarkerMatcher(roots?: SandboxRoots): (pathOrLine: string) => boolean;
71
86
  /** 附件文件名的上限(UTF-8 字节,常见文件系统单个文件名上限 255 字节,留出 `-N` 后缀的余量)。 */
72
87
  export declare const ATTACHMENT_NAME_MAX_BYTES = 200;
73
88
  /**
@@ -111,10 +126,20 @@ export declare function display(zh: string, en: string): {
111
126
  export declare function clip(s: string, max: number): string;
112
127
  /** 结果正文前加上沙箱状态说明。 */
113
128
  export declare function withNotices(notices: readonly string[], text: string): string;
114
- /** 校验沙箱内路径(`validateSandboxPath`);不合法抛给模型可执行的错误。 */
115
- export declare function checkPath(path: string, field?: string): string;
116
- /** A writing tool's `file_path`: `/skills` is the read-only copy of the skill files. */
117
- export declare function checkWritable(path: string): string;
129
+ /**
130
+ * The root-independent checks of a path (`validateSandboxPathShape`: non-empty, absolute, no
131
+ * control characters, no empty or `..` segments), run before the call enters its environment, so
132
+ * a malformed path never creates or resumes an instance; root membership is checked inside
133
+ * ({@link checkPath}).
134
+ */
135
+ export declare function checkPathShape(path: string, field?: string): void;
136
+ /**
137
+ * Checks a path inside the instance with `roots` (`validateSandboxPath`: absolute, under one of its
138
+ * roots); an invalid one throws an error the model can act on, naming the instance's roots.
139
+ */
140
+ export declare function checkPath(path: string, roots: SandboxRoots, field?: string): string;
141
+ /** A writing tool's `file_path`: the skills root is the read-only copy of the skill files. */
142
+ export declare function checkWritable(path: string, roots: SandboxRoots): string;
118
143
  /** POSIX shell 单引号转义。 */
119
144
  export declare function shellQuote(s: string): string;
120
145
  /**
@@ -135,7 +160,8 @@ export declare function mapSandboxError(e: unknown): unknown;
135
160
  * rebuilt on the kept files as needed, every `exec` carrying the call's actor). Attachments are
136
161
  * synced first. Returns the value and the notices to put before the result (first use, a new
137
162
  * instance, newly copied attachments). When `withSandbox` rebuilds a gone instance and calls
138
- * `fn` again, the notices are the last call's.
163
+ * `fn` again, the notices are the last call's. The instance's roots are recorded for the model
164
+ * on the conversation's first call and whenever they change ({@link recordEnvironmentPaths}).
139
165
  */
140
166
  export declare function inSandbox<T>(deps: SandboxToolDeps, ctx: BuiltinCallContext, fn: (s: SandboxSession) => Promise<T>): Promise<{
141
167
  value: T;
@@ -151,9 +177,18 @@ export declare const sandboxConversationOf: (ctx: BuiltinCallContext) => string;
151
177
  * stubs) it just runs `fn` with `signal`.
152
178
  */
153
179
  export declare function withPathLease<T>(deps: Pick<SandboxToolDeps, "sandbox">, s: Pick<SandboxSession, "environmentId">, path: string, signal: AbortSignal, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
154
- /** 本部署是否有沙箱:没有时返回「不可用」原因(注册表据此把沙箱工具报为不可用)。 */
155
- export declare const unavailableWhenNoSandbox: (deps: Pick<SandboxToolDeps, "sandbox">) => (_ctx?: {
156
- orgId: string;
157
- }) => string | null;
180
+ /**
181
+ * Whether a sandbox tool call of the conversation tree rooted at `conversationId` can run: a
182
+ * cloud provider is loaded, or (ruling R8) the conversation's environment runs on a registered
183
+ * kernel provider (`@device`). Without a conversation only the cloud provider counts.
184
+ */
185
+ export declare function sandboxUsable(deps: Pick<SandboxToolDeps, "sandbox" | "environments">, orgId: string, conversationId: string | undefined): Promise<boolean>;
186
+ /**
187
+ * The reason the registry reports the sandbox tools unavailable with, or null when they can run
188
+ * ({@link sandboxUsable}; the listing passes the root conversation of the run). An eval replay
189
+ * lists them as available (`replay`: its calls are never executed). `memo` (one per listing)
190
+ * makes every sandbox tool of a listing share one answer.
191
+ */
192
+ export declare const unavailableWhenNoSandbox: (deps: Pick<SandboxToolDeps, "sandbox" | "environments">) => (ctx: ToolListingScope) => Promise<string | null>;
158
193
  /** 读出的字节是否像二进制(开头 8 KB 里有 NUL)。 */
159
194
  export declare function looksBinary(data: Uint8Array): boolean;
@@ -1,14 +1,16 @@
1
- import { isSandboxGoneError, ToolError, validateSandboxPath, } from "@capybara-hq/plugin-sdk";
1
+ import { DEFAULT_SANDBOX_ROOTS, DeviceProtocolError, isFileSystemGoneError, isSandboxGoneError, ToolError, validateSandboxPath, validateSandboxPathShape, } from "@capybara-hq/plugin-sdk";
2
2
  import { ANONYMOUS_NO_ENVIRONMENT, } from "../../../environments/conversation-environments.js";
3
3
  import { ApiError } from "../../../errors.js";
4
+ import { environmentPathsChange } from "../../../runtime/context/environment-paths.js";
4
5
  import { SandboxPathBusyError } from "../../../sandbox/leases.js";
5
6
  import { SandboxLockLostError, } from "../../../sandbox/sandbox.service.js";
6
7
  // ---- 模型可见文案(英文) ----
7
- /** 没有沙箱提供方时「工具不可用」的原因(进提醒与调用结果)。 */
8
- /** This conversation's first use of its environment. */
9
- export const SANDBOX_STARTED_NOTICE = "Environment started (first use in this conversation); the working directory is /workspace, shared with the environment's other conversations.";
8
+ /** This conversation's first use of its environment (in the instance's workspace). */
9
+ export const sandboxStartedNotice = (roots) => `Environment started (first use in this conversation); the working directory is ${roots.workspace}, shared with the environment's other conversations.`;
10
+ /** {@link sandboxStartedNotice} of an instance with the default roots. */
11
+ export const SANDBOX_STARTED_NOTICE = sandboxStartedNotice(DEFAULT_SANDBOX_ROOTS);
10
12
  /** A new instance came up on the environment's kept files. */
11
- export const SANDBOX_RESTARTED_NOTICE = "The environment's instance was restarted; files are kept, running processes and /tmp are gone.";
13
+ export const SANDBOX_RESTARTED_NOTICE = "The environment's instance was restarted; files are kept, running processes are gone and temporary files may be gone.";
12
14
  const BUSY_TEXT = "The environment is busy: all its command slots are taken by commands of this or another conversation sharing it; wait for one to finish and retry.";
13
15
  const NO_SANDBOX_TEXT = "No sandbox is configured in this deployment; ask an administrator to install a sandbox plugin, or do the task without the sandbox.";
14
16
  /** 没有沙箱提供方时「工具不可用」的原因(进提醒,内核在后面补句号):与调用时的错误同一句话。 */
@@ -23,20 +25,34 @@ const ENV_NOT_FOUND_TEXT = "This conversation's environment was deleted or its o
23
25
  const ENV_FORBIDDEN_TEXT = "This conversation's owner may no longer use its environment; ask the user to start a new conversation in an environment they can use.";
24
26
  const ENV_UNAVAILABLE_TEXT = "This conversation's environment is kept by a sandbox provider that is not active in this deployment; ask the user to start a new conversation in another environment, or an administrator to re-enable that provider.";
25
27
  const ENV_FILES_LOST_TEXT = "This environment's files are gone. Ask the user to reset its files on the environments page, then retry.";
28
+ const DEVICE_FILES_LOST_TEXT = "This environment's directory is gone from its connected machine. Ask the person to run capybara-device logout, then capybara-device login into this environment, on that machine, then retry.";
29
+ const FILES_GONE_TEXT = "This environment's files are gone. Ask the user to reset its files on the environments page (for a connected machine: to run capybara-device logout, then capybara-device login, on it), then retry.";
30
+ const DEVICE_OFFLINE_TEXT = "The connected machine is offline. Ask the person to start capybara-device on it.";
31
+ const DEVICE_OUTDATED_TEXT = "The connected machine runs an outdated capybara-device. Ask the person to update it on that machine and start it again.";
32
+ const DEVICE_REVOKED_TEXT = "The connected machine of this environment was disconnected. Ask the person to run capybara-device logout, then capybara-device login on it, or to start a new conversation in another environment.";
33
+ const deviceProtocolText = (detail) => `The connected machine sent an answer the platform cannot read (${detail}). Ask the person to update capybara-device on that machine, then retry.`;
26
34
  const LOCK_LOST_TEXT = "The sandbox operation was interrupted because the sandbox lock was lost; check the sandbox state and retry.";
27
35
  /** The directory of attachments in the environment (each conversation has a subdirectory). */
28
- export const ATTACHMENTS_ROOT = "/workspace/attachments";
36
+ export const attachmentsRoot = (roots) => `${roots.workspace}/attachments`;
37
+ /** {@link attachmentsRoot} of an instance with the default roots. */
38
+ export const ATTACHMENTS_ROOT = attachmentsRoot(DEFAULT_SANDBOX_ROOTS);
29
39
  /**
30
40
  * One conversation's attachment directory: the first 12 hex digits of the root conversation's
31
41
  * UUID, so conversations sharing an environment do not overwrite each other's files.
32
42
  */
33
- export const attachmentsDir = (rootConversationId) => `${ATTACHMENTS_ROOT}/${rootConversationId.replaceAll("-", "").slice(0, 12)}`;
43
+ export const attachmentsDir = (roots, rootConversationId) => `${attachmentsRoot(roots)}/${rootConversationId.replaceAll("-", "").slice(0, 12)}`;
34
44
  /** The conversation's marker of synced attachments: `{ <attachment id>: <file name> }`. */
35
- export const syncMarkerPath = (rootConversationId) => `${attachmentsDir(rootConversationId)}/.capybara-sync.json`;
45
+ export const syncMarkerPath = (roots, rootConversationId) => `${attachmentsDir(roots, rootConversationId)}/.capybara-sync.json`;
36
46
  const SYNC_MARKER_NAME = ".capybara-sync.json";
37
- const SYNC_MARKER_RE = new RegExp(`^${ATTACHMENTS_ROOT}/[^/]+/${SYNC_MARKER_NAME.replaceAll(".", "\\.")}(?=$|[:-])`);
38
- /** Whether a path, or a Grep output line starting with one, belongs to a sync marker (hidden from Glob / Grep). */
39
- export const isSyncMarkerEntry = (pathOrLine) => SYNC_MARKER_RE.test(pathOrLine);
47
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48
+ /**
49
+ * Whether a path, or a Grep output line starting with one, belongs to a sync marker of the
50
+ * instance with `roots` (hidden from Glob / Grep). Build it once per call and test every entry.
51
+ */
52
+ export function syncMarkerMatcher(roots = DEFAULT_SANDBOX_ROOTS) {
53
+ const re = new RegExp(`^${escapeRe(attachmentsRoot(roots))}/[^/]+/${escapeRe(SYNC_MARKER_NAME)}(?=$|[:-])`);
54
+ return (pathOrLine) => re.test(pathOrLine);
55
+ }
40
56
  const MARKER_MAX_BYTES = 1024 * 1024;
41
57
  /** 附件文件名的上限(UTF-8 字节,常见文件系统单个文件名上限 255 字节,留出 `-N` 后缀的余量)。 */
42
58
  export const ATTACHMENT_NAME_MAX_BYTES = 200;
@@ -89,19 +105,34 @@ export function clip(s, max) {
89
105
  export function withNotices(notices, text) {
90
106
  return [...notices, text].join("\n\n");
91
107
  }
92
- /** 校验沙箱内路径(`validateSandboxPath`);不合法抛给模型可执行的错误。 */
93
- export function checkPath(path, field = "path") {
94
- const errors = validateSandboxPath(path);
108
+ /**
109
+ * The root-independent checks of a path (`validateSandboxPathShape`: non-empty, absolute, no
110
+ * control characters, no empty or `..` segments), run before the call enters its environment, so
111
+ * a malformed path never creates or resumes an instance; root membership is checked inside
112
+ * ({@link checkPath}).
113
+ */
114
+ export function checkPathShape(path, field = "path") {
115
+ const errors = validateSandboxPathShape(path);
95
116
  if (errors.length > 0) {
96
- throw new ToolError(`Invalid ${field} "${clip(path, 200)}": ${errors.join("; ")}. Pass an absolute path under /workspace, /skills or /tmp.`);
117
+ throw new ToolError(`Invalid ${field} "${clip(path, 200)}": ${errors.join("; ")}. Pass an absolute path under the workspace, skills or temporary files directory.`);
118
+ }
119
+ }
120
+ /**
121
+ * Checks a path inside the instance with `roots` (`validateSandboxPath`: absolute, under one of its
122
+ * roots); an invalid one throws an error the model can act on, naming the instance's roots.
123
+ */
124
+ export function checkPath(path, roots, field = "path") {
125
+ const errors = validateSandboxPath(path, roots);
126
+ if (errors.length > 0) {
127
+ throw new ToolError(`Invalid ${field} "${clip(path, 200)}": ${errors.join("; ")}. Pass an absolute path under ${roots.workspace}, ${roots.skills} or ${roots.tmp}.`);
97
128
  }
98
129
  return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
99
130
  }
100
- /** A writing tool's `file_path`: `/skills` is the read-only copy of the skill files. */
101
- export function checkWritable(path) {
102
- const p = checkPath(path, "file_path");
103
- if (p === "/skills" || p.startsWith("/skills/")) {
104
- throw new ToolError(`Path ${clip(p, 200)} is read-only (/skills holds skill files); pass a path under /workspace or /tmp instead.`);
131
+ /** A writing tool's `file_path`: the skills root is the read-only copy of the skill files. */
132
+ export function checkWritable(path, roots) {
133
+ const p = checkPath(path, roots, "file_path");
134
+ if (p === roots.skills || p.startsWith(`${roots.skills}/`)) {
135
+ throw new ToolError(`Path ${clip(p, 200)} is read-only (${roots.skills} holds skill files); pass a path under ${roots.workspace} or ${roots.tmp} instead.`);
105
136
  }
106
137
  return p;
107
138
  }
@@ -186,7 +217,7 @@ function copyFailureReason(e) {
186
217
  return "a temporary error occurred; it will be retried on the next sandbox call";
187
218
  }
188
219
  /**
189
- * 把本对话已 ready 的附件复制到 `/workspace/attachments/<conversation>/<name>`({@link attachmentsDir}),
220
+ * 把本对话已 ready 的附件复制到 `<workspace>/attachments/<conversation>/<name>`({@link attachmentsDir}),
190
221
  * 标记文件记下已复制的(不重复复制)。对象已不在的附件跳过(下次再试)。
191
222
  * 每复制一个就更新标记文件:中途超时 / 中止,下次从停下的地方继续。
192
223
  * 单个附件失败(存储故障等)只跳过它并给出说明,不让工具调用失败;signal 已中止或实例已不在时照常抛出。
@@ -203,11 +234,11 @@ async function syncAttachments(deps, ctx, s) {
203
234
  if (list.every((a) => Object.hasOwn(marker, a.id)))
204
235
  return none;
205
236
  // the sync marker is read, changed and written: one holder at a time
206
- return withPathLease(deps, s, syncMarkerPath(conv), s.signal, (signal) => copyAttachments(deps, ctx, { ...s, signal }, conv, list));
237
+ return withPathLease(deps, s, syncMarkerPath(s.roots, conv), s.signal, (signal) => copyAttachments(deps, ctx, { ...s, signal }, conv, list));
207
238
  }
208
239
  /** The sync marker's entries (`{}` when it is missing, too large or unreadable). */
209
240
  async function readSyncMarker(s, conv) {
210
- const m = await s.p.readFile(s.h, syncMarkerPath(conv), {
241
+ const m = await s.p.readFile(s.h, syncMarkerPath(s.roots, conv), {
211
242
  maxBytes: MARKER_MAX_BYTES,
212
243
  signal: s.signal,
213
244
  });
@@ -224,7 +255,7 @@ async function copyAttachments(deps, ctx, s, conv, list) {
224
255
  const failures = [];
225
256
  for (const a of pending) {
226
257
  const name = names.get(a.id);
227
- const path = `${attachmentsDir(conv)}/${name}`;
258
+ const path = `${attachmentsDir(s.roots, conv)}/${name}`;
228
259
  try {
229
260
  const opened = await deps.attachments.openForConversation(ctx.orgId, conv, a.id);
230
261
  if (!opened)
@@ -244,7 +275,7 @@ async function copyAttachments(deps, ctx, s, conv, list) {
244
275
  copied.push(path);
245
276
  const next = { ...marker, [a.id]: name };
246
277
  try {
247
- await s.p.writeFile(s.h, syncMarkerPath(conv), utf8.encode(JSON.stringify(next)), s.signal);
278
+ await s.p.writeFile(s.h, syncMarkerPath(s.roots, conv), utf8.encode(JSON.stringify(next)), s.signal);
248
279
  marker = next;
249
280
  }
250
281
  catch (e) {
@@ -265,15 +296,31 @@ export function mapSandboxError(e) {
265
296
  return new ToolError(ANONYMOUS_TEXT);
266
297
  return new ToolError(NO_SANDBOX_TEXT);
267
298
  }
299
+ const detail = e.details?.[0];
268
300
  if (e.code === "environment.not_found")
269
301
  return new ToolError(ENV_NOT_FOUND_TEXT);
302
+ if (e.code === "environment.forbidden" && detail?.reason === "machine_revoked") {
303
+ return new ToolError(DEVICE_REVOKED_TEXT);
304
+ }
270
305
  if (e.code === "environment.forbidden")
271
306
  return new ToolError(ENV_FORBIDDEN_TEXT);
272
307
  if (e.code === "environment.unavailable")
273
308
  return new ToolError(ENV_UNAVAILABLE_TEXT);
309
+ if (e.code === "environment.files_lost" && detail?.source === "device") {
310
+ return new ToolError(DEVICE_FILES_LOST_TEXT);
311
+ }
274
312
  if (e.code === "environment.files_lost")
275
313
  return new ToolError(ENV_FILES_LOST_TEXT);
314
+ if (e.code === "environment.offline")
315
+ return new ToolError(DEVICE_OFFLINE_TEXT);
316
+ if (e.code === "environment.program_outdated")
317
+ return new ToolError(DEVICE_OUTDATED_TEXT);
276
318
  }
319
+ // an operation outside withSandbox's own mapping (it records lost files itself)
320
+ if (isFileSystemGoneError(e))
321
+ return new ToolError(FILES_GONE_TEXT);
322
+ if (e instanceof DeviceProtocolError)
323
+ return new ToolError(deviceProtocolText(e.message));
277
324
  if (e instanceof SandboxLockLostError)
278
325
  return new ToolError(LOCK_LOST_TEXT);
279
326
  if (e instanceof SandboxPathBusyError)
@@ -287,7 +334,8 @@ export function mapSandboxError(e) {
287
334
  * rebuilt on the kept files as needed, every `exec` carrying the call's actor). Attachments are
288
335
  * synced first. Returns the value and the notices to put before the result (first use, a new
289
336
  * instance, newly copied attachments). When `withSandbox` rebuilds a gone instance and calls
290
- * `fn` again, the notices are the last call's.
337
+ * `fn` again, the notices are the last call's. The instance's roots are recorded for the model
338
+ * on the conversation's first call and whenever they change ({@link recordEnvironmentPaths}).
291
339
  */
292
340
  export async function inSandbox(deps, ctx, fn) {
293
341
  try {
@@ -299,12 +347,13 @@ export async function inSandbox(deps, ctx, fn) {
299
347
  holderConversationId: ctx.conversationId,
300
348
  actor: { accountId: ctx.accountId, conversationId: ctx.conversationId },
301
349
  }, async (h, p, info, signal) => {
302
- const s = { environmentId, h, p, signal };
350
+ const s = { environmentId, h, roots: info.roots, p, signal };
351
+ await recordEnvironmentPaths(deps, ctx, info.roots);
303
352
  const notices = [];
304
353
  if (info.restarted)
305
354
  notices.push(SANDBOX_RESTARTED_NOTICE);
306
355
  else if (info.fresh)
307
- notices.push(SANDBOX_STARTED_NOTICE);
356
+ notices.push(sandboxStartedNotice(info.roots));
308
357
  const { copied, failures } = await syncAttachments(deps, ctx, s);
309
358
  if (copied.length > 0) {
310
359
  notices.push(`Attachments copied into the sandbox: ${copied.join(", ")}.`);
@@ -317,6 +366,21 @@ export async function inSandbox(deps, ctx, fn) {
317
366
  throw mapSandboxError(e);
318
367
  }
319
368
  }
369
+ /**
370
+ * Environments spec §13.1, ruling R22: the calling conversation's log (the log its model reads; a
371
+ * sub-agent's own too) gets an `environment_paths` event on its first sandbox call, the default
372
+ * roots included, and again whenever the held instance's roots differ from the latest recorded
373
+ * ones; always before the call's result. One indexed read per call (migration 0094); parallel
374
+ * calls that saw the same latest event write one event (its idempotency key).
375
+ */
376
+ async function recordEnvironmentPaths(deps, ctx, roots) {
377
+ if (!deps.eventLog.latestOfType)
378
+ return;
379
+ const latest = await deps.eventLog.latestOfType(ctx.conversationId, "environment_paths");
380
+ const change = environmentPathsChange(latest, roots, ctx.runId);
381
+ if (change)
382
+ await deps.eventLog.append(ctx.conversationId, [change]);
383
+ }
320
384
  /** The conversation whose environment a call uses (the root conversation's for a sub-agent). */
321
385
  export const sandboxConversationOf = (ctx) => ctx.sandboxConversationId ?? ctx.conversationId;
322
386
  /**
@@ -337,8 +401,35 @@ export async function withPathLease(deps, s, path, signal, fn) {
337
401
  await lease.release().catch(() => { });
338
402
  }
339
403
  }
340
- /** 本部署是否有沙箱:没有时返回「不可用」原因(注册表据此把沙箱工具报为不可用)。 */
341
- export const unavailableWhenNoSandbox = (deps) => (_ctx) => deps.sandbox.available ? null : SANDBOX_UNAVAILABLE_REASON;
404
+ /**
405
+ * Whether a sandbox tool call of the conversation tree rooted at `conversationId` can run: a
406
+ * cloud provider is loaded, or (ruling R8) the conversation's environment runs on a registered
407
+ * kernel provider (`@device`). Without a conversation only the cloud provider counts.
408
+ */
409
+ export async function sandboxUsable(deps, orgId, conversationId) {
410
+ if (deps.sandbox.cloudAvailable)
411
+ return true;
412
+ if (conversationId === undefined || !deps.environments.usableNow)
413
+ return false;
414
+ return deps.environments.usableNow(orgId, conversationId);
415
+ }
416
+ /**
417
+ * The reason the registry reports the sandbox tools unavailable with, or null when they can run
418
+ * ({@link sandboxUsable}; the listing passes the root conversation of the run). An eval replay
419
+ * lists them as available (`replay`: its calls are never executed). `memo` (one per listing)
420
+ * makes every sandbox tool of a listing share one answer.
421
+ */
422
+ export const unavailableWhenNoSandbox = (deps) => async (ctx) => {
423
+ if (ctx.replay === true)
424
+ return null;
425
+ const key = "sandboxUsable";
426
+ let usable = ctx.memo?.get(key);
427
+ if (usable === undefined) {
428
+ usable = sandboxUsable(deps, ctx.orgId, ctx.conversationId);
429
+ ctx.memo?.set(key, usable);
430
+ }
431
+ return (await usable) ? null : SANDBOX_UNAVAILABLE_REASON;
432
+ };
342
433
  /** 读出的字节是否像二进制(开头 8 KB 里有 NUL)。 */
343
434
  export function looksBinary(data) {
344
435
  return data.subarray(0, 8192).includes(0);