@cjhyy/code-shell-core 0.8.13 → 0.9.0
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/automation/desktop-authority-client.d.ts +9 -0
- package/dist/automation/desktop-authority-client.js +47 -0
- package/dist/automation/scheduler.d.ts +8 -0
- package/dist/automation/scheduler.js +37 -1
- package/dist/automation/store.js +14 -1
- package/dist/capabilities/index.d.ts +1 -0
- package/dist/cli/agent-server-stdio.js +8 -4
- package/dist/engine/engine-workspace-authority.d.ts +35 -0
- package/dist/engine/engine-workspace-authority.js +136 -0
- package/dist/engine/engine.d.ts +5 -10
- package/dist/engine/engine.js +108 -112
- package/dist/engine/input-attachments.d.ts +1 -0
- package/dist/engine/input-attachments.js +6 -2
- package/dist/engine/run-environment.d.ts +8 -3
- package/dist/engine/run-environment.js +33 -8
- package/dist/engine/run-image-input.d.ts +1 -0
- package/dist/engine/run-image-input.js +1 -0
- package/dist/engine/run-session-open.d.ts +2 -1
- package/dist/engine/run-session-open.js +7 -1
- package/dist/engine/run-setup.d.ts +1 -0
- package/dist/engine/run-setup.js +2 -1
- package/dist/engine/run-tooling.d.ts +2 -0
- package/dist/engine/run-tooling.js +3 -0
- package/dist/engine/run-types.d.ts +4 -0
- package/dist/engine/run-workspace.d.ts +6 -1
- package/dist/engine/run-workspace.js +47 -0
- package/dist/engine/subagent-spawner.d.ts +1 -0
- package/dist/engine/subagent-spawner.js +1 -0
- package/dist/engine/turn-loop.js +17 -0
- package/dist/engine/types.d.ts +2 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.extension.d.ts +1 -1
- package/dist/index.internal.d.ts +3 -2
- package/dist/index.internal.js +2 -0
- package/dist/index.js +2 -2
- package/dist/llm/client-base.d.ts +2 -1
- package/dist/llm/client-base.js +3 -1
- package/dist/llm/providers/anthropic.js +24 -25
- package/dist/llm/providers/openai.d.ts +4 -1
- package/dist/llm/providers/openai.js +76 -13
- package/dist/panel-apps/index.d.ts +1 -1
- package/dist/panel-apps/index.js +1 -1
- package/dist/panel-apps/installer.d.ts +29 -0
- package/dist/panel-apps/installer.js +124 -15
- package/dist/plugins/pluginAutomationTemplates.d.ts +2 -0
- package/dist/plugins/pluginAutomationTemplates.js +4 -0
- package/dist/plugins/pluginCatalog.d.ts +6 -0
- package/dist/plugins/pluginCatalog.js +7 -2
- package/dist/plugins/pluginContent.d.ts +1 -1
- package/dist/plugins/pluginContent.js +2 -10
- package/dist/prompt/composer.d.ts +4 -1
- package/dist/prompt/composer.js +11 -3
- package/dist/protocol/chat-session-manager.d.ts +42 -1
- package/dist/protocol/chat-session-manager.js +167 -4
- package/dist/protocol/chat-session.d.ts +11 -1
- package/dist/protocol/chat-session.js +20 -2
- package/dist/protocol/mobile-remote-types.d.ts +15 -0
- package/dist/protocol/server.d.ts +1 -2
- package/dist/protocol/server.js +28 -51
- package/dist/protocol/session-workspace-rpc.d.ts +23 -0
- package/dist/protocol/session-workspace-rpc.js +171 -0
- package/dist/protocol/types.d.ts +47 -1
- package/dist/protocol/types.js +4 -0
- package/dist/session/session-manager.d.ts +25 -0
- package/dist/session/session-manager.js +106 -6
- package/dist/session/transcript.d.ts +9 -0
- package/dist/session/transcript.js +81 -0
- package/dist/settings/manager.d.ts +12 -0
- package/dist/settings/manager.js +31 -0
- package/dist/tool-system/builtin/configure-model-connection.d.ts +36 -0
- package/dist/tool-system/builtin/configure-model-connection.js +396 -0
- package/dist/tool-system/builtin/cron.d.ts +11 -0
- package/dist/tool-system/builtin/cron.js +27 -2
- package/dist/tool-system/builtin/edit-model-catalog.d.ts +4 -6
- package/dist/tool-system/builtin/edit-model-catalog.js +5 -8
- package/dist/tool-system/builtin/edit.js +5 -3
- package/dist/tool-system/builtin/index.js +14 -0
- package/dist/tool-system/builtin/install-capability.js +22 -8
- package/dist/tool-system/builtin/settings-changed.d.ts +9 -0
- package/dist/tool-system/builtin/settings-changed.js +18 -0
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +12 -3
- package/dist/tool-system/executor.js +1 -1
- package/dist/tool-system/path-policy.d.ts +11 -3
- package/dist/tool-system/path-policy.js +56 -36
- package/dist/types.d.ts +11 -0
- package/dist/workspace/canonical-key.d.ts +7 -0
- package/dist/workspace/canonical-key.js +39 -0
- package/dist/workspace/workspace-context.d.ts +26 -0
- package/dist/workspace/workspace-context.js +112 -0
- package/package.json +1 -1
|
@@ -28,6 +28,7 @@ import { resolveExecutable } from "../../utils/exec.js";
|
|
|
28
28
|
const SAFE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
29
29
|
const SAFE_PLUGIN_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
30
30
|
const SAFE_SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
31
|
+
const SAFE_OVERRIDE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
31
32
|
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
32
33
|
const HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/;
|
|
33
34
|
const MCP_TOOL_NAME_RE = /^[^\s\u0000-\u001F\u007F]{1,256}$/;
|
|
@@ -338,10 +339,13 @@ function isSafeRemoteUrl(raw) {
|
|
|
338
339
|
return false;
|
|
339
340
|
return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname.toLowerCase());
|
|
340
341
|
}
|
|
341
|
-
async function installMarketplacePlugin(args, deps) {
|
|
342
|
+
async function installMarketplacePlugin(args, ctx, deps) {
|
|
342
343
|
if (args.scope !== undefined && args.scope !== "user") {
|
|
343
344
|
return "Error: marketplace plugins currently install at user scope; omit scope or use scope='user'.";
|
|
344
345
|
}
|
|
346
|
+
if (ctx?.settingsScope !== "full") {
|
|
347
|
+
return "Error: this host isolates user settings; marketplace plugins cannot be installed.";
|
|
348
|
+
}
|
|
345
349
|
const plugin = safePluginSegment(args.plugin);
|
|
346
350
|
const marketplace = safePluginSegment(args.marketplace);
|
|
347
351
|
if (!plugin)
|
|
@@ -440,6 +444,9 @@ async function mutateMarketplacePlugin(args, ctx, deps) {
|
|
|
440
444
|
if (!deps.listInstalled().some((candidate) => candidate.key === installKey)) {
|
|
441
445
|
return `Error: plugin ${installKey} is not installed.`;
|
|
442
446
|
}
|
|
447
|
+
if ((action === "update" || action === "uninstall") && ctx?.settingsScope !== "full") {
|
|
448
|
+
return "Error: this host isolates user settings; marketplace plugins cannot be changed.";
|
|
449
|
+
}
|
|
443
450
|
if (action === "update") {
|
|
444
451
|
const refreshed = await deps.refreshMarketplace(marketplace);
|
|
445
452
|
if (!refreshed.ok) {
|
|
@@ -474,12 +481,15 @@ async function mutateMarketplacePlugin(args, ctx, deps) {
|
|
|
474
481
|
if (args.scope !== undefined && args.scope !== "project" && args.scope !== "user") {
|
|
475
482
|
return "Error: plugin enable/disable scope must be `project` or `user`.";
|
|
476
483
|
}
|
|
477
|
-
if (scope === "user" && ctx?.settingsScope
|
|
484
|
+
if (scope === "user" && ctx?.settingsScope !== "full") {
|
|
478
485
|
return "Error: this host isolates user settings; use project scope.";
|
|
479
486
|
}
|
|
480
487
|
const manager = deps.makeSettingsManager(cwd, scope === "user" ? "full" : "project");
|
|
481
488
|
const enabled = action === "enable";
|
|
482
489
|
if (scope === "project") {
|
|
490
|
+
if (!SAFE_OVERRIDE_NAME_RE.test(plugin)) {
|
|
491
|
+
return "Error: project-scoped plugin overrides do not support dots in plugin names; use user scope.";
|
|
492
|
+
}
|
|
483
493
|
manager.saveProjectSetting(`capabilityOverrides.plugins.${plugin}`, enabled ? "on" : "off", cwd);
|
|
484
494
|
}
|
|
485
495
|
else {
|
|
@@ -670,12 +680,16 @@ async function mutateProjectSkills(args, ctx, deps) {
|
|
|
670
680
|
if (args.scope !== undefined && args.scope !== "project" && args.scope !== "user") {
|
|
671
681
|
return "Error: Skill enable/disable scope must be `project` or `user`.";
|
|
672
682
|
}
|
|
673
|
-
if (scope === "user" && ctx?.settingsScope
|
|
683
|
+
if (scope === "user" && ctx?.settingsScope !== "full") {
|
|
674
684
|
return "Error: this host isolates user settings; use project scope.";
|
|
675
685
|
}
|
|
676
686
|
const manager = deps.makeSettingsManager(cwd, scope === "user" ? "full" : "project");
|
|
677
687
|
const enabled = action === "enable";
|
|
678
688
|
if (scope === "project") {
|
|
689
|
+
const unsafe = skills.filter((skill) => !SAFE_OVERRIDE_NAME_RE.test(skill));
|
|
690
|
+
if (unsafe.length > 0) {
|
|
691
|
+
return `Error: project-scoped Skill overrides do not support dots in names: ${unsafe.join(", ")}.`;
|
|
692
|
+
}
|
|
679
693
|
for (const skill of skills) {
|
|
680
694
|
manager.saveProjectSetting(`capabilityOverrides.skills.${skill}`, enabled ? "on" : "off", cwd);
|
|
681
695
|
}
|
|
@@ -806,7 +820,7 @@ async function installMcpServer(args, ctx, deps) {
|
|
|
806
820
|
if (!resolved.ok)
|
|
807
821
|
return `Error: ${resolved.error}`;
|
|
808
822
|
const scope = resolved.scope;
|
|
809
|
-
if (scope === "user" && ctx?.settingsScope
|
|
823
|
+
if (scope === "user" && ctx?.settingsScope !== "full") {
|
|
810
824
|
return "Error: this host isolates user settings; install the MCP server at local or project scope.";
|
|
811
825
|
}
|
|
812
826
|
const built = buildMcpConfig(args);
|
|
@@ -927,7 +941,7 @@ function mcpDetailLines(name, scope, config) {
|
|
|
927
941
|
}
|
|
928
942
|
function listMcpServers(ctx, deps) {
|
|
929
943
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
930
|
-
const full =
|
|
944
|
+
const full = ctx?.settingsScope === "full";
|
|
931
945
|
const manager = deps.makeSettingsManager(cwd, full ? "full" : "project");
|
|
932
946
|
const scopes = full
|
|
933
947
|
? ["local", "project", "user"]
|
|
@@ -953,7 +967,7 @@ function inspectMcpServer(args, ctx, deps) {
|
|
|
953
967
|
const resolved = mcpScope(args);
|
|
954
968
|
if (!resolved.ok)
|
|
955
969
|
return `Error: ${resolved.error}`;
|
|
956
|
-
if (resolved.scope === "user" && ctx?.settingsScope
|
|
970
|
+
if (resolved.scope === "user" && ctx?.settingsScope !== "full") {
|
|
957
971
|
return "Error: this host isolates user settings; user MCP configuration is unavailable.";
|
|
958
972
|
}
|
|
959
973
|
const manager = deps.makeSettingsManager(cwd, resolved.scope === "user" ? "full" : "project");
|
|
@@ -980,7 +994,7 @@ async function mutateMcpServer(args, ctx, deps) {
|
|
|
980
994
|
const resolved = mcpScope(args);
|
|
981
995
|
if (!resolved.ok)
|
|
982
996
|
return `Error: ${resolved.error}`;
|
|
983
|
-
if (resolved.scope === "user" && ctx?.settingsScope
|
|
997
|
+
if (resolved.scope === "user" && ctx?.settingsScope !== "full") {
|
|
984
998
|
return "Error: this host isolates user settings; use local or project scope.";
|
|
985
999
|
}
|
|
986
1000
|
const manager = deps.makeSettingsManager(cwd, resolved.scope === "user" ? "full" : "project");
|
|
@@ -1044,7 +1058,7 @@ export async function installCapabilityWithDeps(args, ctx, deps) {
|
|
|
1044
1058
|
}
|
|
1045
1059
|
if (action === "install") {
|
|
1046
1060
|
if (args.kind === "plugin")
|
|
1047
|
-
return installMarketplacePlugin(args, deps);
|
|
1061
|
+
return installMarketplacePlugin(args, ctx, deps);
|
|
1048
1062
|
if (args.kind === "skill")
|
|
1049
1063
|
return installGithubSkills(args, ctx, deps);
|
|
1050
1064
|
return installMcpServer(args, ctx, deps);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-host invalidation bridge for built-ins that persist settings-like
|
|
3
|
+
* resources outside the renderer. Desktop wires one sink that forwards the
|
|
4
|
+
* existing agent/settingsChanged notification; headless hosts may leave it
|
|
5
|
+
* unset and pick the change up on their next settings load.
|
|
6
|
+
*/
|
|
7
|
+
export type SettingsChangedSink = () => void;
|
|
8
|
+
export declare function setSettingsChangedSink(sink: SettingsChangedSink | null): void;
|
|
9
|
+
export declare function notifySettingsChanged(): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-host invalidation bridge for built-ins that persist settings-like
|
|
3
|
+
* resources outside the renderer. Desktop wires one sink that forwards the
|
|
4
|
+
* existing agent/settingsChanged notification; headless hosts may leave it
|
|
5
|
+
* unset and pick the change up on their next settings load.
|
|
6
|
+
*/
|
|
7
|
+
let settingsChangedSink = null;
|
|
8
|
+
export function setSettingsChangedSink(sink) {
|
|
9
|
+
settingsChangedSink = sink;
|
|
10
|
+
}
|
|
11
|
+
export function notifySettingsChanged() {
|
|
12
|
+
try {
|
|
13
|
+
settingsChangedSink?.();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Persistence already succeeded. Host invalidation is best-effort.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -28,14 +28,16 @@ export async function writeTool(args, ctx) {
|
|
|
28
28
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
29
29
|
const filePath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
30
30
|
try {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
31
|
+
const roots = ctx?.workspace?.roots.map((root) => root.path);
|
|
32
|
+
const digest = ctx?.workspace?.rootsDigest;
|
|
33
|
+
const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd, roots, digest);
|
|
34
|
+
const beforeMkdir = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
|
|
33
35
|
if ("error" in beforeMkdir)
|
|
34
36
|
return { ok: false, error: beforeMkdir.error };
|
|
35
37
|
await mkdir(dirname(beforeMkdir.resolvedPath), { recursive: true });
|
|
36
38
|
// mkdir may have crossed an existing symlink in a missing parent chain;
|
|
37
39
|
// resolve again immediately before opening the final file.
|
|
38
|
-
const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
|
|
40
|
+
const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
|
|
39
41
|
if ("error" in beforeWrite)
|
|
40
42
|
return { ok: false, error: beforeWrite.error };
|
|
41
43
|
await writeFileNoFollow(beforeWrite.resolvedPath, content);
|
|
@@ -189,6 +189,7 @@ export interface SubAgentSpawnResult {
|
|
|
189
189
|
*/
|
|
190
190
|
export interface ToolVisibilityContext {
|
|
191
191
|
cwd: string;
|
|
192
|
+
workspace?: import("../workspace/workspace-context.js").WorkspaceContext;
|
|
192
193
|
hasGoal: boolean;
|
|
193
194
|
/** Resolved Session identity for per-session extension-tool visibility. */
|
|
194
195
|
sessionId?: string;
|
|
@@ -227,10 +228,16 @@ export interface ToolContext {
|
|
|
227
228
|
cwd: string;
|
|
228
229
|
/**
|
|
229
230
|
* True when this call belongs to an external Agent Runtime rather than the
|
|
230
|
-
* native Engine loop.
|
|
231
|
-
* the current turn instead of queueing a wake-up only the Engine can consume.
|
|
231
|
+
* native Engine loop.
|
|
232
232
|
*/
|
|
233
233
|
externalRuntime?: boolean;
|
|
234
|
+
/**
|
|
235
|
+
* The external-runtime host can route notificationQueue completions back into
|
|
236
|
+
* this business Session as an injected continuation turn. Async tools must
|
|
237
|
+
* require this capability before detaching work: `externalRuntime` alone says
|
|
238
|
+
* who owns the turn, not whether anybody can deliver a later result.
|
|
239
|
+
*/
|
|
240
|
+
externalRuntimeBackgroundDelivery?: boolean;
|
|
234
241
|
/**
|
|
235
242
|
* Active digital-human profile's portable memory root. Present only when
|
|
236
243
|
* the resolved WorkspaceProfile enables portableMemory for this run.
|
|
@@ -456,7 +463,9 @@ export interface ToolContext {
|
|
|
456
463
|
* switcher. Undefined outside hosts that can switch this conversation's
|
|
457
464
|
* session workspace.
|
|
458
465
|
*/
|
|
459
|
-
|
|
466
|
+
workspaceBridge?: import("./workspace-bridge.js").WorkspaceBridge;
|
|
467
|
+
/** Immutable roots authorized for this run. */
|
|
468
|
+
workspace?: import("../workspace/workspace-context.js").WorkspaceContext;
|
|
460
469
|
/** Host panel discovery/focus bridge. Undefined in non-Desktop/headless engines. */
|
|
461
470
|
panels?: import("./panel-bridge.js").PanelHostBridge;
|
|
462
471
|
/**
|
|
@@ -319,7 +319,7 @@ export class ToolExecutor {
|
|
|
319
319
|
const target = isAbsolutePath(raw) ? raw : resolvePath(this.toolCtx.cwd, raw);
|
|
320
320
|
call = {
|
|
321
321
|
...call,
|
|
322
|
-
args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd),
|
|
322
|
+
args: attachFinalWritePathSnapshot(call.args, target, this.toolCtx.cwd, this.toolCtx.workspace?.roots.map((root) => root.path), this.toolCtx.workspace?.rootsDigest),
|
|
323
323
|
};
|
|
324
324
|
}
|
|
325
325
|
const pathPolicyError = await this.enforceDeclaredPathPolicy(toolDef, call.args);
|
|
@@ -42,10 +42,14 @@ export interface PathClassification {
|
|
|
42
42
|
reason: string;
|
|
43
43
|
/** Resolved absolute path (with symlinks followed when possible). */
|
|
44
44
|
resolvedPath: string;
|
|
45
|
+
/** Canonical workspace root containing resolvedPath, when one matched. */
|
|
46
|
+
matchedRoot?: string;
|
|
45
47
|
}
|
|
46
48
|
export interface ClassifyOptions {
|
|
47
49
|
/** Absolute path of the active workspace (Engine.cwd). */
|
|
48
50
|
workspaceRoot: string;
|
|
51
|
+
/** Complete authorized root set. Omitted preserves legacy single-root behavior. */
|
|
52
|
+
workspaceRoots?: readonly string[];
|
|
49
53
|
/** "read" or "write" — different defaults for sensitive paths. */
|
|
50
54
|
operation: PathOperation;
|
|
51
55
|
}
|
|
@@ -53,20 +57,24 @@ export interface FinalWritePathSnapshot {
|
|
|
53
57
|
resolvedPath: string;
|
|
54
58
|
workspacePath: string;
|
|
55
59
|
insideWorkspace: boolean;
|
|
60
|
+
matchedRoot?: string;
|
|
61
|
+
rootsDigest?: string;
|
|
56
62
|
}
|
|
57
63
|
export type PathApprovalScope = "once" | "session" | "project";
|
|
58
64
|
/** Test seam: clear the in-memory session grants. */
|
|
59
65
|
export declare function _resetSessionPathGrants(): void;
|
|
60
66
|
export declare function openSessionPathApprovals(sessionId: string): void;
|
|
61
67
|
export declare function clearSessionPathApprovals(sessionId: string): void;
|
|
68
|
+
/** Revoke only grants that point into a root removed from a live project. */
|
|
69
|
+
export declare function clearSessionPathApprovalsUnderRoot(sessionId: string, root: string): void;
|
|
62
70
|
/**
|
|
63
71
|
* Capture the concrete target approved by the executor's first path-policy
|
|
64
72
|
* pass. The snapshot is carried on the internal args object via a symbol, so
|
|
65
73
|
* it cannot collide with an LLM-supplied schema property or leak into logs.
|
|
66
74
|
*/
|
|
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): {
|
|
75
|
+
export declare function attachFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string, workspaceRoots?: readonly string[], rootsDigest?: string): Record<string, unknown>;
|
|
76
|
+
export declare function getFinalWritePathSnapshot(args: Record<string, unknown>, filePath: string, workspaceRoot: string, workspaceRoots?: readonly string[], rootsDigest?: string): FinalWritePathSnapshot;
|
|
77
|
+
export declare function revalidateFinalWritePath(filePath: string, workspaceRoot: string, approved: FinalWritePathSnapshot, workspaceRoots?: readonly string[], rootsDigest?: string): {
|
|
70
78
|
resolvedPath: string;
|
|
71
79
|
} | {
|
|
72
80
|
error: string;
|
|
@@ -33,8 +33,9 @@ import { constants, realpathSync, existsSync, readFileSync, writeFileSync, mkdir
|
|
|
33
33
|
import { open } from "node:fs/promises";
|
|
34
34
|
import { homedir } from "node:os";
|
|
35
35
|
import { randomUUID } from "node:crypto";
|
|
36
|
-
import { dirname,
|
|
36
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
37
37
|
import { readInstalledPlugins } from "../plugins/installedPlugins.js";
|
|
38
|
+
import { canonicalPath } from "../workspace/canonical-key.js";
|
|
38
39
|
const FINAL_WRITE_PATH_SNAPSHOT = Symbol("codeshell.finalWritePathSnapshot");
|
|
39
40
|
/**
|
|
40
41
|
* Default sensitive path patterns. These are evaluated AFTER home-expansion
|
|
@@ -291,6 +292,20 @@ export function clearSessionPathApprovals(sessionId) {
|
|
|
291
292
|
closedPathApprovalSessions.add(sessionId);
|
|
292
293
|
askChains.delete(sessionId);
|
|
293
294
|
}
|
|
295
|
+
/** Revoke only grants that point into a root removed from a live project. */
|
|
296
|
+
export function clearSessionPathApprovalsUnderRoot(sessionId, root) {
|
|
297
|
+
const grants = sessionPathGrants.get(sessionId);
|
|
298
|
+
if (!grants)
|
|
299
|
+
return;
|
|
300
|
+
const canonicalRoot = safeRealpath(root);
|
|
301
|
+
for (const grant of [...grants]) {
|
|
302
|
+
const grantPath = safeRealpath(grant.prefix);
|
|
303
|
+
if (isInsideDir(grantPath, canonicalRoot))
|
|
304
|
+
grants.delete(grant);
|
|
305
|
+
}
|
|
306
|
+
if (grants.size === 0)
|
|
307
|
+
sessionPathGrants.delete(sessionId);
|
|
308
|
+
}
|
|
294
309
|
/**
|
|
295
310
|
* Best-effort resolution. realpath fails when the path doesn't exist yet —
|
|
296
311
|
* the common case for Write creating a new file. We walk up to the nearest
|
|
@@ -303,47 +318,28 @@ export function clearSessionPathApprovals(sessionId) {
|
|
|
303
318
|
* would be misclassified as outside-workspace.
|
|
304
319
|
*/
|
|
305
320
|
function safeRealpath(p) {
|
|
306
|
-
|
|
307
|
-
// Walk up to the nearest existing ancestor.
|
|
308
|
-
let candidate = abs;
|
|
309
|
-
const segments = [];
|
|
310
|
-
// Cap the walk so a pathological input can't spin forever.
|
|
311
|
-
for (let i = 0; i < 64; i++) {
|
|
312
|
-
try {
|
|
313
|
-
const resolved = realpathSync(candidate);
|
|
314
|
-
if (segments.length === 0)
|
|
315
|
-
return resolved;
|
|
316
|
-
return resolvePath(resolved, ...segments.reverse());
|
|
317
|
-
}
|
|
318
|
-
catch {
|
|
319
|
-
const parent = dirname(candidate);
|
|
320
|
-
if (parent === candidate) {
|
|
321
|
-
// Reached root without finding anything that exists — return the
|
|
322
|
-
// original absolute form so the caller still has a usable path.
|
|
323
|
-
return abs;
|
|
324
|
-
}
|
|
325
|
-
segments.push(candidate.slice(parent.length + (parent.endsWith(sep) ? 0 : 1)));
|
|
326
|
-
candidate = parent;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
return abs;
|
|
321
|
+
return canonicalPath(p);
|
|
330
322
|
}
|
|
331
323
|
/**
|
|
332
324
|
* Capture the concrete target approved by the executor's first path-policy
|
|
333
325
|
* pass. The snapshot is carried on the internal args object via a symbol, so
|
|
334
326
|
* it cannot collide with an LLM-supplied schema property or leak into logs.
|
|
335
327
|
*/
|
|
336
|
-
export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot) {
|
|
328
|
+
export function attachFinalWritePathSnapshot(args, filePath, workspaceRoot, workspaceRoots, rootsDigest) {
|
|
337
329
|
const resolvedPath = safeRealpath(filePath);
|
|
338
330
|
const workspacePath = safeRealpath(workspaceRoot);
|
|
331
|
+
const roots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
|
|
332
|
+
const matchedRoot = roots.find((root) => isInsideDir(resolvedPath, root));
|
|
339
333
|
const snapshot = {
|
|
340
334
|
resolvedPath,
|
|
341
335
|
workspacePath,
|
|
342
|
-
insideWorkspace:
|
|
336
|
+
insideWorkspace: matchedRoot !== undefined,
|
|
337
|
+
matchedRoot,
|
|
338
|
+
rootsDigest,
|
|
343
339
|
};
|
|
344
340
|
return { ...args, [FINAL_WRITE_PATH_SNAPSHOT]: snapshot };
|
|
345
341
|
}
|
|
346
|
-
export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
|
|
342
|
+
export function getFinalWritePathSnapshot(args, filePath, workspaceRoot, workspaceRoots, rootsDigest) {
|
|
347
343
|
const carried = args[FINAL_WRITE_PATH_SNAPSHOT];
|
|
348
344
|
if (carried && typeof carried === "object") {
|
|
349
345
|
return carried;
|
|
@@ -353,19 +349,32 @@ export function getFinalWritePathSnapshot(args, filePath, workspaceRoot) {
|
|
|
353
349
|
// read-to-write interval with a second check before its writeFile.
|
|
354
350
|
const resolvedPath = safeRealpath(filePath);
|
|
355
351
|
const workspacePath = safeRealpath(workspaceRoot);
|
|
352
|
+
const roots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
|
|
353
|
+
const matchedRoot = roots.find((root) => isInsideDir(resolvedPath, root));
|
|
356
354
|
return {
|
|
357
355
|
resolvedPath,
|
|
358
356
|
workspacePath,
|
|
359
|
-
insideWorkspace:
|
|
357
|
+
insideWorkspace: matchedRoot !== undefined,
|
|
358
|
+
matchedRoot,
|
|
359
|
+
rootsDigest,
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
|
-
export function revalidateFinalWritePath(filePath, workspaceRoot, approved) {
|
|
362
|
+
export function revalidateFinalWritePath(filePath, workspaceRoot, approved, workspaceRoots, rootsDigest) {
|
|
363
363
|
const currentPath = safeRealpath(filePath);
|
|
364
364
|
const currentWorkspace = safeRealpath(workspaceRoot);
|
|
365
365
|
const sameTarget = normPath(currentPath) === normPath(approved.resolvedPath);
|
|
366
366
|
const sameWorkspace = normPath(currentWorkspace) === normPath(approved.workspacePath);
|
|
367
|
-
const
|
|
368
|
-
|
|
367
|
+
const currentRoots = (workspaceRoots?.length ? workspaceRoots : [workspaceRoot]).map(safeRealpath);
|
|
368
|
+
const currentMatchedRoot = currentRoots.find((root) => isInsideDir(currentPath, root));
|
|
369
|
+
const crossedWorkspaceBoundary = approved.insideWorkspace && currentMatchedRoot === undefined;
|
|
370
|
+
const changedMatchedRoot = approved.matchedRoot !== undefined &&
|
|
371
|
+
normPath(currentMatchedRoot ?? "") !== normPath(approved.matchedRoot);
|
|
372
|
+
const changedRootsDigest = approved.rootsDigest !== undefined && rootsDigest !== approved.rootsDigest;
|
|
373
|
+
if (crossedWorkspaceBoundary ||
|
|
374
|
+
changedMatchedRoot ||
|
|
375
|
+
changedRootsDigest ||
|
|
376
|
+
!sameTarget ||
|
|
377
|
+
!sameWorkspace) {
|
|
369
378
|
const reason = crossedWorkspaceBoundary
|
|
370
379
|
? "final write path resolved outside the workspace after approval"
|
|
371
380
|
: "final write path changed after approval";
|
|
@@ -620,11 +629,13 @@ export function classifyPath(rawPath, opts) {
|
|
|
620
629
|
}
|
|
621
630
|
const expanded = expandTilde(rawPath);
|
|
622
631
|
const resolved = safeRealpath(expanded);
|
|
623
|
-
const
|
|
632
|
+
const workspaceRoots = opts.workspaceRoots?.length ? opts.workspaceRoots : [opts.workspaceRoot];
|
|
633
|
+
const workspaces = workspaceRoots.map((root) => safeRealpath(root));
|
|
624
634
|
const sensitiveDir = matchSensitiveDir(resolved);
|
|
625
635
|
const sensitiveFile = matchSensitiveFile(resolved);
|
|
626
636
|
const sensitiveLabel = sensitiveDir ?? sensitiveFile;
|
|
627
|
-
const
|
|
637
|
+
const matchedRoot = workspaces.find((workspace) => isInsideDir(resolved, workspace));
|
|
638
|
+
const insideWorkspace = matchedRoot !== undefined;
|
|
628
639
|
// Registered Skill resources are managed runtime inputs. Their SKILL.md is
|
|
629
640
|
// already readable through the Skill builtin; allow its contained reference
|
|
630
641
|
// files through the ordinary Read tool as well. A credential-shaped basename
|
|
@@ -675,7 +686,12 @@ export function classifyPath(rawPath, opts) {
|
|
|
675
686
|
};
|
|
676
687
|
}
|
|
677
688
|
if (insideWorkspace) {
|
|
678
|
-
return {
|
|
689
|
+
return {
|
|
690
|
+
decision: "allow",
|
|
691
|
+
reason: "inside workspace",
|
|
692
|
+
resolvedPath: resolved,
|
|
693
|
+
matchedRoot,
|
|
694
|
+
};
|
|
679
695
|
}
|
|
680
696
|
// Outside workspace: ask for both read and write. The conservative bias
|
|
681
697
|
// matches the plan's leaning answer to Q1 — ask on sensitive reads, deny
|
|
@@ -732,7 +748,11 @@ export async function enforcePathPolicyWithApproval(filePath, operation, ctx) {
|
|
|
732
748
|
// who chose full access still got prompted for project-external reads.
|
|
733
749
|
if (ctx.permissionMode === "bypassPermissions")
|
|
734
750
|
return null;
|
|
735
|
-
const c = classifyPath(filePath, {
|
|
751
|
+
const c = classifyPath(filePath, {
|
|
752
|
+
workspaceRoot: ctx.cwd,
|
|
753
|
+
workspaceRoots: ctx.workspace?.roots.map((root) => root.path),
|
|
754
|
+
operation,
|
|
755
|
+
});
|
|
736
756
|
if (c.decision === "allow")
|
|
737
757
|
return null;
|
|
738
758
|
if (c.decision === "deny") {
|
package/dist/types.d.ts
CHANGED
|
@@ -188,6 +188,10 @@ export interface SessionWorkspace {
|
|
|
188
188
|
createdBy: "codeshell";
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
|
+
export interface SessionProjectBinding {
|
|
192
|
+
projectId: string;
|
|
193
|
+
mainRootId: string;
|
|
194
|
+
}
|
|
191
195
|
export interface ContextUsageAnchor {
|
|
192
196
|
promptTokens: number;
|
|
193
197
|
messageCount: number;
|
|
@@ -231,6 +235,8 @@ export interface SessionState {
|
|
|
231
235
|
cwd: string;
|
|
232
236
|
/** Current main/worktree execution pointer. Absent only on legacy state.json files. */
|
|
233
237
|
workspace?: SessionWorkspace;
|
|
238
|
+
/** Stable Desktop project identity. Absent on legacy/no-repo sessions. */
|
|
239
|
+
project?: SessionProjectBinding;
|
|
234
240
|
startedAt: number;
|
|
235
241
|
model: string;
|
|
236
242
|
provider: string;
|
|
@@ -747,6 +753,11 @@ export interface ClientDefaults {
|
|
|
747
753
|
timeout?: number;
|
|
748
754
|
/** Max retry attempts for transient errors. Default 3. */
|
|
749
755
|
retryMaxAttempts?: number;
|
|
756
|
+
/**
|
|
757
|
+
* Optional HTTP transport override. Primarily used by embedders and tests
|
|
758
|
+
* that need an isolated transport without mutating process-global fetch.
|
|
759
|
+
*/
|
|
760
|
+
fetch?: typeof globalThis.fetch;
|
|
750
761
|
/**
|
|
751
762
|
* Provider-agnostic image clarity level. Drives the renderer-side
|
|
752
763
|
* downscale (long-edge cap: low→~1024 / standard→~1568 / high→~2576)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a path through symlinks without requiring the leaf to exist.
|
|
3
|
+
* Missing suffixes are appended to the nearest existing realpathed ancestor.
|
|
4
|
+
*/
|
|
5
|
+
export declare function canonicalPath(input: string): string;
|
|
6
|
+
/** Stable comparison key for project roots and workspace containment decisions. */
|
|
7
|
+
export declare function canonicalKey(input: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, parse, resolve } from "node:path";
|
|
3
|
+
const CASE_INSENSITIVE_PLATFORM = process.platform === "darwin" || process.platform === "win32";
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a path through symlinks without requiring the leaf to exist.
|
|
6
|
+
* Missing suffixes are appended to the nearest existing realpathed ancestor.
|
|
7
|
+
*/
|
|
8
|
+
export function canonicalPath(input) {
|
|
9
|
+
const absolute = isAbsolute(input) ? resolve(input) : resolve(process.cwd(), input);
|
|
10
|
+
let candidate = absolute;
|
|
11
|
+
const suffix = [];
|
|
12
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
13
|
+
try {
|
|
14
|
+
const existing = realpathSync(candidate);
|
|
15
|
+
const joined = suffix.length > 0 ? resolve(existing, ...suffix) : resolve(existing);
|
|
16
|
+
return trimTrailingSeparators(joined);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
const parent = dirname(candidate);
|
|
20
|
+
if (parent === candidate)
|
|
21
|
+
return trimTrailingSeparators(absolute);
|
|
22
|
+
suffix.unshift(basename(candidate));
|
|
23
|
+
candidate = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return trimTrailingSeparators(absolute);
|
|
27
|
+
}
|
|
28
|
+
/** Stable comparison key for project roots and workspace containment decisions. */
|
|
29
|
+
export function canonicalKey(input) {
|
|
30
|
+
const normalized = canonicalPath(input);
|
|
31
|
+
return CASE_INSENSITIVE_PLATFORM ? normalized.toLocaleLowerCase("en-US") : normalized;
|
|
32
|
+
}
|
|
33
|
+
function trimTrailingSeparators(input) {
|
|
34
|
+
const root = parse(input).root;
|
|
35
|
+
let end = input.length;
|
|
36
|
+
while (end > root.length && (input[end - 1] === "/" || input[end - 1] === "\\"))
|
|
37
|
+
end -= 1;
|
|
38
|
+
return input.slice(0, end);
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonicalKey, canonicalPath } from "./canonical-key.js";
|
|
2
|
+
export { canonicalKey, canonicalPath };
|
|
3
|
+
export type ProjectId = string;
|
|
4
|
+
export type ProjectRootId = string;
|
|
5
|
+
export interface ProjectRootContext {
|
|
6
|
+
id: ProjectRootId;
|
|
7
|
+
path: string;
|
|
8
|
+
role: "primary" | "secondary";
|
|
9
|
+
}
|
|
10
|
+
export interface WorkspaceContext {
|
|
11
|
+
version: 1;
|
|
12
|
+
projectId: ProjectId;
|
|
13
|
+
projectRevision: number;
|
|
14
|
+
sessionMainRootId: ProjectRootId;
|
|
15
|
+
roots: ProjectRootContext[];
|
|
16
|
+
rootsDigest: string;
|
|
17
|
+
}
|
|
18
|
+
export type WorkspaceContextInput = Omit<WorkspaceContext, "version" | "rootsDigest">;
|
|
19
|
+
export declare function computeWorkspaceRootsDigest(roots: readonly ProjectRootContext[]): string;
|
|
20
|
+
export declare function createWorkspaceContext(input: WorkspaceContextInput): WorkspaceContext;
|
|
21
|
+
export declare function validateWorkspaceContext(value: unknown): WorkspaceContext;
|
|
22
|
+
/** Compatibility context for callers that only possess one cwd. It is never persisted as a binding. */
|
|
23
|
+
export declare function legacySingleRootWorkspace(cwd: string): WorkspaceContext;
|
|
24
|
+
export declare function workspacePrimaryRoot(context: WorkspaceContext): ProjectRootContext;
|
|
25
|
+
/** Paths present in the previous run-scoped root set but absent from the next one. */
|
|
26
|
+
export declare function removedWorkspaceRootPaths(previous: Pick<WorkspaceContext, "roots">, next: Pick<WorkspaceContext, "roots">): string[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
3
|
+
import { canonicalKey, canonicalPath } from "./canonical-key.js";
|
|
4
|
+
export { canonicalKey, canonicalPath };
|
|
5
|
+
function requireIdentifier(value, label) {
|
|
6
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 512) {
|
|
7
|
+
throw new Error(`invalid WorkspaceContext ${label}`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function containsPath(parent, child) {
|
|
12
|
+
if (parent === child)
|
|
13
|
+
return true;
|
|
14
|
+
const rel = relative(parent, child);
|
|
15
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
16
|
+
}
|
|
17
|
+
export function computeWorkspaceRootsDigest(roots) {
|
|
18
|
+
const keys = roots.map((root) => canonicalKey(root.path)).sort();
|
|
19
|
+
return createHash("sha256").update(keys.join("\0"), "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function validateRoots(roots, sessionMainRootId) {
|
|
22
|
+
if (roots.length === 0 || roots.length > 256) {
|
|
23
|
+
throw new Error("WorkspaceContext roots must contain between 1 and 256 entries");
|
|
24
|
+
}
|
|
25
|
+
const ids = new Set();
|
|
26
|
+
const normalized = roots.map((root, index) => {
|
|
27
|
+
if (!root || typeof root !== "object") {
|
|
28
|
+
throw new Error(`invalid WorkspaceContext root at index ${index}`);
|
|
29
|
+
}
|
|
30
|
+
const id = requireIdentifier(root.id, `roots[${index}].id`);
|
|
31
|
+
if (ids.has(id))
|
|
32
|
+
throw new Error(`duplicate root id: ${id}`);
|
|
33
|
+
ids.add(id);
|
|
34
|
+
if (typeof root.path !== "string" || root.path.length === 0 || root.path.length > 16_384) {
|
|
35
|
+
throw new Error(`invalid WorkspaceContext roots[${index}].path`);
|
|
36
|
+
}
|
|
37
|
+
if (!isAbsolute(root.path)) {
|
|
38
|
+
throw new Error(`WorkspaceContext root path must be absolute: ${root.path}`);
|
|
39
|
+
}
|
|
40
|
+
if (root.role !== "primary" && root.role !== "secondary") {
|
|
41
|
+
throw new Error(`invalid WorkspaceContext roots[${index}].role`);
|
|
42
|
+
}
|
|
43
|
+
return { id, path: root.path, role: root.role };
|
|
44
|
+
});
|
|
45
|
+
const primary = normalized.filter((root) => root.role === "primary");
|
|
46
|
+
if (primary.length !== 1 || primary[0]?.id !== sessionMainRootId) {
|
|
47
|
+
throw new Error("WorkspaceContext must have exactly one primary matching sessionMainRootId");
|
|
48
|
+
}
|
|
49
|
+
const keys = normalized.map((root) => canonicalKey(root.path));
|
|
50
|
+
for (let left = 0; left < keys.length; left += 1) {
|
|
51
|
+
for (let right = left + 1; right < keys.length; right += 1) {
|
|
52
|
+
if (containsPath(keys[left], keys[right]) || containsPath(keys[right], keys[left])) {
|
|
53
|
+
throw new Error(`WorkspaceContext roots overlap: ${normalized[left].path} and ${normalized[right].path}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
export function createWorkspaceContext(input) {
|
|
60
|
+
const projectId = requireIdentifier(input.projectId, "projectId");
|
|
61
|
+
const sessionMainRootId = requireIdentifier(input.sessionMainRootId, "sessionMainRootId");
|
|
62
|
+
if (!Number.isSafeInteger(input.projectRevision) || input.projectRevision < 1) {
|
|
63
|
+
throw new Error("invalid WorkspaceContext projectRevision");
|
|
64
|
+
}
|
|
65
|
+
const roots = validateRoots(input.roots, sessionMainRootId);
|
|
66
|
+
return {
|
|
67
|
+
version: 1,
|
|
68
|
+
projectId,
|
|
69
|
+
projectRevision: input.projectRevision,
|
|
70
|
+
sessionMainRootId,
|
|
71
|
+
roots,
|
|
72
|
+
rootsDigest: computeWorkspaceRootsDigest(roots),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function validateWorkspaceContext(value) {
|
|
76
|
+
if (!value || typeof value !== "object")
|
|
77
|
+
throw new Error("invalid WorkspaceContext");
|
|
78
|
+
const candidate = value;
|
|
79
|
+
if (candidate.version !== 1)
|
|
80
|
+
throw new Error("invalid WorkspaceContext version");
|
|
81
|
+
const created = createWorkspaceContext({
|
|
82
|
+
projectId: candidate.projectId,
|
|
83
|
+
projectRevision: candidate.projectRevision,
|
|
84
|
+
sessionMainRootId: candidate.sessionMainRootId,
|
|
85
|
+
roots: candidate.roots,
|
|
86
|
+
});
|
|
87
|
+
if (candidate.rootsDigest !== created.rootsDigest) {
|
|
88
|
+
throw new Error("WorkspaceContext rootsDigest does not match roots");
|
|
89
|
+
}
|
|
90
|
+
return created;
|
|
91
|
+
}
|
|
92
|
+
/** Compatibility context for callers that only possess one cwd. It is never persisted as a binding. */
|
|
93
|
+
export function legacySingleRootWorkspace(cwd) {
|
|
94
|
+
const path = canonicalPath(cwd);
|
|
95
|
+
const keyDigest = createHash("sha256").update(canonicalKey(path), "utf8").digest("hex");
|
|
96
|
+
return createWorkspaceContext({
|
|
97
|
+
projectId: `legacy-${keyDigest}`,
|
|
98
|
+
projectRevision: 1,
|
|
99
|
+
sessionMainRootId: "legacy-root",
|
|
100
|
+
roots: [{ id: "legacy-root", path, role: "primary" }],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function workspacePrimaryRoot(context) {
|
|
104
|
+
return context.roots.find((root) => root.id === context.sessionMainRootId);
|
|
105
|
+
}
|
|
106
|
+
/** Paths present in the previous run-scoped root set but absent from the next one. */
|
|
107
|
+
export function removedWorkspaceRootPaths(previous, next) {
|
|
108
|
+
const nextKeys = new Set(next.roots.map((root) => canonicalKey(root.path)));
|
|
109
|
+
return previous.roots
|
|
110
|
+
.filter((root) => !nextKeys.has(canonicalKey(root.path)))
|
|
111
|
+
.map((root) => root.path);
|
|
112
|
+
}
|
package/package.json
CHANGED