@cjhyy/code-shell-core 0.8.20 → 0.9.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/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 +4 -1
- package/dist/engine/engine-workspace-authority.d.ts +50 -0
- package/dist/engine/engine-workspace-authority.js +167 -0
- package/dist/engine/engine.d.ts +7 -10
- package/dist/engine/engine.js +87 -101
- 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 +6 -0
- 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 +2 -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/types.d.ts +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.extension.d.ts +1 -1
- package/dist/index.internal.d.ts +2 -2
- package/dist/index.internal.js +1 -0
- package/dist/index.js +1 -1
- package/dist/plugins/pluginAutomationTemplates.d.ts +2 -0
- package/dist/plugins/pluginAutomationTemplates.js +4 -0
- package/dist/prompt/composer.d.ts +4 -1
- package/dist/prompt/composer.js +11 -3
- package/dist/protocol/background-result-wakeup.d.ts +18 -0
- package/dist/protocol/background-result-wakeup.js +101 -0
- 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 +9 -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 +5 -6
- package/dist/protocol/server.js +62 -128
- 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 +45 -1
- package/dist/protocol/types.js +4 -0
- package/dist/session/session-manager.d.ts +18 -0
- package/dist/session/session-manager.js +87 -0
- package/dist/settings/manager.d.ts +7 -0
- package/dist/settings/manager.js +64 -3
- package/dist/tool-system/builtin/agent-notifications.d.ts +8 -0
- package/dist/tool-system/builtin/agent-notifications.js +19 -0
- package/dist/tool-system/builtin/config.d.ts +11 -0
- package/dist/tool-system/builtin/config.js +55 -10
- 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.js +5 -3
- package/dist/tool-system/builtin/view-image.js +8 -2
- package/dist/tool-system/builtin/write.js +5 -3
- package/dist/tool-system/context.d.ts +4 -1
- 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 +6 -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 +35 -0
- package/dist/workspace/workspace-context.js +128 -0
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ConfigTool — read or update project settings.
|
|
3
3
|
*/
|
|
4
|
-
import { existsSync, readFileSync
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { setDottedSetting } from "../../settings/manager.js";
|
|
6
|
+
import { SettingsManager, setDottedSetting } from "../../settings/manager.js";
|
|
7
7
|
import { enforcePathPolicyWithApproval } from "../path-policy.js";
|
|
8
8
|
export const configToolDef = {
|
|
9
9
|
name: "Config",
|
|
@@ -28,7 +28,20 @@ export const configToolDef = {
|
|
|
28
28
|
required: ["action"],
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
|
+
const DEFAULT_DEPS = {
|
|
32
|
+
makeSettingsManager: (cwd, scope) => new SettingsManager(cwd, scope),
|
|
33
|
+
};
|
|
34
|
+
/** Factory so tests can inject a SettingsManager (barrier/fake); production
|
|
35
|
+
* uses the default instance-per-call, matching the other builtins. */
|
|
36
|
+
export function makeConfigTool(deps = DEFAULT_DEPS) {
|
|
37
|
+
return async function configTool(args, ctx) {
|
|
38
|
+
return runConfigTool(args, ctx, deps);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
31
41
|
export async function configTool(args, ctx) {
|
|
42
|
+
return runConfigTool(args, ctx, DEFAULT_DEPS);
|
|
43
|
+
}
|
|
44
|
+
async function runConfigTool(args, ctx, deps) {
|
|
32
45
|
const action = args.action;
|
|
33
46
|
const cwd = ctx?.cwd ?? process.cwd();
|
|
34
47
|
const configPath = join(cwd, ".code-shell", "settings.json");
|
|
@@ -52,18 +65,18 @@ export async function configTool(args, ctx) {
|
|
|
52
65
|
return "Error: 'key' is required for write action.";
|
|
53
66
|
if (value === undefined)
|
|
54
67
|
return "Error: 'value' is required for write action.";
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
68
|
+
// Validate the key BEFORE touching disk, preserving the existing contract
|
|
69
|
+
// that an unsafe dotted key is rejected without creating settings.json.
|
|
70
|
+
// setDottedSetting is the same validator saveProjectSetting applies inside
|
|
71
|
+
// the lock; running it here on a throwaway object only surfaces the error.
|
|
60
72
|
try {
|
|
61
|
-
setDottedSetting(
|
|
73
|
+
setDottedSetting({}, key, value);
|
|
62
74
|
}
|
|
63
75
|
catch (error) {
|
|
64
76
|
const message = error instanceof Error ? error.message : String(error);
|
|
65
77
|
return `Error: ${message}`;
|
|
66
78
|
}
|
|
79
|
+
await deps.beforeWrite?.();
|
|
67
80
|
// Never resurrect a deleted project root: a recursive mkdir of
|
|
68
81
|
// <cwd>/.code-shell would recreate `cwd` itself as an empty shell when the
|
|
69
82
|
// directory has been deleted (e.g. a stale session pointing at a removed
|
|
@@ -71,8 +84,40 @@ export async function configTool(args, ctx) {
|
|
|
71
84
|
if (!existsSync(cwd)) {
|
|
72
85
|
return `Error: project directory does not exist: ${cwd}`;
|
|
73
86
|
}
|
|
74
|
-
|
|
75
|
-
writeFileSync
|
|
87
|
+
// Persist through SettingsManager rather than a hand-rolled
|
|
88
|
+
// read → modify → writeFileSync. That path had no lock and no temp+rename,
|
|
89
|
+
// so two writers that both read before either wrote each persisted their
|
|
90
|
+
// own stale snapshot and silently dropped the other's key (the class
|
|
91
|
+
// documented in utils/file-mutex.ts). saveProjectSetting re-reads inside
|
|
92
|
+
// the lock, writes atomically, and invalidates the merged cache so a
|
|
93
|
+
// following read sees this write.
|
|
94
|
+
//
|
|
95
|
+
// Side effect worth knowing about on a YAML-configured project:
|
|
96
|
+
// SettingsManager reads a sibling settings.yaml when settings.json is
|
|
97
|
+
// absent but always writes back JSON, so the first write folds the YAML
|
|
98
|
+
// content into a new settings.json and the now-shadowed YAML is left on
|
|
99
|
+
// disk. That is SettingsManager's established behaviour for every caller,
|
|
100
|
+
// and it is strictly better than what this tool used to do (write the one
|
|
101
|
+
// new key and lose the YAML content entirely) — so it is inherited
|
|
102
|
+
// deliberately rather than special-cased here.
|
|
103
|
+
//
|
|
104
|
+
// saveProjectSetting throws on a hostile state dir (a `.code-shell` that is
|
|
105
|
+
// a file or a link) and on an existing-but-unreadable settings file, where
|
|
106
|
+
// it refuses to overwrite rather than rewrite from {}. The old hand-rolled
|
|
107
|
+
// write also threw on those inputs, but let the exception escape; every
|
|
108
|
+
// other failure in this tool is reported as a string, so convert it here.
|
|
109
|
+
//
|
|
110
|
+
// Note the file mode also changed: the old writeFileSync left settings.json
|
|
111
|
+
// at the umask default (typically 0644), while SettingsManager writes 0600.
|
|
112
|
+
// settings.json can hold plaintext API keys, so owner-only is the intended
|
|
113
|
+
// posture — an existing world-readable file is tightened on its next write.
|
|
114
|
+
try {
|
|
115
|
+
deps.makeSettingsManager(cwd, "project").saveProjectSetting(key, value, cwd);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
119
|
+
return `Error: ${message}`;
|
|
120
|
+
}
|
|
76
121
|
return `Updated ${key} = ${JSON.stringify(value)}`;
|
|
77
122
|
}
|
|
78
123
|
return `Unknown action: ${action}. Use 'read' or 'write'.`;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ToolDefinition } from "../../types.js";
|
|
5
5
|
import type { ToolContext } from "../context.js";
|
|
6
|
+
import type { CronJob, CreateJobOptions } from "../../automation/scheduler.js";
|
|
6
7
|
export { cronListToolDef } from "./cron-list.definition.js";
|
|
7
8
|
/** Sink notified after a cron job is created/deleted, so the host (Electron
|
|
8
9
|
* main) can reload+arm the scheduler that actually executes jobs. The worker
|
|
@@ -10,6 +11,16 @@ export { cronListToolDef } from "./cron-list.definition.js";
|
|
|
10
11
|
* host never learns about an AI-created job until the user opens the UI. */
|
|
11
12
|
type CronChangedSink = () => void;
|
|
12
13
|
export declare function setCronChangedSink(sink: CronChangedSink | null): void;
|
|
14
|
+
export interface CronCreateAuthorityInput extends CreateJobOptions {
|
|
15
|
+
name: string;
|
|
16
|
+
schedule: string;
|
|
17
|
+
prompt: string;
|
|
18
|
+
/** Current Engine Session, used by Desktop Main as authority even for standalone jobs. */
|
|
19
|
+
authoritySessionId?: string;
|
|
20
|
+
}
|
|
21
|
+
export type CronCreateAuthority = (input: CronCreateAuthorityInput) => Promise<CronJob>;
|
|
22
|
+
/** Desktop installs a Main-process delegate; other hosts keep the local scheduler path. */
|
|
23
|
+
export declare function setCronCreateAuthority(authority: CronCreateAuthority | null): void;
|
|
13
24
|
export declare const cronCreateToolDef: ToolDefinition;
|
|
14
25
|
export declare function cronCreateTool(args: Record<string, unknown>, context?: ToolContext): Promise<string>;
|
|
15
26
|
export declare const cronDeleteToolDef: ToolDefinition;
|
|
@@ -17,6 +17,11 @@ function fireCronChanged() {
|
|
|
17
17
|
// Notifying the host is best-effort; never break the tool on it.
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
let cronCreateAuthority = null;
|
|
21
|
+
/** Desktop installs a Main-process delegate; other hosts keep the local scheduler path. */
|
|
22
|
+
export function setCronCreateAuthority(authority) {
|
|
23
|
+
cronCreateAuthority = authority;
|
|
24
|
+
}
|
|
20
25
|
export const cronCreateToolDef = {
|
|
21
26
|
name: "CronCreate",
|
|
22
27
|
description: "Create a scheduled automation job that runs a prompt on a recurring schedule. " +
|
|
@@ -125,15 +130,33 @@ export async function cronCreateTool(args, context) {
|
|
|
125
130
|
// time (the model never supplies it — getCurrentSid() reads the running
|
|
126
131
|
// Engine's ALS context). Empty/unknown sid → treat as standalone.
|
|
127
132
|
const resumeSessionId = args.continueInSession === true && getCurrentSid() ? getCurrentSid() : undefined;
|
|
133
|
+
const stableWorkspace = context?.workspace && !context.workspace.projectId.startsWith("legacy-")
|
|
134
|
+
? {
|
|
135
|
+
projectId: context.workspace.projectId,
|
|
136
|
+
rootId: context.workspace.sessionMainRootId,
|
|
137
|
+
}
|
|
138
|
+
: undefined;
|
|
128
139
|
let job;
|
|
129
140
|
try {
|
|
130
|
-
|
|
141
|
+
const createInput = {
|
|
142
|
+
name,
|
|
143
|
+
schedule,
|
|
144
|
+
prompt,
|
|
131
145
|
...(timezone !== undefined ? { timezone } : {}),
|
|
132
146
|
...(cwd !== undefined ? { cwd } : {}),
|
|
147
|
+
...(stableWorkspace ?? {}),
|
|
133
148
|
...(permissionLevel !== undefined ? { permissionLevel } : {}),
|
|
134
149
|
...(once ? { once: true } : {}),
|
|
135
150
|
...(resumeSessionId !== undefined ? { resumeSessionId } : {}),
|
|
136
|
-
|
|
151
|
+
...(getCurrentSid() ? { authoritySessionId: getCurrentSid() } : {}),
|
|
152
|
+
};
|
|
153
|
+
if (cronCreateAuthority) {
|
|
154
|
+
job = await cronCreateAuthority(createInput);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const { name: _name, schedule: _schedule, prompt: _prompt, authoritySessionId: _authoritySessionId, ...createOptions } = createInput;
|
|
158
|
+
job = cronScheduler.create(name, schedule, prompt, createOptions);
|
|
159
|
+
}
|
|
137
160
|
}
|
|
138
161
|
catch (err) {
|
|
139
162
|
return `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -162,12 +185,14 @@ export async function cronDeleteTool(args) {
|
|
|
162
185
|
const id = args.jobId;
|
|
163
186
|
if (!id)
|
|
164
187
|
return "Error: jobId is required";
|
|
188
|
+
cronScheduler.loadJobs({ arm: false });
|
|
165
189
|
const deleted = cronScheduler.delete(id);
|
|
166
190
|
if (deleted)
|
|
167
191
|
fireCronChanged();
|
|
168
192
|
return deleted ? `Cron job #${id} deleted.` : `Cron job #${id} not found.`;
|
|
169
193
|
}
|
|
170
194
|
export async function cronListTool(_args) {
|
|
195
|
+
cronScheduler.loadJobs({ arm: false });
|
|
171
196
|
const jobs = cronScheduler.list();
|
|
172
197
|
if (jobs.length === 0)
|
|
173
198
|
return "No cron jobs scheduled.";
|
|
@@ -51,8 +51,10 @@ export async function editTool(args, ctx) {
|
|
|
51
51
|
if (!existsSync(filePath))
|
|
52
52
|
return `Error: File not found: ${filePath}`;
|
|
53
53
|
try {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
54
|
+
const roots = ctx?.workspace?.roots.map((root) => root.path);
|
|
55
|
+
const digest = ctx?.workspace?.rootsDigest;
|
|
56
|
+
const approvedPath = getFinalWritePathSnapshot(args, filePath, cwd, roots, digest);
|
|
57
|
+
const beforeRead = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
|
|
56
58
|
if ("error" in beforeRead)
|
|
57
59
|
return { ok: false, error: beforeRead.error };
|
|
58
60
|
const raw = await readFile(beforeRead.resolvedPath, "utf-8");
|
|
@@ -75,7 +77,7 @@ export async function editTool(args, ctx) {
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
const updatedLf = replaceAll ? content.split(oldLf).join(newLf) : content.replace(oldLf, newLf);
|
|
78
|
-
const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath);
|
|
80
|
+
const beforeWrite = revalidateFinalWritePath(filePath, cwd, approvedPath, roots, digest);
|
|
79
81
|
if ("error" in beforeWrite)
|
|
80
82
|
return { ok: false, error: beforeWrite.error };
|
|
81
83
|
await writeFileNoFollow(beforeWrite.resolvedPath, applyEol(updatedLf, eol));
|
|
@@ -39,7 +39,8 @@ export const viewImageToolDef = {
|
|
|
39
39
|
},
|
|
40
40
|
imageNumber: {
|
|
41
41
|
type: "number",
|
|
42
|
-
description: "
|
|
42
|
+
description: "Positive image history number N from an earlier [image #N, already provided] " +
|
|
43
|
+
"placeholder. Omit this field entirely when path is provided; do not send 0.",
|
|
43
44
|
},
|
|
44
45
|
detail: {
|
|
45
46
|
type: "string",
|
|
@@ -53,7 +54,12 @@ export async function viewImageTool(args, ctx) {
|
|
|
53
54
|
const rawPath = args.path;
|
|
54
55
|
const rawImageNumber = args.imageNumber;
|
|
55
56
|
const hasPath = typeof rawPath === "string" && rawPath.trim().length > 0;
|
|
56
|
-
const
|
|
57
|
+
const hasImageNumberInput = rawImageNumber !== undefined && rawImageNumber !== null;
|
|
58
|
+
// Some tool-calling models materialize every optional numeric property with
|
|
59
|
+
// a zero sentinel. With a real path, imageNumber: 0 unambiguously means
|
|
60
|
+
// "unused" because valid history numbers start at 1. Accept that one
|
|
61
|
+
// compatibility shape while keeping path + a real image number forbidden.
|
|
62
|
+
const hasImageNumber = hasImageNumberInput && !(hasPath && rawImageNumber === 0);
|
|
57
63
|
if (hasPath === hasImageNumber) {
|
|
58
64
|
return "Error: provide exactly one of path or imageNumber";
|
|
59
65
|
}
|
|
@@ -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;
|
|
@@ -462,7 +463,9 @@ export interface ToolContext {
|
|
|
462
463
|
* switcher. Undefined outside hosts that can switch this conversation's
|
|
463
464
|
* session workspace.
|
|
464
465
|
*/
|
|
465
|
-
|
|
466
|
+
workspaceBridge?: import("./workspace-bridge.js").WorkspaceBridge;
|
|
467
|
+
/** Immutable roots authorized for this run. */
|
|
468
|
+
workspace?: import("../workspace/workspace-context.js").WorkspaceContext;
|
|
466
469
|
/** Host panel discovery/focus bridge. Undefined in non-Desktop/headless engines. */
|
|
467
470
|
panels?: import("./panel-bridge.js").PanelHostBridge;
|
|
468
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;
|
|
@@ -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,35 @@
|
|
|
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
|
+
/**
|
|
26
|
+
* Rebase only the Session's primary runtime path while preserving the host's
|
|
27
|
+
* authoritative project/root identities and the mounted secondary roots.
|
|
28
|
+
*
|
|
29
|
+
* A worktree switch changes where the primary root executes; it does not mint
|
|
30
|
+
* a new project or root id. Rebuilding through createWorkspaceContext also
|
|
31
|
+
* recomputes rootsDigest and re-runs the overlap/absolute-path validation.
|
|
32
|
+
*/
|
|
33
|
+
export declare function rebaseWorkspacePrimaryRoot(context: WorkspaceContext, primaryPath: string): WorkspaceContext;
|
|
34
|
+
/** Paths present in the previous run-scoped root set but absent from the next one. */
|
|
35
|
+
export declare function removedWorkspaceRootPaths(previous: Pick<WorkspaceContext, "roots">, next: Pick<WorkspaceContext, "roots">): string[];
|