@cjhyy/code-shell-core 0.7.0 → 0.7.1
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/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -197,6 +197,7 @@ export class FalVideoProvider {
|
|
|
197
197
|
const videoUrl = j.video?.url;
|
|
198
198
|
if (!videoUrl)
|
|
199
199
|
return { ok: false, error: "fal result: no video.url" };
|
|
200
|
+
req.onUrl?.(videoUrl);
|
|
200
201
|
// hop 2: video bytes
|
|
201
202
|
const vr = await this.fetchImpl(videoUrl, { method: "GET", signal: req.signal });
|
|
202
203
|
if (!vr.ok) {
|
|
@@ -13,6 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { ToolDefinition } from "../../types.js";
|
|
15
15
|
import type { ToolContext } from "../context.js";
|
|
16
|
-
import type {
|
|
16
|
+
import type { BuiltinToolReturn } from "./index.js";
|
|
17
17
|
export declare const viewImageToolDef: ToolDefinition;
|
|
18
|
-
export declare function viewImageTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<
|
|
18
|
+
export declare function viewImageTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<BuiltinToolReturn>;
|
|
@@ -25,6 +25,7 @@ export const webFetchToolDef = {
|
|
|
25
25
|
};
|
|
26
26
|
const MAX_OUTPUT = 100_000;
|
|
27
27
|
const DEFAULT_MAX = 50_000;
|
|
28
|
+
const RAW_BYTE_BUDGET_MULTIPLIER = 4;
|
|
28
29
|
// Header names that must not be overridable via args.headers (SSRF / auth injection)
|
|
29
30
|
const BLOCKED_REQUEST_HEADERS = new Set([
|
|
30
31
|
"host", "authorization", "cookie", "proxy-authorization",
|
|
@@ -252,7 +253,46 @@ export async function webFetchTool(args) {
|
|
|
252
253
|
}
|
|
253
254
|
async function readAndTruncateBody(res, maxLength) {
|
|
254
255
|
const contentType = res.headers.get("content-type") ?? "";
|
|
255
|
-
const
|
|
256
|
+
const maxBytes = maxLength * RAW_BYTE_BUDGET_MULTIPLIER;
|
|
257
|
+
const reader = res.body?.getReader();
|
|
258
|
+
let body;
|
|
259
|
+
let rawTruncated = false;
|
|
260
|
+
if (!reader) {
|
|
261
|
+
body = await res.text();
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const decoder = new TextDecoder("utf-8");
|
|
265
|
+
const chunks = [];
|
|
266
|
+
let bytesRead = 0;
|
|
267
|
+
try {
|
|
268
|
+
for (;;) {
|
|
269
|
+
const { done, value } = await reader.read();
|
|
270
|
+
if (done)
|
|
271
|
+
break;
|
|
272
|
+
const remaining = maxBytes - bytesRead;
|
|
273
|
+
if (value.byteLength >= remaining) {
|
|
274
|
+
chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }));
|
|
275
|
+
bytesRead += remaining;
|
|
276
|
+
rawTruncated = true;
|
|
277
|
+
await reader.cancel();
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
281
|
+
bytesRead += value.byteLength;
|
|
282
|
+
}
|
|
283
|
+
const tail = decoder.decode();
|
|
284
|
+
// When the byte budget cuts through a multi-byte code point, flushing
|
|
285
|
+
// the decoder would turn that intentionally incomplete suffix into �.
|
|
286
|
+
// All complete code points were already emitted by stream:true, so the
|
|
287
|
+
// truncated tail is safe to discard.
|
|
288
|
+
if (!rawTruncated)
|
|
289
|
+
chunks.push(tail);
|
|
290
|
+
body = chunks.join("");
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
reader.releaseLock();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
256
296
|
let text;
|
|
257
297
|
if (contentType.includes("text/html") || contentType.includes("application/xhtml")) {
|
|
258
298
|
text = extractTextFromHTML(body);
|
|
@@ -260,8 +300,9 @@ async function readAndTruncateBody(res, maxLength) {
|
|
|
260
300
|
else {
|
|
261
301
|
text = body;
|
|
262
302
|
}
|
|
263
|
-
if (text.length > maxLength) {
|
|
264
|
-
|
|
303
|
+
if (rawTruncated || text.length > maxLength) {
|
|
304
|
+
const shownChars = Math.min(text.length, maxLength);
|
|
305
|
+
text = text.slice(0, maxLength) + `\n\n... content truncated (showing first ${shownChars} chars)`;
|
|
265
306
|
}
|
|
266
307
|
return text || "(page returned empty content)";
|
|
267
308
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { isAbsolute, resolve } from "node:path";
|
|
6
|
-
import { createWorktree, removeWorktree, listWorktrees, validateWorktreeSlug, selectPlatformScript, runWorktreeSetup, worktreeHasUncommittedOrAheadChanges, currentBranch, } from "../../git/worktree.js";
|
|
6
|
+
import { createWorktree, removeWorktree, listWorktrees, validateWorktreeSlug, selectPlatformScript, runWorktreeSetup, worktreeHasUncommittedOrAheadChanges, currentBranch, cleanupAbortedWorktree, } from "../../git/worktree.js";
|
|
7
7
|
export const switchSessionWorkspaceToolDef = {
|
|
8
8
|
name: "SwitchSessionWorkspace",
|
|
9
9
|
description: "Switch this current conversation session into or out of a workspace through the host UI path. " +
|
|
@@ -44,7 +44,7 @@ export async function switchSessionWorkspaceTool(args, ctx) {
|
|
|
44
44
|
nextTurnNotice(workspace.root, ctx?.cwd ?? workspace.root));
|
|
45
45
|
}
|
|
46
46
|
catch (err) {
|
|
47
|
-
return `Error switching workspace: ${err.message}`;
|
|
47
|
+
return `Error: switching workspace failed: ${err.message}`;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
export const enterWorktreeToolDef = {
|
|
@@ -86,6 +86,7 @@ export async function enterWorktreeTool(args, ctx) {
|
|
|
86
86
|
const branchPrefix = ctx?.engine?.readWorktreeBranchPrefix?.(mainRoot);
|
|
87
87
|
try {
|
|
88
88
|
if (target === "main") {
|
|
89
|
+
ctx?.signal?.throwIfAborted();
|
|
89
90
|
const workspace = { root: mainRoot, kind: "main" };
|
|
90
91
|
persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
|
|
91
92
|
sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
|
|
@@ -101,13 +102,26 @@ export async function enterWorktreeTool(args, ctx) {
|
|
|
101
102
|
sessionId,
|
|
102
103
|
currentWorkspace: fromWorkspace,
|
|
103
104
|
branchPrefix,
|
|
105
|
+
signal: ctx?.signal,
|
|
104
106
|
});
|
|
107
|
+
if (ctx?.signal?.aborted) {
|
|
108
|
+
if (selected.created) {
|
|
109
|
+
await cleanupAbortedWorktree(mainRoot, selected.session.worktreePath, selected.session.worktreeBranch);
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Worktree creation aborted");
|
|
112
|
+
}
|
|
105
113
|
const workspace = toSessionWorkspace(selected, fromWorkspace);
|
|
106
|
-
persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
|
|
107
|
-
sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
|
|
108
114
|
const setupNote = selected.created
|
|
109
115
|
? await runSetupIfConfigured(selected.session.worktreePath, mainRoot, ctx)
|
|
110
116
|
: "";
|
|
117
|
+
if (ctx?.signal?.aborted) {
|
|
118
|
+
if (selected.created) {
|
|
119
|
+
await cleanupAbortedWorktree(mainRoot, selected.session.worktreePath, selected.session.worktreeBranch);
|
|
120
|
+
}
|
|
121
|
+
throw new Error("Worktree creation aborted");
|
|
122
|
+
}
|
|
123
|
+
persistSessionWorkspace(sessionManager, sessionId, workspace, ctx);
|
|
124
|
+
sessionManager.recordWorkspaceHandoff(sessionId, fromWorkspace, workspace);
|
|
111
125
|
const verb = selected.created ? "Worktree created and switched" : "Switched to worktree";
|
|
112
126
|
return (`${verb}:\n` +
|
|
113
127
|
` Path: ${workspace.worktree.path}\n` +
|
|
@@ -118,7 +132,7 @@ export async function enterWorktreeTool(args, ctx) {
|
|
|
118
132
|
setupNote);
|
|
119
133
|
}
|
|
120
134
|
catch (err) {
|
|
121
|
-
return `Error switching worktree: ${err.message}`;
|
|
135
|
+
return `Error: switching worktree failed: ${err.message}`;
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
/** Keep setup output from bloating the tool result — head+tail-ish trim. */
|
|
@@ -229,7 +243,7 @@ export async function exitWorktreeTool(args, ctx) {
|
|
|
229
243
|
nextTurnNotice(mainRoot, currentTurnRoot));
|
|
230
244
|
}
|
|
231
245
|
catch (err) {
|
|
232
|
-
return `Error exiting worktree: ${err.message}`;
|
|
246
|
+
return `Error: exiting worktree failed: ${err.message}`;
|
|
233
247
|
}
|
|
234
248
|
}
|
|
235
249
|
function sessionServices(ctx) {
|
|
@@ -261,6 +275,7 @@ function sharedWorktreeRemovalSkippedMessage(otherOwners, mainRoot, currentTurnR
|
|
|
261
275
|
}
|
|
262
276
|
async function resolveWorktreeTarget(opts) {
|
|
263
277
|
const entries = await listWorktrees(opts.mainRoot);
|
|
278
|
+
opts.signal?.throwIfAborted();
|
|
264
279
|
const pathTarget = pathLike(opts.target) ? resolvePathTarget(opts.target, opts.cwd) : undefined;
|
|
265
280
|
const branchTarget = normalizeBranchName(opts.target);
|
|
266
281
|
const match = entries.find((entry) => {
|
|
@@ -269,6 +284,8 @@ async function resolveWorktreeTarget(opts) {
|
|
|
269
284
|
return entry.branch === branchTarget;
|
|
270
285
|
});
|
|
271
286
|
if (match) {
|
|
287
|
+
const originalBranch = await currentBranch(opts.mainRoot);
|
|
288
|
+
opts.signal?.throwIfAborted();
|
|
272
289
|
return {
|
|
273
290
|
created: false,
|
|
274
291
|
session: {
|
|
@@ -276,7 +293,7 @@ async function resolveWorktreeTarget(opts) {
|
|
|
276
293
|
worktreePath: match.path,
|
|
277
294
|
worktreeName: match.path.split(/[\\/]/).pop() ?? match.branch,
|
|
278
295
|
worktreeBranch: match.branch,
|
|
279
|
-
originalBranch
|
|
296
|
+
originalBranch,
|
|
280
297
|
sessionId: opts.sessionId,
|
|
281
298
|
createdAt: Date.now(),
|
|
282
299
|
},
|
|
@@ -289,6 +306,7 @@ async function resolveWorktreeTarget(opts) {
|
|
|
289
306
|
validateWorktreeSlug(opts.target);
|
|
290
307
|
const created = await createWorktree(opts.mainRoot, opts.target, opts.sessionId, {
|
|
291
308
|
prefix: opts.branchPrefix,
|
|
309
|
+
signal: opts.signal,
|
|
292
310
|
});
|
|
293
311
|
return { created: true, session: created, from: created.originalBranch ?? "HEAD" };
|
|
294
312
|
}
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ToolDefinition } from "../../types.js";
|
|
5
5
|
import type { ToolContext } from "../context.js";
|
|
6
|
+
import type { ToolFailure } from "./index.js";
|
|
6
7
|
export declare const writeToolDef: ToolDefinition;
|
|
7
|
-
export declare function writeTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string>;
|
|
8
|
+
export declare function writeTool(args: Record<string, unknown>, ctx?: ToolContext): Promise<string | ToolFailure>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in Write file tool.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
6
6
|
import { fileCache } from "./file-cache.js";
|
|
7
|
+
import { getFinalWritePathSnapshot, revalidateFinalWritePath, writeFileNoFollow, } from "../path-policy.js";
|
|
7
8
|
export const writeToolDef = {
|
|
8
9
|
name: "Write",
|
|
9
10
|
description: "Write content to a file. Creates the file if it doesn't exist, " +
|
|
@@ -27,12 +28,21 @@ export async function writeTool(args, ctx) {
|
|
|
27
28
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
28
29
|
const filePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd);
|
|
32
|
+
const beforeMkdir = revalidateFinalWritePath(filePath, cwd, approvedPath);
|
|
33
|
+
if ("error" in beforeMkdir)
|
|
34
|
+
return { ok: false, error: beforeMkdir.error };
|
|
35
|
+
await mkdir(dirname(beforeMkdir.resolvedPath), { recursive: true });
|
|
36
|
+
// mkdir may have crossed an existing symlink in a missing parent chain;
|
|
37
|
+
// resolve again immediately before opening the final file.
|
|
38
|
+
const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
|
|
39
|
+
if ("error" in beforeWrite)
|
|
40
|
+
return { ok: false, error: beforeWrite.error };
|
|
41
|
+
await writeFileNoFollow(beforeWrite.resolvedPath, content);
|
|
32
42
|
fileCache.invalidate(filePath);
|
|
33
43
|
return `Successfully wrote to ${filePath}`;
|
|
34
44
|
}
|
|
35
45
|
catch (err) {
|
|
36
|
-
return `Error writing file: ${err.message}
|
|
46
|
+
return { ok: false, error: `Error writing file: ${err.message}` };
|
|
37
47
|
}
|
|
38
48
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Tools that don't need any context (Read/Write/Bash/...) just ignore
|
|
13
13
|
* the second argument; the type is purely additive.
|
|
14
14
|
*/
|
|
15
|
-
import type { LLMConfig, StreamCallback } from "../types.js";
|
|
15
|
+
import type { LLMConfig, StreamCallback, TokenUsage } from "../types.js";
|
|
16
16
|
import type { ModelPool } from "../llm/model-pool.js";
|
|
17
17
|
import type { ToolRegistry } from "./registry.js";
|
|
18
18
|
import type { AgentPresetName } from "../preset/index.js";
|
|
@@ -20,6 +20,7 @@ import type { SandboxBackend } from "./sandbox/index.js";
|
|
|
20
20
|
import type { HookRegistry } from "../hooks/registry.js";
|
|
21
21
|
import type { SessionManager } from "../session/session-manager.js";
|
|
22
22
|
import type { SessionWorkspace } from "../types.js";
|
|
23
|
+
import type { ApprovalRouter } from "./permission.js";
|
|
23
24
|
/**
|
|
24
25
|
* Narrow view of the owning Engine that tools are allowed to call back into.
|
|
25
26
|
* Defined here (in the low-level tool-system) rather than importing the
|
|
@@ -154,10 +155,7 @@ export interface SubAgentSpawner {
|
|
|
154
155
|
* (or `resumeSessionId` when resuming), so the parent can address follow-up
|
|
155
156
|
* input at the same session via AgentSendInput (transcript replay).
|
|
156
157
|
*/
|
|
157
|
-
spawn(req: SubAgentSpawnRequest): Promise<
|
|
158
|
-
text: string;
|
|
159
|
-
sessionId: string;
|
|
160
|
-
}>;
|
|
158
|
+
spawn(req: SubAgentSpawnRequest): Promise<SubAgentSpawnResult>;
|
|
161
159
|
/**
|
|
162
160
|
* Whether a child session id exists on disk. Lets AgentSendInput resume an
|
|
163
161
|
* agent across a process restart (when the in-memory registry is empty but
|
|
@@ -173,6 +171,12 @@ export interface SubAgentSpawner {
|
|
|
173
171
|
permissionMode: string;
|
|
174
172
|
};
|
|
175
173
|
}
|
|
174
|
+
export interface SubAgentSpawnResult {
|
|
175
|
+
text: string;
|
|
176
|
+
sessionId: string;
|
|
177
|
+
/** Complete billed usage returned by the child Engine run. */
|
|
178
|
+
usage?: TokenUsage;
|
|
179
|
+
}
|
|
176
180
|
/**
|
|
177
181
|
* The full context object handed to every tool invocation.
|
|
178
182
|
*
|
|
@@ -247,6 +251,14 @@ export interface ToolContext {
|
|
|
247
251
|
* headless/test mode; tools must tolerate absence.
|
|
248
252
|
*/
|
|
249
253
|
streamCallback?: StreamCallback;
|
|
254
|
+
/**
|
|
255
|
+
* Report provider usage produced by a tool or child Engine into the owning
|
|
256
|
+
* session and run-scoped Goal budget. This must not invoke the process-wide
|
|
257
|
+
* billing hook again; the originating provider client already did that.
|
|
258
|
+
*/
|
|
259
|
+
recordBilledUsage?: (usage: TokenUsage) => void;
|
|
260
|
+
/** Connection-scoped approval owner router supplied by the protocol host. */
|
|
261
|
+
approvalRouter?: ApprovalRouter;
|
|
250
262
|
/** Whether the owning Engine is currently in plan mode. Replaces the
|
|
251
263
|
* removed module-level `isInPlanMode()` singleton. */
|
|
252
264
|
planMode: boolean;
|
|
@@ -9,7 +9,7 @@ import { logger as rootLogger, getCurrentSid } from "../logging/logger.js";
|
|
|
9
9
|
import { recordToolCall, recordToolResult } from "../logging/session-recorder.js";
|
|
10
10
|
import { validateToolArgs } from "./validation.js";
|
|
11
11
|
import { PLAN_MODE_ALLOWED_TOOLS } from "./plan-mode-allowlist.js";
|
|
12
|
-
import { enforcePathPolicyWithApproval } from "./path-policy.js";
|
|
12
|
+
import { attachFinalWritePathSnapshot, enforcePathPolicyWithApproval, } from "./path-policy.js";
|
|
13
13
|
import { parsePatch } from "./builtin/apply-patch/parser.js";
|
|
14
14
|
import { BUILTIN_TOOL_GUARDS } from "./builtin/index.js";
|
|
15
15
|
import { COMPLETE_GOAL_TOOL_NAME } from "./builtin/complete-goal.js";
|
|
@@ -244,6 +244,7 @@ export class ToolExecutor {
|
|
|
244
244
|
toolName: call.toolName,
|
|
245
245
|
args: call.args,
|
|
246
246
|
toolCallId: call.id,
|
|
247
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
247
248
|
});
|
|
248
249
|
if (hookResult.decision === "deny") {
|
|
249
250
|
return {
|
|
@@ -280,6 +281,16 @@ export class ToolExecutor {
|
|
|
280
281
|
// path surface on RegisteredTool.pathPolicy; the executor enforces it
|
|
281
282
|
// after hooks have had a chance to rewrite args, but before permission
|
|
282
283
|
// classification or the handler can touch the filesystem.
|
|
284
|
+
if ((call.toolName === "Write" || call.toolName === "Edit") &&
|
|
285
|
+
this.toolCtx?.cwd &&
|
|
286
|
+
typeof call.args.file_path === "string") {
|
|
287
|
+
const raw = call.args.file_path;
|
|
288
|
+
const target = isAbsolutePath(raw) ? raw : resolvePath(this.toolCtx.cwd, raw);
|
|
289
|
+
call = {
|
|
290
|
+
...call,
|
|
291
|
+
args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
283
294
|
const pathPolicyError = await this.enforceDeclaredPathPolicy(toolDef, call.args);
|
|
284
295
|
if (pathPolicyError) {
|
|
285
296
|
return {
|
|
@@ -325,6 +336,7 @@ export class ToolExecutor {
|
|
|
325
336
|
args: call.args,
|
|
326
337
|
toolCallId: call.id,
|
|
327
338
|
classifierDecision,
|
|
339
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
328
340
|
});
|
|
329
341
|
const preClamped = clampHookDecision(classifierDecision, hookResult.decision);
|
|
330
342
|
let decision = preClamped.decision;
|
|
@@ -383,6 +395,7 @@ export class ToolExecutor {
|
|
|
383
395
|
toolName: call.toolName,
|
|
384
396
|
args: call.args,
|
|
385
397
|
toolCallId: call.id,
|
|
398
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
386
399
|
});
|
|
387
400
|
// 3. Execute. Use a span so begin/end share one cat and end carries
|
|
388
401
|
// duration_ms; widen args truncation from 200→2000 so Edit/Write payloads
|
|
@@ -431,25 +444,25 @@ export class ToolExecutor {
|
|
|
431
444
|
result.id = call.id;
|
|
432
445
|
// Prepend any non-blocking guard reminder onto a successful result so the
|
|
433
446
|
// model sees it on its next turn alongside the content it just fetched.
|
|
434
|
-
if (guardDecision?.prepend && !result.
|
|
447
|
+
if (guardDecision?.prepend && !result.isError && result.result) {
|
|
435
448
|
result.result = `${guardDecision.prepend}\n${result.result}`;
|
|
436
449
|
}
|
|
437
450
|
const observerResult = toolResultDisplayText(result);
|
|
438
451
|
const payload = observerResult ?? result.error ?? "";
|
|
439
452
|
span.end({
|
|
440
|
-
ok: !result.
|
|
453
|
+
ok: !result.isError,
|
|
441
454
|
chars: payload.length,
|
|
442
455
|
// Snippet only on failure — successful tool output can be huge and is
|
|
443
456
|
// already on the transcript. Failures are rare and the first 500 chars
|
|
444
457
|
// usually contain the message we need.
|
|
445
|
-
...(result.
|
|
458
|
+
...(result.isError ? { errorSnippet: payload.slice(0, 500) } : {}),
|
|
446
459
|
});
|
|
447
460
|
recordToolResult(sid, {
|
|
448
461
|
id: call.id,
|
|
449
462
|
toolName: call.toolName,
|
|
450
|
-
ok: !result.
|
|
463
|
+
ok: !result.isError,
|
|
451
464
|
durationMs: Date.now() - toolStartedAt,
|
|
452
|
-
output: result.
|
|
465
|
+
output: result.isError ? undefined : observerResult,
|
|
453
466
|
error: result.error,
|
|
454
467
|
});
|
|
455
468
|
// 4. Post-tool hook
|
|
@@ -458,6 +471,7 @@ export class ToolExecutor {
|
|
|
458
471
|
toolCallId: call.id,
|
|
459
472
|
result: observerResult,
|
|
460
473
|
error: result.error,
|
|
474
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
461
475
|
});
|
|
462
476
|
// 5. Post-tool-use hook (after execution, can observe/modify result)
|
|
463
477
|
const postHook = await this.hooks.emit("post_tool_use", {
|
|
@@ -465,13 +479,14 @@ export class ToolExecutor {
|
|
|
465
479
|
toolCallId: call.id,
|
|
466
480
|
result: observerResult,
|
|
467
481
|
error: result.error,
|
|
482
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
468
483
|
});
|
|
469
484
|
// Append handler-supplied context (linter output, type-check result,
|
|
470
485
|
// etc.) onto the tool result so the model sees it on the next turn.
|
|
471
486
|
// Tagged with a separator so the model can tell hook output from
|
|
472
487
|
// tool output. Skip when the tool errored — additional context on a
|
|
473
488
|
// failed tool would be confusing.
|
|
474
|
-
if (postHook.additionalContext && !result.
|
|
489
|
+
if (postHook.additionalContext && !result.isError) {
|
|
475
490
|
const tag = "--- additional context from post_tool_use hook ---";
|
|
476
491
|
result.result = result.result
|
|
477
492
|
? `${result.result}\n\n${tag}\n${postHook.additionalContext}`
|
|
@@ -484,10 +499,11 @@ export class ToolExecutor {
|
|
|
484
499
|
});
|
|
485
500
|
}
|
|
486
501
|
// 6. file_changed hook for Write/Edit tools
|
|
487
|
-
if ((call.toolName === "Write" || call.toolName === "Edit") && !result.
|
|
502
|
+
if ((call.toolName === "Write" || call.toolName === "Edit") && !result.isError) {
|
|
488
503
|
await this.hooks.emit("file_changed", {
|
|
489
504
|
toolName: call.toolName,
|
|
490
505
|
filePath: call.args.file_path,
|
|
506
|
+
signal: this.signal ?? this.toolCtx?.signal,
|
|
491
507
|
});
|
|
492
508
|
}
|
|
493
509
|
return result;
|
|
@@ -48,6 +48,12 @@ export declare function buildStdioEnv(serverName: string, config: MCPServerConfi
|
|
|
48
48
|
export declare function buildHttpHeaders(serverName: string, config: MCPServerConfig, resolveCredential?: (id: string) => HttpCredentialResolverResult, options?: BuildHttpHeadersOptions): Record<string, string>;
|
|
49
49
|
export declare function bearerTokenFromMcpCredential(serverName: string, credentialId: string, credential: ResolvedMcpCredential, options?: BuildHttpHeadersOptions): string;
|
|
50
50
|
export declare function buildHttpHeadersWithCredentialAccess(serverName: string, config: MCPServerConfig, access?: Pick<CredentialAccess, "resolveValue"> & Partial<Pick<CredentialAccess, "resolveMeta">>, options?: BuildHttpHeadersOptions): Promise<Record<string, string>>;
|
|
51
|
+
/**
|
|
52
|
+
* Fetch adapter for long-lived HTTP MCP transports. OAuth access tokens are
|
|
53
|
+
* resolved for every request, refreshed by the host inside the skew window,
|
|
54
|
+
* and force-refreshed once after a replayable 401.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createMcpAuthenticatedFetch(serverName: string, config: MCPServerConfig, access?: CredentialAccess, baseFetch?: typeof fetch): typeof fetch;
|
|
51
57
|
/**
|
|
52
58
|
* Infer the transport when the config doesn't name one: a url-only entry is
|
|
53
59
|
* HTTP, everything else stdio. This is the CC `.mcp.json` convention —
|
|
@@ -11,6 +11,7 @@ import { getCredentialAccess } from "../credentials/access.js";
|
|
|
11
11
|
import { buildOAuthRefreshRequest, oauthCredentialStatus, parseOAuthCredentialSecret, } from "../credentials/oauth.js";
|
|
12
12
|
import { ENV_ALLOWLIST } from "../runtime/spawn-common.js";
|
|
13
13
|
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { join } from "node:path";
|
|
15
16
|
import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
|
|
16
17
|
import { codeShellHome } from "../session/session-manager.js";
|
|
@@ -153,6 +154,55 @@ export async function buildHttpHeadersWithCredentialAccess(serverName, config, a
|
|
|
153
154
|
});
|
|
154
155
|
return buildHttpHeaders(serverName, config, (id) => id === config.credentialRef ? { secret, type: meta?.type, label: meta?.label } : undefined, options);
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Fetch adapter for long-lived HTTP MCP transports. OAuth access tokens are
|
|
159
|
+
* resolved for every request, refreshed by the host inside the skew window,
|
|
160
|
+
* and force-refreshed once after a replayable 401.
|
|
161
|
+
*/
|
|
162
|
+
export function createMcpAuthenticatedFetch(serverName, config, access = getCredentialAccess(), baseFetch = fetch) {
|
|
163
|
+
return (async (input, init) => {
|
|
164
|
+
const credentialId = config.credentialRef;
|
|
165
|
+
const meta = credentialId ? access.resolveMeta(undefined, credentialId, "full") : undefined;
|
|
166
|
+
const isOAuth = meta?.type === "oauth";
|
|
167
|
+
const headers = isOAuth
|
|
168
|
+
? buildHttpHeaders(serverName, { ...config, credentialRef: undefined })
|
|
169
|
+
: await buildHttpHeadersWithCredentialAccess(serverName, config, access);
|
|
170
|
+
let token;
|
|
171
|
+
if (isOAuth) {
|
|
172
|
+
if (!access.resolveOAuthAccess) {
|
|
173
|
+
throw new Error(`MCP server "${serverName}": oauth credential "${credentialId}" requires host OAuth access`);
|
|
174
|
+
}
|
|
175
|
+
token = (await access.resolveOAuthAccess({ id: credentialId, scope: "full" })).accessToken;
|
|
176
|
+
}
|
|
177
|
+
const source = new Request(input, init);
|
|
178
|
+
let replayable = true;
|
|
179
|
+
try {
|
|
180
|
+
source.clone();
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
replayable = false;
|
|
184
|
+
}
|
|
185
|
+
const send = async (accessToken) => {
|
|
186
|
+
const request = replayable ? source.clone() : source;
|
|
187
|
+
const requestHeaders = new Headers(request.headers);
|
|
188
|
+
for (const [name, value] of Object.entries(headers))
|
|
189
|
+
requestHeaders.set(name, value);
|
|
190
|
+
if (accessToken)
|
|
191
|
+
requestHeaders.set("Authorization", `Bearer ${accessToken}`);
|
|
192
|
+
return baseFetch(new Request(request, { headers: requestHeaders }));
|
|
193
|
+
};
|
|
194
|
+
const first = await send(token);
|
|
195
|
+
if (first.status !== 401 || !isOAuth || !credentialId || !replayable || source.signal.aborted) {
|
|
196
|
+
return first;
|
|
197
|
+
}
|
|
198
|
+
const refreshed = await access.resolveOAuthAccess({
|
|
199
|
+
id: credentialId,
|
|
200
|
+
scope: "full",
|
|
201
|
+
forceRefresh: true,
|
|
202
|
+
});
|
|
203
|
+
return send(refreshed.accessToken);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
156
206
|
/**
|
|
157
207
|
* Infer the transport when the config doesn't name one: a url-only entry is
|
|
158
208
|
* HTTP, everything else stdio. This is the CC `.mcp.json` convention —
|
|
@@ -186,9 +236,7 @@ const MAX_MCP_IMAGE_SPILLS_PER_TOOL = 50;
|
|
|
186
236
|
const MAX_MCP_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
187
237
|
function spillTimestamp(filename, prefix) {
|
|
188
238
|
const suffix = filename.slice(prefix.length);
|
|
189
|
-
const
|
|
190
|
-
const raw = dot === -1 ? suffix : suffix.slice(0, dot);
|
|
191
|
-
const timestamp = Number(raw);
|
|
239
|
+
const timestamp = Number(/^\d+/.exec(suffix)?.[0]);
|
|
192
240
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
193
241
|
}
|
|
194
242
|
async function pruneMcpImageSpills(baseDir, prefix) {
|
|
@@ -232,7 +280,7 @@ export async function spillMcpImage(serverName, toolName, base64, mimeType, opts
|
|
|
232
280
|
const safeServer = serverName.replace(/[^\w.-]+/g, "_");
|
|
233
281
|
const safeTool = toolName.replace(/[^\w.-]+/g, "_");
|
|
234
282
|
const prefix = `${safeServer}-${safeTool}-`;
|
|
235
|
-
const filename = `${prefix}${now()}.${ext}`;
|
|
283
|
+
const filename = `${prefix}${now()}-${randomUUID().slice(0, 8)}.${ext}`;
|
|
236
284
|
const filePath = join(baseDir, filename);
|
|
237
285
|
try {
|
|
238
286
|
await mkdir(baseDir, { recursive: true });
|
|
@@ -472,9 +520,11 @@ export class MCPManager {
|
|
|
472
520
|
// credentialRef resolves against user-scope credential access. The shared
|
|
473
521
|
// MCPManager pool has no per-session cwd, and MCP-referenced credentials
|
|
474
522
|
// (e.g. a Figma token) remain user-global by design.
|
|
475
|
-
const
|
|
523
|
+
const access = getCredentialAccess();
|
|
524
|
+
const headers = buildHttpHeaders(name, { ...config, credentialRef: undefined });
|
|
476
525
|
transport = new StreamableHTTPClientTransport(new URL(config.url), {
|
|
477
526
|
requestInit: Object.keys(headers).length ? { headers } : undefined,
|
|
527
|
+
fetch: createMcpAuthenticatedFetch(name, config, access),
|
|
478
528
|
});
|
|
479
529
|
}
|
|
480
530
|
else {
|
|
@@ -49,11 +49,30 @@ export interface ClassifyOptions {
|
|
|
49
49
|
/** "read" or "write" — different defaults for sensitive paths. */
|
|
50
50
|
operation: PathOperation;
|
|
51
51
|
}
|
|
52
|
+
export interface FinalWritePathSnapshot {
|
|
53
|
+
resolvedPath: string;
|
|
54
|
+
workspacePath: string;
|
|
55
|
+
insideWorkspace: boolean;
|
|
56
|
+
}
|
|
52
57
|
export type PathApprovalScope = "once" | "session" | "project";
|
|
53
58
|
/** Test seam: clear the in-memory session grants. */
|
|
54
59
|
export declare function _resetSessionPathGrants(): void;
|
|
55
60
|
export declare function openSessionPathApprovals(sessionId: string): void;
|
|
56
61
|
export declare function clearSessionPathApprovals(sessionId: string): void;
|
|
62
|
+
/**
|
|
63
|
+
* Capture the concrete target approved by the executor's first path-policy
|
|
64
|
+
* pass. The snapshot is carried on the internal args object via a symbol, so
|
|
65
|
+
* it cannot collide with an LLM-supplied schema property or leak into logs.
|
|
66
|
+
*/
|
|
67
|
+
export declare function attachFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): Record<string, unknown>;
|
|
68
|
+
export declare function getFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string): FinalWritePathSnapshot;
|
|
69
|
+
export declare function revalidateFinalWritePath(filePath: string, workspaceRoot: string, approved: FinalWritePathSnapshot): {
|
|
70
|
+
resolvedPath: string;
|
|
71
|
+
} | {
|
|
72
|
+
error: string;
|
|
73
|
+
};
|
|
74
|
+
/** Open the already-revalidated final path without following its last segment. */
|
|
75
|
+
export declare function writeFileNoFollow(filePath: string, content: string): Promise<void>;
|
|
57
76
|
/**
|
|
58
77
|
* Classify a file path against the workspace + sensitive-path policy.
|
|
59
78
|
*
|
|
@@ -29,10 +29,12 @@
|
|
|
29
29
|
* approval round-trip; it is not an authority to write anywhere on disk.
|
|
30
30
|
* Callers must consult classifyPath before honoring acceptEdits.
|
|
31
31
|
*/
|
|
32
|
-
import { realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from "node:fs";
|
|
32
|
+
import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, } from "node:fs";
|
|
33
|
+
import { open } from "node:fs/promises";
|
|
33
34
|
import { homedir } from "node:os";
|
|
34
35
|
import { randomUUID } from "node:crypto";
|
|
35
36
|
import { dirname, isAbsolute, join, resolve as resolvePath, sep } from "node:path";
|
|
37
|
+
const FINAL_WRITE_PATH_SNAPSHOT = Symbol("codeshell.finalWritePathSnapshot");
|
|
36
38
|
/**
|
|
37
39
|
* Default sensitive path patterns. These are evaluated AFTER home-expansion
|
|
38
40
|
* and resolution, so a literal "$HOME/.ssh" and a symlink at /tmp/x → ~/.ssh
|
|
@@ -325,6 +327,65 @@ function safeRealpath(p) {
|
|
|
325
327
|
}
|
|
326
328
|
return abs;
|
|
327
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Capture the concrete target approved by the executor's first path-policy
|
|
332
|
+
* pass. The snapshot is carried on the internal args object via a symbol, so
|
|
333
|
+
* it cannot collide with an LLM-supplied schema property or leak into logs.
|
|
334
|
+
*/
|
|
335
|
+
export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot) {
|
|
336
|
+
const resolvedPath = safeRealpath(filePath);
|
|
337
|
+
const workspacePath = safeRealpath(workspaceRoot);
|
|
338
|
+
const snapshot = {
|
|
339
|
+
resolvedPath,
|
|
340
|
+
workspacePath,
|
|
341
|
+
insideWorkspace: isInsideDir(resolvedPath, workspacePath),
|
|
342
|
+
};
|
|
343
|
+
return { ...args, [FINAL_WRITE_PATH_SNAPSHOT]: snapshot };
|
|
344
|
+
}
|
|
345
|
+
export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
|
|
346
|
+
const carried = args[FINAL_WRITE_PATH_SNAPSHOT];
|
|
347
|
+
if (carried && typeof carried === "object") {
|
|
348
|
+
return carried;
|
|
349
|
+
}
|
|
350
|
+
// Direct tool calls do not pass through ToolExecutor. Preserve that API by
|
|
351
|
+
// taking the baseline at handler entry, while still protecting Edit's
|
|
352
|
+
// read-to-write interval with a second check before its writeFile.
|
|
353
|
+
const resolvedPath = safeRealpath(filePath);
|
|
354
|
+
const workspacePath = safeRealpath(workspaceRoot);
|
|
355
|
+
return {
|
|
356
|
+
resolvedPath,
|
|
357
|
+
workspacePath,
|
|
358
|
+
insideWorkspace: isInsideDir(resolvedPath, workspacePath),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
export function revalidateFinalWritePath(filePath, workspaceRoot, approved) {
|
|
362
|
+
const currentPath = safeRealpath(filePath);
|
|
363
|
+
const currentWorkspace = safeRealpath(workspaceRoot);
|
|
364
|
+
const sameTarget = normPath(currentPath) === normPath(approved.resolvedPath);
|
|
365
|
+
const sameWorkspace = normPath(currentWorkspace) === normPath(approved.workspacePath);
|
|
366
|
+
const crossedWorkspaceBoundary = approved.insideWorkspace && !isInsideDir(currentPath, currentWorkspace);
|
|
367
|
+
if (crossedWorkspaceBoundary || !sameTarget || !sameWorkspace) {
|
|
368
|
+
const reason = crossedWorkspaceBoundary
|
|
369
|
+
? "final write path resolved outside the workspace after approval"
|
|
370
|
+
: "final write path changed after approval";
|
|
371
|
+
return {
|
|
372
|
+
error: `Error: ${reason}; refusing to write. ` +
|
|
373
|
+
`Approved target: ${approved.resolvedPath}. Current target: ${currentPath}`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { resolvedPath: currentPath };
|
|
377
|
+
}
|
|
378
|
+
/** Open the already-revalidated final path without following its last segment. */
|
|
379
|
+
export async function writeFileNoFollow(filePath, content) {
|
|
380
|
+
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW;
|
|
381
|
+
const handle = await open(filePath, flags, 0o666);
|
|
382
|
+
try {
|
|
383
|
+
await handle.writeFile(content, "utf-8");
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await handle.close();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
328
389
|
function isInsideDir(child, parent) {
|
|
329
390
|
const c = normPath(child);
|
|
330
391
|
const par = normPath(parent);
|