@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
@@ -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);
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { ExtensionPointUnavailableError, MEDIA_NAME_MAX_CHARS, ToolError, } from "@capybara-hq/plugin-sdk";
3
3
  import { isValidArtifactName } from "../../../conversations/events.js";
4
4
  import { ApiError } from "../../../errors.js";
5
- import { checkPath, clip, display, inSandbox, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, } from "./common.js";
5
+ import { checkPath, checkPathShape, clip, display, inSandbox, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, } from "./common.js";
6
6
  const SPEC = {
7
7
  name: "Deliver",
8
8
  // Display only (the tool row; not in the fingerprint, never sent to the model).
@@ -18,7 +18,7 @@ const SPEC = {
18
18
  file_path: {
19
19
  type: "string",
20
20
  minLength: 1,
21
- description: 'Absolute path of the file in the sandbox, e.g. "/workspace/out/report.pdf".',
21
+ description: "Absolute path of the file in the sandbox, e.g. out/report.pdf in the workspace directory.",
22
22
  },
23
23
  name: {
24
24
  type: "string",
@@ -96,21 +96,23 @@ export function sandboxDeliverTool(deps) {
96
96
  spec: SPEC,
97
97
  defaultPolicy: "allow",
98
98
  // 没有沙箱或没有对象存储都交付不了(先报沙箱的原因)
99
- unavailable: (ctx) => unavailableWhenNoSandbox(deps)(ctx) ?? (deps.storage.available ? null : NO_STORAGE_REASON),
99
+ unavailable: async (ctx) => (await unavailableWhenNoSandbox(deps)(ctx)) ??
100
+ (deps.storage.available ? null : NO_STORAGE_REASON),
100
101
  timeoutMs: () => 2 * sandboxBaseTimeoutMs(deps.config.get()),
101
102
  async run(input, ctx) {
102
103
  // 存储在调用前就核对:不为一个交付不了的文件去 stat / 读沙箱
103
104
  if (!deps.storage.available)
104
105
  throw new ToolError(NO_STORAGE);
105
- const path = checkPath(input.file_path, "file_path");
106
106
  const given = input.name?.trim();
107
107
  // 与 artifact_created 的 schema 同一规则,在上传前检查(不合法的名字不会在写事件时才失败)
108
108
  if (given !== undefined && (!isValidArtifactName(given) || /[/\\]/.test(given))) {
109
109
  throw new ToolError(BAD_NAME);
110
110
  }
111
- const name = given ?? defaultName(path);
112
111
  const max = deps.config.get().sandbox.deliverMaxBytes;
113
- const { value: data, notices } = await inSandbox(deps, ctx, async ({ h, p, signal }) => {
112
+ checkPathShape(input.file_path, "file_path");
113
+ let path = "";
114
+ const { value: data, notices } = await inSandbox(deps, ctx, async ({ h, roots, p, signal }) => {
115
+ path = checkPath(input.file_path, roots, "file_path");
114
116
  const st = await p.stat(h, path, signal);
115
117
  if (st === null)
116
118
  throw new ToolError(notFound(path));
@@ -126,6 +128,7 @@ export function sandboxDeliverTool(deps) {
126
128
  throw new ToolError(tooLarge(path, max + 1, max));
127
129
  return got.data;
128
130
  });
131
+ const name = given ?? defaultName(path);
129
132
  // 上传在沙箱锁之外进行:不占着沙箱
130
133
  const artifactId = randomUUID();
131
134
  const objectKey = `artifacts/${ctx.conversationId}/${artifactId}`;
@@ -1,6 +1,6 @@
1
1
  import { ToolError } from "@capybara-hq/plugin-sdk";
2
2
  import { lineDiff, viewPath } from "../../view.js";
3
- import { checkWritable, clip, display, inSandbox, looksBinary, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, withPathLease, } from "./common.js";
3
+ import { checkPathShape, checkWritable, clip, display, inSandbox, looksBinary, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, withPathLease, } from "./common.js";
4
4
  import { assertReadAndUnchanged, fileStateOf, normalizeSandboxPath, recordedFile, } from "./file-state.js";
5
5
  import { READ_MAX_BYTES } from "./read.js";
6
6
  /** 可编辑的最大文件(字节):更大的用 Bash 处理。 */
@@ -19,7 +19,7 @@ const SPEC = {
19
19
  file_path: {
20
20
  type: "string",
21
21
  minLength: 1,
22
- description: 'Absolute path of the file, e.g. "/workspace/src/app.py".',
22
+ description: "Absolute path of the file, e.g. src/app.py in the workspace directory.",
23
23
  },
24
24
  old_string: { type: "string", minLength: 1, description: "The exact text to replace." },
25
25
  new_string: { type: "string", description: "The replacement text." },
@@ -53,43 +53,46 @@ export function sandboxEditTool(deps) {
53
53
  timeoutMs: () => sandboxBaseTimeoutMs(deps.config.get()),
54
54
  async run(input, ctx) {
55
55
  const { old_string, new_string, replace_all = false } = input;
56
- const path = checkWritable(input.file_path);
57
56
  if (old_string === new_string)
58
57
  throw new ToolError(SAME_TEXT);
59
- const { value: edit, notices } = await inSandbox(deps, ctx, ({ environmentId, h, p, signal }) =>
60
- // read, replace, write: one holder at a time per file
61
- withPathLease(deps, { environmentId }, path, signal, async (signal) => {
62
- const st = await p.stat(h, path, signal);
63
- if (st === null)
64
- throw new ToolError(notFound(path));
65
- if (st.isDir)
66
- throw new ToolError(notText(path));
67
- const got = await p.readFile(h, path, { maxBytes: EDIT_MAX_BYTES, signal });
68
- if (got === null)
69
- throw new ToolError(notFound(path));
70
- if (got.truncated || looksBinary(got.data))
71
- throw new ToolError(notText(path));
72
- await assertReadAndUnchanged(deps, ctx.conversationId, path, fileStateOf(got.data, st.size));
73
- const text = new TextDecoder().decode(got.data);
74
- const count = occurrences(text, old_string);
75
- if (count === 0)
76
- throw new ToolError(noMatch(path));
77
- if (count > 1 && !replace_all)
78
- throw new ToolError(ambiguous(count));
79
- // 用函数形式替换:new_string 里的 `$&` 等不被当成替换模式
80
- const next = replace_all
81
- ? text.split(old_string).join(new_string)
82
- : text.replace(old_string, () => new_string);
83
- const nextBytes = new TextEncoder().encode(next);
84
- await p.writeFile(h, path, nextBytes, signal);
85
- return {
86
- n: replace_all ? count : 1,
87
- before: text,
88
- after: next,
89
- // over the bytes the next Write / Edit reads back (Read's limit), as Read records it
90
- file: recordedFile(normalizeSandboxPath(path), nextBytes.subarray(0, READ_MAX_BYTES), nextBytes.byteLength),
91
- };
92
- }));
58
+ checkPathShape(input.file_path, "file_path");
59
+ let path = "";
60
+ const { value: edit, notices } = await inSandbox(deps, ctx, ({ environmentId, h, roots, p, signal }) => {
61
+ path = checkWritable(input.file_path, roots);
62
+ // read, replace, write: one holder at a time per file
63
+ return withPathLease(deps, { environmentId }, path, signal, async (signal) => {
64
+ const st = await p.stat(h, path, signal);
65
+ if (st === null)
66
+ throw new ToolError(notFound(path));
67
+ if (st.isDir)
68
+ throw new ToolError(notText(path));
69
+ const got = await p.readFile(h, path, { maxBytes: EDIT_MAX_BYTES, signal });
70
+ if (got === null)
71
+ throw new ToolError(notFound(path));
72
+ if (got.truncated || looksBinary(got.data))
73
+ throw new ToolError(notText(path));
74
+ await assertReadAndUnchanged(deps, ctx.conversationId, path, fileStateOf(got.data, st.size));
75
+ const text = new TextDecoder().decode(got.data);
76
+ const count = occurrences(text, old_string);
77
+ if (count === 0)
78
+ throw new ToolError(noMatch(path));
79
+ if (count > 1 && !replace_all)
80
+ throw new ToolError(ambiguous(count));
81
+ // 用函数形式替换:new_string 里的 `$&` 等不被当成替换模式
82
+ const next = replace_all
83
+ ? text.split(old_string).join(new_string)
84
+ : text.replace(old_string, () => new_string);
85
+ const nextBytes = new TextEncoder().encode(next);
86
+ await p.writeFile(h, path, nextBytes, signal);
87
+ return {
88
+ n: replace_all ? count : 1,
89
+ before: text,
90
+ after: next,
91
+ // over the bytes the next Write / Edit reads back (Read's limit), as Read records it
92
+ file: recordedFile(normalizeSandboxPath(path), nextBytes.subarray(0, READ_MAX_BYTES), nextBytes.byteLength),
93
+ };
94
+ });
95
+ });
93
96
  const { n } = edit;
94
97
  const text = `Edited ${path}: replaced ${n} occurrence${n === 1 ? "" : "s"}.`;
95
98
  return {