@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
@@ -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 {
@@ -1,5 +1,5 @@
1
1
  import { ToolError } from "@capybara-hq/plugin-sdk";
2
- import { checkPath, clip, display, inSandbox, isSyncMarkerEntry, sandboxBaseTimeoutMs, searchTimeoutMs, unavailableWhenNoSandbox, withNotices, } from "./common.js";
2
+ import { checkPath, checkPathShape, clip, display, inSandbox, sandboxBaseTimeoutMs, searchTimeoutMs, syncMarkerMatcher, unavailableWhenNoSandbox, withNotices, } from "./common.js";
3
3
  import { compileGlob, GlobPatternError } from "./glob-match.js";
4
4
  /** 最多列出的文件数。 */
5
5
  const MAX_RESULTS = 1000;
@@ -11,7 +11,7 @@ const SPEC = {
11
11
  keyArgs: ["pattern"],
12
12
  description: "Find files by name pattern in this conversation's sandbox. Use it to list files and to " +
13
13
  'locate files by name, e.g. pattern "**/*.py" for all Python files. The pattern is matched ' +
14
- "against paths relative to path (default /workspace): * matches within one directory, ** " +
14
+ "against paths relative to path (default: the workspace directory): * matches within one directory, ** " +
15
15
  "matches across directories, ? matches one character, {a,b} matches either. Returns absolute " +
16
16
  "paths sorted by name, up to 1000. To search file contents, use Grep.",
17
17
  inputSchema: {
@@ -25,7 +25,7 @@ const SPEC = {
25
25
  path: {
26
26
  type: "string",
27
27
  minLength: 1,
28
- description: "Absolute directory to search in. Default /workspace.",
28
+ description: "Absolute directory to search in. Default: the workspace directory.",
29
29
  },
30
30
  },
31
31
  required: ["pattern"],
@@ -34,7 +34,7 @@ const SPEC = {
34
34
  annotations: { readOnlyHint: true },
35
35
  };
36
36
  const ABSOLUTE_PATTERN = 'The pattern must be relative to path; pass the directory in path and a relative pattern such as "**/*.py".';
37
- const notDirectory = (path) => `${clip(path, 200)} is not a directory; pass an existing directory in path (default /workspace).`;
37
+ const notDirectory = (path, workspace) => `${clip(path, 200)} is not a directory; pass an existing directory in path (default ${workspace}).`;
38
38
  const FIND_FAILED = "Listing files did not finish in time; pass a narrower path or pattern and retry.";
39
39
  /** 内置工具 `Glob`:按文件名模式找文件(只读)。 */
40
40
  export function sandboxGlobTool(deps) {
@@ -48,7 +48,6 @@ export function sandboxGlobTool(deps) {
48
48
  const { pattern } = input;
49
49
  if (pattern.startsWith("/"))
50
50
  throw new ToolError(ABSOLUTE_PATTERN);
51
- const dir = checkPath(input.path ?? "/workspace");
52
51
  let matches;
53
52
  try {
54
53
  matches = compileGlob(pattern);
@@ -58,10 +57,15 @@ export function sandboxGlobTool(deps) {
58
57
  throw e;
59
58
  throw new ToolError(`Invalid glob pattern: ${e.message}; pass a simpler pattern (fewer wildcards and braces) and retry.`);
60
59
  }
61
- const { value: files, notices } = await inSandbox(deps, ctx, async ({ h, p, signal }) => {
60
+ const given = input.path;
61
+ if (given !== undefined)
62
+ checkPathShape(given);
63
+ let dir = "";
64
+ const { value: files, notices } = await inSandbox(deps, ctx, async ({ h, roots, p, signal }) => {
65
+ dir = checkPath(input.path ?? roots.workspace, roots);
62
66
  const st = await p.stat(h, dir, signal);
63
67
  if (!st?.isDir)
64
- throw new ToolError(notDirectory(dir));
68
+ throw new ToolError(notDirectory(dir, roots.workspace));
65
69
  const r = await p.exec(h, {
66
70
  command: "find . -type f -print0",
67
71
  cwd: dir,
@@ -77,12 +81,13 @@ export function sandboxGlobTool(deps) {
77
81
  if (r.truncated)
78
82
  names.pop();
79
83
  const base = dir === "/" ? "" : dir;
84
+ const isMarker = syncMarkerMatcher(roots);
80
85
  return (names
81
86
  .filter((l) => l.startsWith("./"))
82
87
  .map((l) => l.slice(2))
83
88
  .filter((rel) => matches(rel))
84
89
  .map((rel) => `${base}/${rel}`)
85
- .filter((abs) => !isSyncMarkerEntry(abs))
90
+ .filter((abs) => !isMarker(abs))
86
91
  .sort()
87
92
  // 文件名里的换行显示为 `\n`(与 Grep 一致),一行一个文件
88
93
  .map((abs) => abs.replace(/\n/g, "\\n")));
@@ -1,5 +1,5 @@
1
1
  import { ToolError } from "@capybara-hq/plugin-sdk";
2
- import { checkPath, clip, display, inSandbox, isSyncMarkerEntry, sandboxBaseTimeoutMs, searchTimeoutMs, shellQuote, unavailableWhenNoSandbox, withNotices, } from "./common.js";
2
+ import { checkPath, checkPathShape, clip, display, inSandbox, sandboxBaseTimeoutMs, searchTimeoutMs, shellQuote, syncMarkerMatcher, unavailableWhenNoSandbox, withNotices, } from "./common.js";
3
3
  /** The most output entries (lines, files or counts) returned in one call, whatever `head_limit` says. */
4
4
  const MAX_MATCHES = 500;
5
5
  /** `head_limit` when the model gives none. */
@@ -15,7 +15,7 @@ const SPEC = {
15
15
  keyArgs: ["pattern", "path"],
16
16
  description: "Search file contents in this conversation's sandbox with a regular expression (POSIX " +
17
17
  "extended, as grep -E). Use it to find where something is defined or mentioned. Searches path " +
18
- "(a file, or a directory recursively; default /workspace), skipping binary files; glob limits " +
18
+ "(a file, or a directory recursively; default: the workspace directory), skipping binary files; glob limits " +
19
19
  'the files searched by name, e.g. "*.py". output_mode picks what comes back: ' +
20
20
  '"files_with_matches" (default) lists the paths of files that match, "content" returns the ' +
21
21
  "matching lines as <path>:<line>:<text> (with -A, -B or -C lines of context around them), " +
@@ -34,7 +34,7 @@ const SPEC = {
34
34
  path: {
35
35
  type: "string",
36
36
  minLength: 1,
37
- description: "Absolute file or directory to search. Default /workspace.",
37
+ description: "Absolute file or directory to search. Default: the workspace directory.",
38
38
  },
39
39
  glob: {
40
40
  type: "string",
@@ -160,8 +160,11 @@ export function sandboxGrepTool(deps) {
160
160
  const { pattern, glob } = g;
161
161
  const mode = g.output_mode ?? "files_with_matches";
162
162
  const numbered = g["-n"] ?? true;
163
- const path = checkPath(g.path ?? "/workspace");
164
- const { value, notices } = await inSandbox(deps, ctx, async ({ h, p, signal }) => {
163
+ if (g.path !== undefined)
164
+ checkPathShape(g.path);
165
+ let path = "";
166
+ const { value, notices } = await inSandbox(deps, ctx, async ({ h, roots, p, signal }) => {
167
+ path = checkPath(g.path ?? roots.workspace, roots);
165
168
  const st = await p.stat(h, path, signal);
166
169
  if (st === null)
167
170
  throw new ToolError(notFound(path));
@@ -207,7 +210,8 @@ export function sandboxGrepTool(deps) {
207
210
  : mode === "count"
208
211
  ? parseCounts(r.stdout, base)
209
212
  : parseGrepNull(r.stdout, base, numbered);
210
- const entries = all.filter((l) => !isSyncMarkerEntry(l));
213
+ const isMarker = syncMarkerMatcher(roots);
214
+ const entries = all.filter((l) => !isMarker(l));
211
215
  // 退出码 2 = 有错误(如个别文件不可读);有输出时照常返回(可能为空:只匹配到隐藏的标记文件)并附上说明
212
216
  if (r.exitCode !== 0 && all.length === 0)
213
217
  throw new ToolError(failed(r.stderr));
@@ -2,12 +2,14 @@ import type { BuiltinTool } from "../../registry.js";
2
2
  import type { SandboxToolDeps } from "./common.js";
3
3
  export { SANDBOX_ASK_TOOL_NAMES, SANDBOX_TOOL_NAMES } from "../../builtin-names.js";
4
4
  export { BASH_SPILL_CHARS, formatExec } from "./bash.js";
5
- export { assignAttachmentNames, RUN_DEADLINE_MARGIN_MS, runTimeLeftMs, SANDBOX_RESTARTED_NOTICE, SANDBOX_SETUP_MARGIN_MS, SANDBOX_STARTED_NOTICE, SANDBOX_UNAVAILABLE_REASON, type SandboxToolDeps, sandboxBaseTimeoutMs, searchTimeoutMs, shellQuote, } from "./common.js";
5
+ export { assignAttachmentNames, RUN_DEADLINE_MARGIN_MS, runTimeLeftMs, SANDBOX_RESTARTED_NOTICE, SANDBOX_SETUP_MARGIN_MS, SANDBOX_STARTED_NOTICE, SANDBOX_UNAVAILABLE_REASON, type SandboxToolDeps, sandboxBaseTimeoutMs, sandboxStartedNotice, sandboxUsable, searchTimeoutMs, shellQuote, } from "./common.js";
6
6
  export { compileGlob, GlobPatternError, MAX_GLOB_STARS } from "./glob-match.js";
7
7
  /**
8
- * 沙箱内置工具(M5 T8):bash / read / write / edit / glob / grep / deliver,都在本对话的沙箱上执行
9
- * (`SandboxService.withSandbox`),执行前同步附件到 `/workspace/attachments`。没有沙箱提供方时报不可用。
10
- * 默认 allow;组织设置 `sandboxToolPolicy` 为 ask 时除 read / glob / grep 外需要审批(见 policy.ts)。
8
+ * The sandbox builtins (M5 T8): Bash / Read / Write / Edit / Glob / Grep / Deliver, all run in the
9
+ * conversation's environment (`SandboxService.withSandbox`) after syncing attachments to
10
+ * `<workspace>/attachments` (the instance's roots, environments spec §13.1); unavailable without
11
+ * a provider for it. Allowed by default; with the org setting `sandboxToolPolicy` set to ask,
12
+ * every one but Read / Glob / Grep needs approval (see policy.ts).
11
13
  * `TaskOutput` and `TaskStop` read and stop background `Bash` tasks through
12
14
  * `SandboxService.ifRunning` (never creating or resuming a sandbox); always allowed.
13
15
  */
@@ -9,12 +9,14 @@ import { taskStopTool } from "./task-stop.js";
9
9
  import { sandboxWriteTool } from "./write.js";
10
10
  export { SANDBOX_ASK_TOOL_NAMES, SANDBOX_TOOL_NAMES } from "../../builtin-names.js";
11
11
  export { BASH_SPILL_CHARS, formatExec } from "./bash.js";
12
- export { assignAttachmentNames, RUN_DEADLINE_MARGIN_MS, runTimeLeftMs, SANDBOX_RESTARTED_NOTICE, SANDBOX_SETUP_MARGIN_MS, SANDBOX_STARTED_NOTICE, SANDBOX_UNAVAILABLE_REASON, sandboxBaseTimeoutMs, searchTimeoutMs, shellQuote, } from "./common.js";
12
+ export { assignAttachmentNames, RUN_DEADLINE_MARGIN_MS, runTimeLeftMs, SANDBOX_RESTARTED_NOTICE, SANDBOX_SETUP_MARGIN_MS, SANDBOX_STARTED_NOTICE, SANDBOX_UNAVAILABLE_REASON, sandboxBaseTimeoutMs, sandboxStartedNotice, sandboxUsable, searchTimeoutMs, shellQuote, } from "./common.js";
13
13
  export { compileGlob, GlobPatternError, MAX_GLOB_STARS } from "./glob-match.js";
14
14
  /**
15
- * 沙箱内置工具(M5 T8):bash / read / write / edit / glob / grep / deliver,都在本对话的沙箱上执行
16
- * (`SandboxService.withSandbox`),执行前同步附件到 `/workspace/attachments`。没有沙箱提供方时报不可用。
17
- * 默认 allow;组织设置 `sandboxToolPolicy` 为 ask 时除 read / glob / grep 外需要审批(见 policy.ts)。
15
+ * The sandbox builtins (M5 T8): Bash / Read / Write / Edit / Glob / Grep / Deliver, all run in the
16
+ * conversation's environment (`SandboxService.withSandbox`) after syncing attachments to
17
+ * `<workspace>/attachments` (the instance's roots, environments spec §13.1); unavailable without
18
+ * a provider for it. Allowed by default; with the org setting `sandboxToolPolicy` set to ask,
19
+ * every one but Read / Glob / Grep needs approval (see policy.ts).
18
20
  * `TaskOutput` and `TaskStop` read and stop background `Bash` tasks through
19
21
  * `SandboxService.ifRunning` (never creating or resuming a sandbox); always allowed.
20
22
  */
@@ -1,6 +1,6 @@
1
1
  import { ToolError } from "@capybara-hq/plugin-sdk";
2
2
  import { viewPath } from "../../view.js";
3
- import { checkPath, clip, display, inSandbox, looksBinary, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, } from "./common.js";
3
+ import { checkPath, checkPathShape, clip, display, inSandbox, looksBinary, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, withNotices, } from "./common.js";
4
4
  import { normalizeSandboxPath, recordedFile } from "./file-state.js";
5
5
  /** 不给 `limit` 时一次读的行数。 */
6
6
  const DEFAULT_LIMIT = 2000;
@@ -13,8 +13,9 @@ const SPEC = {
13
13
  // Display only (the tool row; not in the fingerprint, never sent to the model).
14
14
  keyArgs: ["file_path"],
15
15
  description: "Read a text file from this conversation's sandbox, with line numbers. Use it to look at " +
16
- "source code, data files, logs and files the user attached (under /workspace/attachments/<conversation>/). " +
17
- "Paths are absolute, under /workspace, /skills or /tmp. Lines are numbered from 1; pass " +
16
+ "source code, data files, logs and files the user attached (under attachments/<conversation>/ in " +
17
+ "the workspace directory). Paths are absolute, under the workspace directory, the skills " +
18
+ "directory or the temporary files directory. Lines are numbered from 1; pass " +
18
19
  "offset and limit to read part of a long file. Binary files are not shown; use Bash " +
19
20
  "to inspect them. Do not use it to list directories; use Glob instead. Read a file before you " +
20
21
  "write it: Write and Edit fail on a file you have not read, or one changed since you read it.",
@@ -24,7 +25,7 @@ const SPEC = {
24
25
  file_path: {
25
26
  type: "string",
26
27
  minLength: 1,
27
- description: 'Absolute path of the file, e.g. "/workspace/src/main.py".',
28
+ description: "Absolute path of the file, e.g. src/main.py in the workspace directory.",
28
29
  },
29
30
  offset: {
30
31
  type: "integer",
@@ -81,8 +82,10 @@ export function sandboxReadTool(deps) {
81
82
  timeoutMs: () => sandboxBaseTimeoutMs(deps.config.get()),
82
83
  async run(input, ctx) {
83
84
  const { offset = 1, limit = DEFAULT_LIMIT } = input;
84
- const path = checkPath(input.file_path, "file_path");
85
- const { value, notices } = await inSandbox(deps, ctx, async ({ h, p, signal }) => {
85
+ checkPathShape(input.file_path, "file_path");
86
+ let path = "";
87
+ const { value, notices } = await inSandbox(deps, ctx, async ({ h, roots, p, signal }) => {
88
+ path = checkPath(input.file_path, roots, "file_path");
86
89
  const st = await p.stat(h, path, signal);
87
90
  if (st === null)
88
91
  throw new ToolError(notFound(path));
@@ -16,8 +16,12 @@ export interface TaskEnv {
16
16
  ifRunning: SandboxService["ifRunning"];
17
17
  environments: SandboxToolDeps["environments"];
18
18
  }
19
- /** The task tools' dependencies; throws the tool's error when they are missing. */
20
- export declare function taskEnv(deps: SandboxToolDeps): TaskEnv;
19
+ /**
20
+ * The task tools' dependencies; throws the tool's error when they are missing, or when the
21
+ * conversation's environment cannot run (no cloud provider, and not a registered `@device` one:
22
+ * ruling R8).
23
+ */
24
+ export declare function taskEnv(deps: SandboxToolDeps, ctx: BuiltinCallContext): Promise<TaskEnv>;
21
25
  /**
22
26
  * The task `id` when it belongs to the calling conversation; a task of another conversation (a
23
27
  * sub-agent's, when called from its parent) is not found, the same as an unknown id.
@@ -45,7 +49,7 @@ export declare const outputFile: (task: BackgroundTaskRow) => string;
45
49
  /** Why the output cannot be read: the task's sandbox instance is not running. */
46
50
  export declare function unavailableText(task: BackgroundTaskRow, r: IfRunningResult<unknown>): string;
47
51
  /**
48
- * The exec that checks and reads task `t` (relative to `/workspace`): for a running task first
52
+ * The exec that checks and reads task `t` (relative to the workspace root): for a running task first
49
53
  * its status line (the poller's, {@link pollScript}: liveness, then the exit file), then
50
54
  * `size <bytes>` and `out <base64>`, at most `cap` bytes of the output from byte `offset` on.
51
55
  * The file is never read whole: `tail -c +<offset+1> | head -c <cap>`. Without `read` only the
@@ -1,11 +1,11 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
- import { isSandboxGoneError, ToolError, } from "@capybara-hq/plugin-sdk";
3
- import { TASK_ID_RE, TASKS_DIR } from "../../../background/launch.js";
2
+ import { isSandboxGoneError, rootsOf, ToolError, } from "@capybara-hq/plugin-sdk";
3
+ import { TASK_ID_RE, TASKS_DIR, taskOutputFile } from "../../../background/launch.js";
4
4
  import { formatElapsed, lostReasonText } from "../../../background/render.js";
5
5
  import { actingAs, SID_RE, taskActor } from "../../../background/session.js";
6
6
  import { parsePollOutput, polledAs, pollScript, } from "../../../background/status.js";
7
7
  import { notRunningAs, RECREATED } from "../../../background/stop.js";
8
- import { clip, display, mapSandboxError, noSandboxError, runTimeLeftMs, sandboxBaseTimeoutMs, sandboxConversationOf, unavailableWhenNoSandbox, } from "./common.js";
8
+ import { clip, display, mapSandboxError, noSandboxError, runTimeLeftMs, sandboxBaseTimeoutMs, sandboxConversationOf, sandboxUsable, unavailableWhenNoSandbox, } from "./common.js";
9
9
  /** How long `TaskOutput` waits with `block` when `timeout` is not given (ms). */
10
10
  export const TASK_OUTPUT_DEFAULT_TIMEOUT_MS = 30_000;
11
11
  /** The longest `TaskOutput` wait (ms). */
@@ -49,9 +49,13 @@ const SPEC = {
49
49
  additionalProperties: false,
50
50
  },
51
51
  };
52
- /** The task tools' dependencies; throws the tool's error when they are missing. */
53
- export function taskEnv(deps) {
54
- if (!deps.sandbox.available)
52
+ /**
53
+ * The task tools' dependencies; throws the tool's error when they are missing, or when the
54
+ * conversation's environment cannot run (no cloud provider, and not a registered `@device` one:
55
+ * ruling R8).
56
+ */
57
+ export async function taskEnv(deps, ctx) {
58
+ if (!(await sandboxUsable(deps, ctx.orgId, sandboxConversationOf(ctx))))
55
59
  throw noSandboxError();
56
60
  const { background, tasks } = deps;
57
61
  if (!background || !tasks || !deps.sandbox.ifRunning) {
@@ -122,7 +126,7 @@ export function statusLine(task, now = new Date()) {
122
126
  return `${s} after ${elapsed}`;
123
127
  }
124
128
  /** The absolute path of the task's output file, as the model sees it. */
125
- export const outputFile = (task) => `/workspace/${task.outputPath}`;
129
+ export const outputFile = (task) => taskOutputFile(task.outputPath);
126
130
  /** Why the output cannot be read: the task's sandbox instance is not running. */
127
131
  export function unavailableText(task, r) {
128
132
  if (r.kind === "not_running" && r.status === "suspended") {
@@ -132,7 +136,7 @@ export function unavailableText(task, r) {
132
136
  }
133
137
  // ---- reading a task ----
134
138
  /**
135
- * The exec that checks and reads task `t` (relative to `/workspace`): for a running task first
139
+ * The exec that checks and reads task `t` (relative to the workspace root): for a running task first
136
140
  * its status line (the poller's, {@link pollScript}: liveness, then the exit file), then
137
141
  * `size <bytes>` and `out <base64>`, at most `cap` bytes of the output from byte `offset` on.
138
142
  * The file is never read whole: `tail -c +<offset+1> | head -c <cap>`. Without `read` only the
@@ -189,7 +193,7 @@ async function look(env, task, read, signal) {
189
193
  return onTaskInstance(env, task, async (p, h, sig) => {
190
194
  const r = await p.exec(h, {
191
195
  command: script,
192
- cwd: "/workspace",
196
+ cwd: rootsOf(h).workspace,
193
197
  timeoutMs: READ_EXEC_TIMEOUT_MS,
194
198
  maxOutputBytes: maxBytes,
195
199
  signal: sig,
@@ -262,7 +266,7 @@ export function taskOutputTool(deps) {
262
266
  },
263
267
  async run(input, ctx) {
264
268
  const { task_id, block = true, timeout } = input;
265
- const env = taskEnv(deps);
269
+ const env = await taskEnv(deps, ctx);
266
270
  let task = await ownedTask(env, ctx, task_id);
267
271
  await recheckEnvironment(env, ctx);
268
272
  if (block && task.status === "running") {
@@ -1,4 +1,4 @@
1
- import { ToolError } from "@capybara-hq/plugin-sdk";
1
+ import { rootsOf, ToolError } from "@capybara-hq/plugin-sdk";
2
2
  import { decodeTail, tailCommand } from "../../../background/session.js";
3
3
  import { clip, display, mapSandboxError, sandboxBaseTimeoutMs, unavailableWhenNoSandbox, } from "./common.js";
4
4
  import { onTaskInstance, ownedTask, recheckEnvironment, statusLine, taskEnv, } from "./task-output.js";
@@ -33,7 +33,7 @@ async function lastLinesOf(env, task, signal) {
33
33
  const r = await onTaskInstance(env, task, async (p, h, sig) => {
34
34
  const res = await p.exec(h, {
35
35
  command: tailCommand(task.id),
36
- cwd: "/workspace",
36
+ cwd: rootsOf(h).workspace,
37
37
  timeoutMs: TAIL_EXEC_TIMEOUT_MS,
38
38
  maxOutputBytes: 64 * 1024,
39
39
  signal: sig,
@@ -82,7 +82,7 @@ export function taskStopTool(deps) {
82
82
  timeoutMs: () => sandboxBaseTimeoutMs(deps.config.get()),
83
83
  async run(input, ctx) {
84
84
  const { task_id } = input;
85
- const env = taskEnv(deps);
85
+ const env = await taskEnv(deps, ctx);
86
86
  const task = await ownedTask(env, ctx, task_id);
87
87
  await recheckEnvironment(env, ctx);
88
88
  if (task.status !== "running") {
@@ -1,6 +1,6 @@
1
1
  import { ToolError } from "@capybara-hq/plugin-sdk";
2
2
  import { lineDiff, TOOL_VIEW_DIFF_MAX_BYTES, 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
  const SPEC = {
@@ -9,8 +9,8 @@ const SPEC = {
9
9
  keyArgs: ["file_path"],
10
10
  description: "Write a text file in this conversation's sandbox, creating parent directories as needed. " +
11
11
  "Each call replaces the whole file (it is created if missing). Use it to create scripts, " +
12
- "source files and data files. The path is absolute, under /workspace or /tmp (/skills is " +
13
- "read-only). Read an existing file first; overwriting a file you have not read, or one changed " +
12
+ "source files and data files. The path is absolute, under the workspace directory or the " +
13
+ "temporary files directory (the skills directory is read-only). Read an existing file first; overwriting a file you have not read, or one changed " +
14
14
  "since you read it, fails. To change part of an existing file, use Edit instead; to give a file to " +
15
15
  "the user, use Deliver.",
16
16
  inputSchema: {
@@ -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/report.md".',
22
+ description: "Absolute path of the file, e.g. report.md in the workspace directory.",
23
23
  },
24
24
  content: { type: "string", description: "The complete new content of the file." },
25
25
  },
@@ -43,33 +43,36 @@ export function sandboxWriteTool(deps) {
43
43
  unavailable: unavailableWhenNoSandbox(deps),
44
44
  timeoutMs: () => sandboxBaseTimeoutMs(deps.config.get()),
45
45
  async run(input, ctx) {
46
- const path = checkWritable(input.file_path);
47
46
  const data = new TextEncoder().encode(input.content);
48
- const { value, notices } = await inSandbox(deps, ctx, ({ environmentId, h, p, signal }) =>
49
- // stat, read the previous content, write: one holder at a time per file
50
- withPathLease(deps, { environmentId }, path, signal, async (signal) => {
51
- const st = await p.stat(h, path, signal);
52
- if (st?.isDir)
53
- throw new ToolError(isDirectory(path));
54
- // the previous content: for the read-before-write check, and the diff on the row (display
55
- // only: a file too large or binary to diff just leaves the view without hunks)
56
- let previous = "";
57
- if (st !== null) {
58
- const got = await p.readFile(h, path, { maxBytes: READ_MAX_BYTES, signal });
59
- if (got === null)
60
- throw new ToolError(vanished(path));
61
- await assertReadAndUnchanged(deps, ctx.conversationId, path, fileStateOf(got.data, st.size));
62
- previous =
63
- st.size > PREVIOUS_MAX_BYTES ||
64
- got.truncated ||
65
- looksBinary(got.data) ||
66
- got.data.includes(0)
67
- ? null
68
- : new TextDecoder().decode(got.data);
69
- }
70
- await p.writeFile(h, path, data, signal);
71
- return { existed: st !== null, previous };
72
- }));
47
+ checkPathShape(input.file_path, "file_path");
48
+ let path = "";
49
+ const { value, notices } = await inSandbox(deps, ctx, ({ environmentId, h, roots, p, signal }) => {
50
+ path = checkWritable(input.file_path, roots);
51
+ // stat, read the previous content, write: one holder at a time per file
52
+ return withPathLease(deps, { environmentId }, path, signal, async (signal) => {
53
+ const st = await p.stat(h, path, signal);
54
+ if (st?.isDir)
55
+ throw new ToolError(isDirectory(path));
56
+ // the previous content: for the read-before-write check, and the diff on the row (display
57
+ // only: a file too large or binary to diff just leaves the view without hunks)
58
+ let previous = "";
59
+ if (st !== null) {
60
+ const got = await p.readFile(h, path, { maxBytes: READ_MAX_BYTES, signal });
61
+ if (got === null)
62
+ throw new ToolError(vanished(path));
63
+ await assertReadAndUnchanged(deps, ctx.conversationId, path, fileStateOf(got.data, st.size));
64
+ previous =
65
+ st.size > PREVIOUS_MAX_BYTES ||
66
+ got.truncated ||
67
+ looksBinary(got.data) ||
68
+ got.data.includes(0)
69
+ ? null
70
+ : new TextDecoder().decode(got.data);
71
+ }
72
+ await p.writeFile(h, path, data, signal);
73
+ return { existed: st !== null, previous };
74
+ });
75
+ });
73
76
  const { existed, previous } = value;
74
77
  const text = `${existed ? "Overwrote" : "Created"} ${path} (${data.byteLength} bytes).`;
75
78
  return {
@@ -8,14 +8,14 @@ import { type SandboxToolDeps } from "./sandbox/common.js";
8
8
  * Builtin tool `Skill` (M5 T14, ruling M17): resolves the name against the latest skills catalog
9
9
  * snapshot in the log to an id and version, and returns that version's SKILL.md body and file list
10
10
  * (the result names the version). A skill with other files is copied whole into the conversation's
11
- * environment at `/skills/<name>@<digest>/` (the provider writes as root, commands read only; the
11
+ * environment at `<skills root>/<name>@<digest>/` (the instance's skills root, `/skills` by default; the provider writes as root, commands read only; the
12
12
  * digest is {@link skillDigest} of the version's file manifest, so versions of one skill coexist in
13
13
  * the shared file system). Without a sandbox provider only the body is given, with a note that
14
14
  * scripts are unavailable.
15
15
  *
16
16
  * - Every file read from storage is checked against the manifest's sha256; a mismatch is refused.
17
17
  * - The copy is recorded in a kernel-controlled marker, one per skill version
18
- * ({@link skillMarkerPath}: `/skills/.capybara/<name>@<digest>.json`, commands cannot change it):
18
+ * ({@link skillMarkerPath}: `<skills root>/.capybara/<name>@<digest>.json`, commands cannot change it):
19
19
  * `{ digest, files }`. A present marker with the same digest skips the copy.
20
20
  * - An agent without any sandbox tool creates no sandbox and copies nothing (the model could not
21
21
  * run the scripts), and is told so.
@@ -23,11 +23,14 @@ import { type SandboxToolDeps } from "./sandbox/common.js";
23
23
  * to run scripts with an interpreter (`sh` / `python3` / `node`).
24
24
  */
25
25
  /**
26
- * 已复制进沙箱的技能的标记目录(内核控制:`/skills` 只有提供方以 root 能写;技能名不以 `.` 开头,不会撞名)。
26
+ * 已复制进沙箱的技能的标记目录(内核控制:技能根目录只有提供方以 root 能写;技能名不以 `.` 开头,不会撞名)。
27
+ * `skillsRoot`: the instance's skills root.
27
28
  */
28
- export declare const SKILLS_MARKER_DIR = "/skills/.capybara";
29
+ export declare const skillsMarkerDir: (skillsRoot?: string) => string;
30
+ /** {@link skillsMarkerDir} of an instance with the default roots. */
31
+ export declare const SKILLS_MARKER_DIR: string;
29
32
  /** The marker file of one skill version. */
30
- export declare const skillMarkerPath: (name: string, digest: string) => string;
33
+ export declare const skillMarkerPath: (name: string, digest: string, skillsRoot?: string) => string;
31
34
  /** 依赖:沙箱工具的依赖,加上读技能行用的库与读快照用的日志。 */
32
35
  export interface SkillLoadDeps extends SandboxToolDeps {
33
36
  db: KernelSchemaDb;