@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.
- package/dist/background/finish.js +2 -1
- package/dist/background/launch.d.ts +13 -7
- package/dist/background/launch.js +15 -8
- package/dist/background/poll.job.d.ts +1 -1
- package/dist/background/poll.job.js +8 -3
- package/dist/background/session.d.ts +2 -2
- package/dist/background/session.js +3 -2
- package/dist/background/status.d.ts +1 -1
- package/dist/background/status.js +1 -1
- package/dist/background/stop.js +4 -3
- package/dist/background/store.d.ts +6 -1
- package/dist/background/store.js +5 -1
- package/dist/background/tasks.service.d.ts +1 -1
- package/dist/background/tasks.service.js +4 -4
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/config/docs.js +2 -0
- package/dist/config/schema.d.ts +7 -0
- package/dist/config/schema.js +45 -0
- package/dist/conversations/event-log.d.ts +4 -3
- package/dist/conversations/event-log.js +4 -3
- package/dist/conversations/events.d.ts +9 -0
- package/dist/conversations/events.js +14 -0
- package/dist/db/kernel-schema.d.ts +26 -1
- package/dist/devices/device-auth.controller.d.ts +42 -0
- package/dist/devices/device-auth.controller.js +116 -0
- package/dist/devices/device-auth.d.ts +119 -0
- package/dist/devices/device-auth.js +403 -0
- package/dist/devices/device-auth.module.d.ts +7 -0
- package/dist/devices/device-auth.module.js +25 -0
- package/dist/devices/device-gateway.d.ts +128 -0
- package/dist/devices/device-gateway.js +530 -0
- package/dist/devices/device-provider.d.ts +89 -0
- package/dist/devices/device-provider.js +151 -0
- package/dist/devices/device-routes.d.ts +36 -0
- package/dist/devices/device-routes.js +90 -0
- package/dist/devices/devices.module.d.ts +16 -0
- package/dist/devices/devices.module.js +44 -0
- package/dist/devices/dto.d.ts +66 -0
- package/dist/devices/dto.js +44 -0
- package/dist/devices/keys.d.ts +25 -0
- package/dist/devices/keys.js +25 -0
- package/dist/devices/machines.d.ts +106 -0
- package/dist/devices/machines.js +301 -0
- package/dist/devices/relay.d.ts +100 -0
- package/dist/devices/relay.js +373 -0
- package/dist/devices/targets.d.ts +28 -0
- package/dist/devices/targets.js +73 -0
- package/dist/environments/access.d.ts +11 -6
- package/dist/environments/access.js +23 -8
- package/dist/environments/conversation-environments.d.ts +20 -6
- package/dist/environments/conversation-environments.js +70 -9
- package/dist/environments/environments.controller.d.ts +4 -1
- package/dist/environments/environments.controller.js +15 -1
- package/dist/environments/environments.module.js +3 -1
- package/dist/environments/environments.service.d.ts +54 -7
- package/dist/environments/environments.service.js +104 -20
- package/dist/environments/legacy-cleanup.job.d.ts +1 -1
- package/dist/environments/legacy-cleanup.job.js +4 -4
- package/dist/errors.d.ts +24 -0
- package/dist/errors.js +24 -0
- package/dist/evals/recorded-tools.d.ts +9 -0
- package/dist/evals/recorded-tools.js +27 -0
- package/dist/http/api.module.js +4 -0
- package/dist/http/gateway-routes.d.ts +7 -3
- package/dist/http/gateway-routes.js +6 -3
- package/dist/infra/config/kernel-config.service.js +2 -0
- package/dist/infra/throttler/named-throttlers.d.ts +5 -0
- package/dist/infra/throttler/named-throttlers.js +38 -0
- package/dist/kernel.module.js +3 -0
- package/dist/lifecycle/kernel-lifecycle.js +5 -0
- package/dist/platform/exclusions.js +33 -0
- package/dist/platform/ops/environments.ops.js +21 -3
- package/dist/roles/gateway-role.d.ts +14 -3
- package/dist/roles/gateway-role.js +25 -5
- package/dist/roles/gateway.module.d.ts +2 -1
- package/dist/roles/gateway.module.js +6 -3
- package/dist/runtime/context/build.d.ts +2 -0
- package/dist/runtime/context/build.js +14 -0
- package/dist/runtime/context/compaction.js +1 -0
- package/dist/runtime/context/environment-paths.d.ts +40 -0
- package/dist/runtime/context/environment-paths.js +48 -0
- package/dist/runtime/model-call.js +2 -0
- package/dist/sandbox/providers.d.ts +29 -7
- package/dist/sandbox/providers.js +43 -8
- package/dist/sandbox/sandbox.service.d.ts +70 -18
- package/dist/sandbox/sandbox.service.js +176 -59
- package/dist/tools/builtins/sandbox/bash.d.ts +2 -2
- package/dist/tools/builtins/sandbox/bash.js +39 -27
- package/dist/tools/builtins/sandbox/common.d.ts +60 -25
- package/dist/tools/builtins/sandbox/common.js +121 -30
- package/dist/tools/builtins/sandbox/deliver.js +9 -6
- package/dist/tools/builtins/sandbox/edit.js +40 -37
- package/dist/tools/builtins/sandbox/glob.js +13 -8
- package/dist/tools/builtins/sandbox/grep.js +10 -6
- package/dist/tools/builtins/sandbox/index.d.ts +6 -4
- package/dist/tools/builtins/sandbox/index.js +6 -4
- package/dist/tools/builtins/sandbox/read.js +9 -6
- package/dist/tools/builtins/sandbox/task-output.d.ts +7 -3
- package/dist/tools/builtins/sandbox/task-output.js +14 -10
- package/dist/tools/builtins/sandbox/task-stop.js +3 -3
- package/dist/tools/builtins/sandbox/write.js +33 -30
- package/dist/tools/builtins/skill-load.d.ts +8 -5
- package/dist/tools/builtins/skill-load.js +21 -15
- package/dist/tools/builtins/skill-write.js +2 -2
- package/dist/tools/pipeline.js +2 -0
- package/dist/tools/registry.d.ts +21 -4
- package/dist/tools/registry.js +10 -2
- package/package.json +5 -2
- package/src/migrations/0093_connected_environments.sql +37 -0
- 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, "
|
|
21
|
-
/**
|
|
22
|
-
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
70
|
-
|
|
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
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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) => `${
|
|
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
|
|
38
|
-
/**
|
|
39
|
-
|
|
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
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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`:
|
|
101
|
-
export function checkWritable(path) {
|
|
102
|
-
const p = checkPath(path, "file_path");
|
|
103
|
-
if (p ===
|
|
104
|
-
throw new ToolError(`Path ${clip(p, 200)} is read-only (
|
|
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 的附件复制到
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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) ??
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 {
|