codelocal 1.5.0-beta.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/README.md +27 -0
- package/dist/approval-memory.js +105 -0
- package/dist/audit.js +34 -0
- package/dist/chat-approval.js +77 -0
- package/dist/cli-saas.js +311 -0
- package/dist/cli.js +344 -0
- package/dist/client-entry-v2.js +22 -0
- package/dist/client-v2.js +910 -0
- package/dist/cloud-client-sync.js +6 -0
- package/dist/context-engine.js +295 -0
- package/dist/editing-engine.js +205 -0
- package/dist/identity.js +30 -0
- package/dist/log.js +235 -0
- package/dist/lsp.js +288 -0
- package/dist/mcp-cloud-sync.js +3 -0
- package/dist/mcp-hub.js +508 -0
- package/dist/native-watcher.js +148 -0
- package/dist/process-manager.js +261 -0
- package/dist/protocol.js +52 -0
- package/dist/runtime-daemon.js +162 -0
- package/dist/security-policy.js +293 -0
- package/dist/semantic-router.js +378 -0
- package/dist/semantic.js +263 -0
- package/dist/state.js +110 -0
- package/dist/terminal-history.js +102 -0
- package/dist/verification.js +66 -0
- package/dist/workspace-index.js +457 -0
- package/dist/workspace-registry.js +86 -0
- package/package.json +31 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import WebSocket from "ws";
|
|
7
|
+
import ignore from "ignore";
|
|
8
|
+
import chokidar from "chokidar";
|
|
9
|
+
import { log, mirrorProcessOutput, summarizeToolArgs } from "./log.js";
|
|
10
|
+
import { PROTOCOL_VERSION, isSideEffectingTool, normalizeError } from "./protocol.js";
|
|
11
|
+
import { classifyCommand, classifyGitWrite, isSensitivePath } from "./security-policy.js";
|
|
12
|
+
import { ProcessManager } from "./process-manager.js";
|
|
13
|
+
import { IdempotencyJournal } from "./state.js";
|
|
14
|
+
import { ApprovalMemory } from "./approval-memory.js";
|
|
15
|
+
import { ChatApprovalBroker } from "./chat-approval.js";
|
|
16
|
+
import { TerminalHistory } from "./terminal-history.js";
|
|
17
|
+
import { audit } from "./audit.js";
|
|
18
|
+
import { SemanticRouter } from "./semantic-router.js";
|
|
19
|
+
import { ProjectContextEngine } from "./context-engine.js";
|
|
20
|
+
import { EditingEngine } from "./editing-engine.js";
|
|
21
|
+
import { VerificationEngine } from "./verification.js";
|
|
22
|
+
import { defaultDeviceIdentity, loadLocalCredential } from "./identity.js";
|
|
23
|
+
import { McpHub } from "./mcp-hub.js";
|
|
24
|
+
const SERVER_URL = process.env.SERVER_URL;
|
|
25
|
+
const PROJECT_ROOT = process.env.PROJECT_ROOT;
|
|
26
|
+
const LEGACY_DEVICE_TOKEN = process.env.DEVICE_TOKEN;
|
|
27
|
+
const ALLOW_SHELL = process.env.CODELOCAL_ALLOW_SHELL === "1";
|
|
28
|
+
const APPROVAL_MODE = (process.env.CODELOCAL_APPROVAL_MODE ?? "prompt");
|
|
29
|
+
const NETWORK_POLICY = (process.env.CODELOCAL_NETWORK ?? "approval");
|
|
30
|
+
const MIRROR_PROCESS_OUTPUT = process.env.CODELOCAL_MIRROR_PROCESS_OUTPUT !== "0";
|
|
31
|
+
const MAX_READ_BYTES = Number(process.env.CODELOCAL_MAX_READ_BYTES ?? 2 * 1024 * 1024);
|
|
32
|
+
const MAX_BATCH_BYTES = Number(process.env.CODELOCAL_MAX_BATCH_BYTES ?? 8 * 1024 * 1024);
|
|
33
|
+
const MAX_LIST_ENTRIES = Number(process.env.CODELOCAL_MAX_LIST_ENTRIES ?? 10000);
|
|
34
|
+
const MAX_OUTPUT_BYTES = Number(process.env.CODELOCAL_MAX_OUTPUT_BYTES ?? 2 * 1024 * 1024);
|
|
35
|
+
if (!SERVER_URL || !PROJECT_ROOT) {
|
|
36
|
+
console.error("Required: SERVER_URL and PROJECT_ROOT. Pairing credential or DEVICE_TOKEN is also required for registration.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const root = await fs.realpath(path.resolve(PROJECT_ROOT));
|
|
40
|
+
const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
41
|
+
const localCredential = await loadLocalCredential(SERVER_URL);
|
|
42
|
+
const defaults = defaultDeviceIdentity();
|
|
43
|
+
const DEVICE_ID = process.env.CODELOCAL_DEVICE_ID ?? localCredential?.deviceId ?? defaults.deviceId;
|
|
44
|
+
const DEVICE_NAME = process.env.CODELOCAL_DEVICE_NAME ?? localCredential?.deviceName ?? defaults.deviceName;
|
|
45
|
+
const WORKSPACE_ID = process.env.CODELOCAL_WORKSPACE_ID ?? path.basename(root);
|
|
46
|
+
const WORKSPACE_NAME = process.env.CODELOCAL_WORKSPACE_NAME ?? path.basename(root);
|
|
47
|
+
const WORKSPACE_KEY = `${DEVICE_ID}::${WORKSPACE_ID}`;
|
|
48
|
+
const semantic = new SemanticRouter(root);
|
|
49
|
+
const context = new ProjectContextEngine(root, semantic);
|
|
50
|
+
const editing = new EditingEngine(root);
|
|
51
|
+
const verification = new VerificationEngine(root, semantic, context);
|
|
52
|
+
const approvalMemory = new ApprovalMemory();
|
|
53
|
+
const chatApproval = new ChatApprovalBroker();
|
|
54
|
+
const terminalHistory = new TerminalHistory();
|
|
55
|
+
const journal = new IdempotencyJournal();
|
|
56
|
+
let mcpConnectAuthorized = false;
|
|
57
|
+
const mcpHub = new McpHub(root, async () => {
|
|
58
|
+
if (!mcpConnectAuthorized)
|
|
59
|
+
throw new Error("Starting an installed MCP runtime requires approval in ChatGPT. Call mcp_call and approve it there.");
|
|
60
|
+
});
|
|
61
|
+
const processManager = new ProcessManager(root, WORKSPACE_KEY, (_record, stream, text) => {
|
|
62
|
+
if (MIRROR_PROCESS_OUTPUT)
|
|
63
|
+
mirrorProcessOutput(stream, text);
|
|
64
|
+
}, async (record) => {
|
|
65
|
+
await terminalHistory.finished(record);
|
|
66
|
+
semantic.invalidate();
|
|
67
|
+
context.invalidate();
|
|
68
|
+
metadataEpoch++;
|
|
69
|
+
log("debug", "workspace.process_settled", { metadataEpoch });
|
|
70
|
+
});
|
|
71
|
+
let ignoreMatcher = ignore();
|
|
72
|
+
let metadataEpoch = 0;
|
|
73
|
+
let reconnectDelay = 1000;
|
|
74
|
+
let activeSocket = null;
|
|
75
|
+
function isInsideRoot(candidate) {
|
|
76
|
+
return candidate === root || candidate.startsWith(rootPrefix);
|
|
77
|
+
}
|
|
78
|
+
function rel(p) {
|
|
79
|
+
return path.relative(root, p).split(path.sep).join("/") || ".";
|
|
80
|
+
}
|
|
81
|
+
function lexicalPath(relativePath) {
|
|
82
|
+
if (path.isAbsolute(relativePath))
|
|
83
|
+
throw new Error("Absolute paths are not allowed.");
|
|
84
|
+
const candidate = path.resolve(root, relativePath || ".");
|
|
85
|
+
if (!isInsideRoot(candidate))
|
|
86
|
+
throw new Error("Path escapes PROJECT_ROOT.");
|
|
87
|
+
return candidate;
|
|
88
|
+
}
|
|
89
|
+
async function safeExistingPath(relativePath) {
|
|
90
|
+
if (isSensitivePath(relativePath))
|
|
91
|
+
throw new Error(`Access blocked by sensitive-path policy: ${relativePath}`);
|
|
92
|
+
const real = await fs.realpath(lexicalPath(relativePath));
|
|
93
|
+
if (!isInsideRoot(real))
|
|
94
|
+
throw new Error("Resolved path escapes PROJECT_ROOT (possible symlink traversal).");
|
|
95
|
+
return real;
|
|
96
|
+
}
|
|
97
|
+
async function safeWritePath(relativePath) {
|
|
98
|
+
if (isSensitivePath(relativePath))
|
|
99
|
+
throw new Error(`Access blocked by sensitive-path policy: ${relativePath}`);
|
|
100
|
+
const candidate = lexicalPath(relativePath);
|
|
101
|
+
await fs.mkdir(path.dirname(candidate), { recursive: true });
|
|
102
|
+
const realParent = await fs.realpath(path.dirname(candidate));
|
|
103
|
+
if (!isInsideRoot(realParent))
|
|
104
|
+
throw new Error("Parent directory escapes PROJECT_ROOT.");
|
|
105
|
+
try {
|
|
106
|
+
const real = await fs.realpath(candidate);
|
|
107
|
+
if (!isInsideRoot(real))
|
|
108
|
+
throw new Error("Existing file escapes PROJECT_ROOT.");
|
|
109
|
+
return real;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error.code !== "ENOENT")
|
|
113
|
+
throw error;
|
|
114
|
+
return path.join(realParent, path.basename(candidate));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function reloadIgnore() {
|
|
118
|
+
const matcher = ignore();
|
|
119
|
+
matcher.add([".git/", ".DS_Store"]);
|
|
120
|
+
try {
|
|
121
|
+
matcher.add(await fs.readFile(path.join(root, ".gitignore"), "utf8"));
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
try {
|
|
125
|
+
matcher.add(await fs.readFile(path.join(root, ".git", "info", "exclude"), "utf8"));
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
ignoreMatcher = matcher;
|
|
129
|
+
metadataEpoch++;
|
|
130
|
+
}
|
|
131
|
+
await reloadIgnore();
|
|
132
|
+
function isIgnored(relativePath) {
|
|
133
|
+
const p = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
134
|
+
if (!p || p === ".")
|
|
135
|
+
return false;
|
|
136
|
+
return ignoreMatcher.ignores(p) || ignoreMatcher.ignores(p.endsWith("/") ? p : `${p}/`);
|
|
137
|
+
}
|
|
138
|
+
function hashBuffer(buf) {
|
|
139
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
140
|
+
}
|
|
141
|
+
async function isBinary(file) {
|
|
142
|
+
const handle = await fs.open(file, "r");
|
|
143
|
+
try {
|
|
144
|
+
const buf = Buffer.alloc(8192);
|
|
145
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
146
|
+
for (let i = 0; i < bytesRead; i++)
|
|
147
|
+
if (buf[i] === 0)
|
|
148
|
+
return true;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await handle.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function fileMeta(file) {
|
|
156
|
+
const stat = await fs.stat(file);
|
|
157
|
+
const relative = rel(file);
|
|
158
|
+
if (isSensitivePath(relative))
|
|
159
|
+
throw new Error(`Access blocked by sensitive-path policy: ${relative}`);
|
|
160
|
+
const hash = stat.isFile() && stat.size <= MAX_READ_BYTES * 4 ? hashBuffer(await fs.readFile(file)) : null;
|
|
161
|
+
return { path: relative, size: stat.size, mtimeMs: stat.mtimeMs, hash, ignored: isIgnored(relative), isFile: stat.isFile(), isDirectory: stat.isDirectory() };
|
|
162
|
+
}
|
|
163
|
+
async function readOne(requestedPath, startLine, endLine) {
|
|
164
|
+
const file = await safeExistingPath(requestedPath);
|
|
165
|
+
const stat = await fs.stat(file);
|
|
166
|
+
if (!stat.isFile())
|
|
167
|
+
throw new Error(`${requestedPath}: not a file.`);
|
|
168
|
+
if (stat.size > MAX_READ_BYTES && startLine == null)
|
|
169
|
+
throw new Error(`${requestedPath}: exceeds read limit; use read_file_range.`);
|
|
170
|
+
if (await isBinary(file))
|
|
171
|
+
return { ...(await fileMeta(file)), binary: true, content: null };
|
|
172
|
+
const text = await fs.readFile(file, "utf8");
|
|
173
|
+
const lines = text.split(/\r?\n/);
|
|
174
|
+
const from = Math.max(1, startLine ?? 1);
|
|
175
|
+
const to = Math.min(lines.length, endLine ?? lines.length);
|
|
176
|
+
return { ...(await fileMeta(file)), binary: false, startLine: from, endLine: to, totalLines: lines.length, content: lines.slice(from - 1, to).join("\n") };
|
|
177
|
+
}
|
|
178
|
+
async function listFiles(startRelative = ".", maxDepth = 4, includeIgnored = false) {
|
|
179
|
+
const start = await safeExistingPath(startRelative);
|
|
180
|
+
if (!(await fs.stat(start)).isDirectory())
|
|
181
|
+
throw new Error("Path is not a directory.");
|
|
182
|
+
const entries = [];
|
|
183
|
+
const walk = async (dir, depth) => {
|
|
184
|
+
if (entries.length >= MAX_LIST_ENTRIES)
|
|
185
|
+
return;
|
|
186
|
+
const children = await fs.readdir(dir, { withFileTypes: true });
|
|
187
|
+
children.sort((a, b) => a.name.localeCompare(b.name));
|
|
188
|
+
for (const child of children) {
|
|
189
|
+
if (entries.length >= MAX_LIST_ENTRIES)
|
|
190
|
+
break;
|
|
191
|
+
const absolute = path.join(dir, child.name);
|
|
192
|
+
const relative = rel(absolute);
|
|
193
|
+
const sensitive = isSensitivePath(relative);
|
|
194
|
+
const ignored = isIgnored(relative);
|
|
195
|
+
if (sensitive) {
|
|
196
|
+
entries.push({ path: relative, type: child.isDirectory() ? "directory" : "file", sensitive: true });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (ignored && !includeIgnored)
|
|
200
|
+
continue;
|
|
201
|
+
if (child.isSymbolicLink()) {
|
|
202
|
+
entries.push({ path: relative, type: "symlink", ignored });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (child.isDirectory()) {
|
|
206
|
+
entries.push({ path: `${relative}/`, type: "directory", ignored });
|
|
207
|
+
if (depth < maxDepth)
|
|
208
|
+
await walk(absolute, depth + 1);
|
|
209
|
+
}
|
|
210
|
+
else
|
|
211
|
+
entries.push({ path: relative, type: "file", ignored });
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
await walk(start, 0);
|
|
215
|
+
return { entries, truncated: entries.length >= MAX_LIST_ENTRIES, includeIgnored };
|
|
216
|
+
}
|
|
217
|
+
async function runDirect(command, args, options = {}) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const child = spawn(command, args, { cwd: options.cwd ?? root, env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat", CI: process.env.CI ?? "1" }, stdio: "pipe" });
|
|
220
|
+
let stdout = "", stderr = "", done = false;
|
|
221
|
+
const timer = options.timeoutMs ? setTimeout(() => { if (!done)
|
|
222
|
+
child.kill("SIGTERM"); }, options.timeoutMs) : null;
|
|
223
|
+
child.stdout.on("data", (d) => { stdout = (stdout + d.toString()).slice(-MAX_OUTPUT_BYTES); });
|
|
224
|
+
child.stderr.on("data", (d) => { stderr = (stderr + d.toString()).slice(-MAX_OUTPUT_BYTES); });
|
|
225
|
+
child.on("error", reject);
|
|
226
|
+
child.on("close", (code) => { done = true; if (timer)
|
|
227
|
+
clearTimeout(timer); resolve({ stdout, stderr, exitCode: code }); });
|
|
228
|
+
if (options.input != null) {
|
|
229
|
+
child.stdin.write(options.input);
|
|
230
|
+
child.stdin.end();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function searchCode(query, requestedPath = ".", maxResults = 200, fixedStrings = false, includeIgnored = false) {
|
|
235
|
+
const cwd = await safeExistingPath(requestedPath);
|
|
236
|
+
const rgArgs = ["--line-number", "--column", "--no-heading", "--color", "never", "--hidden"];
|
|
237
|
+
if (includeIgnored)
|
|
238
|
+
rgArgs.push("--no-ignore");
|
|
239
|
+
if (fixedStrings)
|
|
240
|
+
rgArgs.push("--fixed-strings");
|
|
241
|
+
rgArgs.push("--glob", "!.git/**", "--", query, ".");
|
|
242
|
+
let result = await runDirect("rg", rgArgs, { cwd, timeoutMs: 30_000 }).catch(() => null);
|
|
243
|
+
if (!result)
|
|
244
|
+
result = await runDirect("grep", ["-RIn", "--", query, "."], { cwd, timeoutMs: 30_000 });
|
|
245
|
+
const matches = result.stdout.split("\n").filter(Boolean).filter((line) => !isSensitivePath(line.split(":", 1)[0].replace(/^\.\//, "")));
|
|
246
|
+
return { matches: matches.slice(0, maxResults), truncated: matches.length > maxResults, includeIgnored };
|
|
247
|
+
}
|
|
248
|
+
async function git(args, timeoutMs = 30_000) {
|
|
249
|
+
return runDirect("git", args, { cwd: root, timeoutMs });
|
|
250
|
+
}
|
|
251
|
+
async function readInstructions(requestedPath = ".") {
|
|
252
|
+
const target = await safeExistingPath(requestedPath);
|
|
253
|
+
const stat = await fs.stat(target);
|
|
254
|
+
const targetDir = stat.isDirectory() ? target : path.dirname(target);
|
|
255
|
+
const dirs = [];
|
|
256
|
+
let current = targetDir;
|
|
257
|
+
while (isInsideRoot(current)) {
|
|
258
|
+
dirs.push(current);
|
|
259
|
+
if (current === root)
|
|
260
|
+
break;
|
|
261
|
+
current = path.dirname(current);
|
|
262
|
+
}
|
|
263
|
+
dirs.reverse();
|
|
264
|
+
const instructionFiles = [];
|
|
265
|
+
for (const dir of dirs) {
|
|
266
|
+
const candidate = rel(dir) === "." ? "AGENTS.md" : `${rel(dir)}/AGENTS.md`;
|
|
267
|
+
try {
|
|
268
|
+
const r = await readOne(candidate);
|
|
269
|
+
if (!r.binary && r.content)
|
|
270
|
+
instructionFiles.push({ path: candidate, content: r.content });
|
|
271
|
+
}
|
|
272
|
+
catch { }
|
|
273
|
+
}
|
|
274
|
+
for (const candidate of ["CLAUDE.md", ".github/copilot-instructions.md"]) {
|
|
275
|
+
try {
|
|
276
|
+
const r = await readOne(candidate);
|
|
277
|
+
if (!r.binary && r.content)
|
|
278
|
+
instructionFiles.push({ path: candidate, content: r.content });
|
|
279
|
+
}
|
|
280
|
+
catch { }
|
|
281
|
+
}
|
|
282
|
+
return { requestedPath, instructionFiles };
|
|
283
|
+
}
|
|
284
|
+
async function inspectDependency(name, ecosystem = "auto") {
|
|
285
|
+
const project = await context.map();
|
|
286
|
+
const chosen = ecosystem === "auto" ? (project.languages.includes("python") && !project.languages.includes("typescript/javascript") ? "python" : project.languages.includes("rust") && !project.languages.includes("typescript/javascript") ? "rust" : project.languages.includes("go") && !project.languages.includes("typescript/javascript") ? "go" : "node") : ecosystem;
|
|
287
|
+
if (chosen === "node") {
|
|
288
|
+
const pkg = path.join(root, "node_modules", name, "package.json");
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(await fs.readFile(pkg, "utf8"));
|
|
291
|
+
return { ecosystem: "node", installed: true, name: parsed.name ?? name, version: parsed.version ?? null, packagePath: rel(pkg), exports: parsed.exports ?? null, types: parsed.types ?? parsed.typings ?? null, dependencies: parsed.dependencies ?? null };
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return { ecosystem: "node", installed: false, name };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (chosen === "python") {
|
|
298
|
+
const r = await runDirect(process.platform === "win32" ? "python" : "python3", ["-c", `import importlib.util,json; s=importlib.util.find_spec(${JSON.stringify(name)}); print(json.dumps({\"found\":bool(s),\"origin\":getattr(s,\"origin\",None),\"locations\":list(getattr(s,\"submodule_search_locations\",[]) or [])}))`], { timeoutMs: 10_000 }).catch(() => null);
|
|
299
|
+
return { ecosystem: "python", name, ...(r ? JSON.parse(r.stdout || "{}") : { found: false }) };
|
|
300
|
+
}
|
|
301
|
+
if (chosen === "rust") {
|
|
302
|
+
const r = await runDirect("cargo", ["metadata", "--format-version", "1", "--no-deps"], { timeoutMs: 30_000 });
|
|
303
|
+
const metadata = JSON.parse(r.stdout || "{}");
|
|
304
|
+
const pkg = (metadata.packages ?? []).find((p) => p.name === name);
|
|
305
|
+
return { ecosystem: "rust", name, package: pkg ?? null };
|
|
306
|
+
}
|
|
307
|
+
if (chosen === "go") {
|
|
308
|
+
const r = await runDirect("go", ["list", "-m", "-json", name], { timeoutMs: 20_000 });
|
|
309
|
+
return { ecosystem: "go", name, module: r.exitCode === 0 ? JSON.parse(r.stdout || "{}") : null };
|
|
310
|
+
}
|
|
311
|
+
return { ecosystem: chosen, name, supported: false };
|
|
312
|
+
}
|
|
313
|
+
function validatePatchPaths(patchText) {
|
|
314
|
+
for (const line of patchText.split("\n")) {
|
|
315
|
+
if (!line.startsWith("+++ ") && !line.startsWith("--- "))
|
|
316
|
+
continue;
|
|
317
|
+
const raw = line.slice(4).trim().split("\t")[0];
|
|
318
|
+
if (raw === "/dev/null")
|
|
319
|
+
continue;
|
|
320
|
+
const normalized = raw.replace(/^[ab]\//, "");
|
|
321
|
+
if (path.isAbsolute(normalized) || normalized.split(/[\\/]+/).includes(".."))
|
|
322
|
+
throw new Error(`Unsafe patch path: ${raw}`);
|
|
323
|
+
if (isSensitivePath(normalized))
|
|
324
|
+
throw new Error(`Access blocked by sensitive-path policy: ${normalized}`);
|
|
325
|
+
lexicalPath(normalized);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function commandCwd(requested = ".") {
|
|
329
|
+
const cwd = await safeExistingPath(requested);
|
|
330
|
+
if (!(await fs.stat(cwd)).isDirectory())
|
|
331
|
+
throw new Error("Command cwd is not a directory.");
|
|
332
|
+
return cwd;
|
|
333
|
+
}
|
|
334
|
+
function hostPolicyInfo() {
|
|
335
|
+
return {
|
|
336
|
+
platform: process.platform,
|
|
337
|
+
backend: "host-policy",
|
|
338
|
+
mode: "policy-only",
|
|
339
|
+
available: true,
|
|
340
|
+
networkMode: NETWORK_POLICY,
|
|
341
|
+
notes: [
|
|
342
|
+
"commands execute on the host after deterministic local policy checks",
|
|
343
|
+
"rememberable approvals are stored only on this machine and scoped to the workspace",
|
|
344
|
+
"critical actions always require fresh confirmation in ChatGPT",
|
|
345
|
+
"explicit paths outside the authorized workspace and credential-retrieval commands are blocked",
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function rememberedDecision(decision) {
|
|
350
|
+
if (decision.approvalPolicy !== "rememberable" || !decision.approvalKey)
|
|
351
|
+
return null;
|
|
352
|
+
return approvalMemory.find(WORKSPACE_KEY, decision.approvalKey);
|
|
353
|
+
}
|
|
354
|
+
async function repositoryScopedDecision(decision) {
|
|
355
|
+
if (decision.approvalPolicy !== "rememberable" || !decision.approvalKey?.startsWith("git.push:"))
|
|
356
|
+
return decision;
|
|
357
|
+
const parts = decision.approvalKey.split(":");
|
|
358
|
+
const remote = parts[1];
|
|
359
|
+
if (!remote)
|
|
360
|
+
return { ...decision, approvalPolicy: "always", approvalKey: undefined };
|
|
361
|
+
const resolved = await git(["remote", "get-url", "--push", remote], 10_000).catch(() => null);
|
|
362
|
+
const url = resolved?.exitCode === 0 ? resolved.stdout.trim() : "";
|
|
363
|
+
if (!url)
|
|
364
|
+
return { ...decision, approvalPolicy: "always", approvalKey: undefined };
|
|
365
|
+
return { ...decision, approvalKey: `${decision.approvalKey}:remote-${hashBuffer(Buffer.from(url)).slice(0, 16)}` };
|
|
366
|
+
}
|
|
367
|
+
async function terminalPreflight(command, requestedCwd = ".", requestId) {
|
|
368
|
+
if (!ALLOW_SHELL)
|
|
369
|
+
return { status: "blocked", riskLevel: "BLOCKED", reason: "Shell execution is disabled for this workspace.", matchedRules: ["shell-disabled"], command: String(command), approvalPolicy: "blocked" };
|
|
370
|
+
const cwd = await commandCwd(requestedCwd);
|
|
371
|
+
const relativeCwd = rel(cwd);
|
|
372
|
+
const decision = await repositoryScopedDecision(classifyCommand(command, NETWORK_POLICY, { workspaceRoot: root, cwd }));
|
|
373
|
+
const remembered = await rememberedDecision(decision);
|
|
374
|
+
if (remembered) {
|
|
375
|
+
const result = { status: "safe", riskLevel: decision.riskLevel, reason: decision.reason, matchedRules: decision.matchedRules, command: decision.redactedCommand, approvalPolicy: decision.approvalPolicy, approvalKey: decision.approvalKey, approvalLabel: decision.approvalLabel, remembered: true };
|
|
376
|
+
await audit({ event: "terminal.approval_memory_hit", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { cwd: relativeCwd, actionKey: decision.approvalKey, command: decision.redactedCommand } });
|
|
377
|
+
return { ...result, cwd: relativeCwd };
|
|
378
|
+
}
|
|
379
|
+
const result = chatApproval.preflight(command, relativeCwd, decision);
|
|
380
|
+
await audit({ event: "terminal.preflight", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { cwd: relativeCwd, command: decision.redactedCommand, rules: decision.matchedRules, status: result.status, approvalPolicy: decision.approvalPolicy, approvalKey: decision.approvalKey } });
|
|
381
|
+
return { ...result, cwd: relativeCwd };
|
|
382
|
+
}
|
|
383
|
+
async function runGuardedCommand(command, options = {}) {
|
|
384
|
+
if (!ALLOW_SHELL)
|
|
385
|
+
throw new Error("Shell execution is disabled. Restart client with CODELOCAL_ALLOW_SHELL=1.");
|
|
386
|
+
const cwd = await commandCwd(options.cwd ?? ".");
|
|
387
|
+
const relativeCwd = rel(cwd);
|
|
388
|
+
const decision = await repositoryScopedDecision(classifyCommand(command, NETWORK_POLICY, { workspaceRoot: root, cwd }));
|
|
389
|
+
await audit({ event: "policy.command", requestId: options.requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { cwd: relativeCwd, command: decision.redactedCommand, rules: decision.matchedRules, blocked: decision.blocked, approvalPolicy: decision.approvalPolicy, approvalKey: decision.approvalKey } });
|
|
390
|
+
if (decision.blocked)
|
|
391
|
+
throw new Error(`Command blocked by policy: ${decision.reason}`);
|
|
392
|
+
let approvalMode = "automatic";
|
|
393
|
+
if (decision.requiresApproval) {
|
|
394
|
+
const remembered = await rememberedDecision(decision);
|
|
395
|
+
if (remembered && decision.approvalKey) {
|
|
396
|
+
approvalMode = "remembered";
|
|
397
|
+
await approvalMemory.touch(WORKSPACE_KEY, decision.approvalKey);
|
|
398
|
+
await audit({ event: "terminal.approval_memory_used", requestId: options.requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { cwd: relativeCwd, actionKey: decision.approvalKey, command: decision.redactedCommand } });
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const approved = chatApproval.consume(options.approvalToken, command, relativeCwd, decision);
|
|
402
|
+
if (!approved) {
|
|
403
|
+
const pending = chatApproval.preflight(command, relativeCwd, decision);
|
|
404
|
+
await audit({ event: "terminal.approval_required", requestId: options.requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { cwd: relativeCwd, command: decision.redactedCommand, rules: decision.matchedRules, expiresAt: pending.expiresAt, approvalPolicy: decision.approvalPolicy, approvalKey: decision.approvalKey } });
|
|
405
|
+
return { ...pending, cwd: relativeCwd };
|
|
406
|
+
}
|
|
407
|
+
approvalMode = "chat";
|
|
408
|
+
const learned = await approvalMemory.remember(WORKSPACE_KEY, decision);
|
|
409
|
+
await audit({ event: learned ? "terminal.approval_remembered" : "terminal.chat_approved", requestId: options.requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { cwd: relativeCwd, command: decision.redactedCommand, rules: decision.matchedRules, actionKey: learned?.actionKey } });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const started = await processManager.start(command, { cwd, timeoutMs: options.timeoutMs, usePty: options.usePty, requestId: options.requestId, ownerSessionId: options.ownerSessionId });
|
|
413
|
+
await terminalHistory.started({
|
|
414
|
+
workspaceKey: WORKSPACE_KEY,
|
|
415
|
+
processId: started.processId,
|
|
416
|
+
requestId: options.requestId,
|
|
417
|
+
sessionId: options.ownerSessionId,
|
|
418
|
+
cwd: relativeCwd,
|
|
419
|
+
command,
|
|
420
|
+
riskLevel: decision.riskLevel,
|
|
421
|
+
matchedRules: decision.matchedRules,
|
|
422
|
+
approval: approvalMode,
|
|
423
|
+
startedAt: started.startedAt,
|
|
424
|
+
executionMode: started.executionMode,
|
|
425
|
+
});
|
|
426
|
+
return started;
|
|
427
|
+
}
|
|
428
|
+
async function gitWrite(operation, detail, args, requestId, approvalToken) {
|
|
429
|
+
const decision = await repositoryScopedDecision(classifyGitWrite(operation, detail));
|
|
430
|
+
if (decision.blocked)
|
|
431
|
+
throw new Error(`Git operation blocked by policy: ${decision.reason}`);
|
|
432
|
+
const approvalCommand = `git ${operation} ${detail}`.trim();
|
|
433
|
+
if (decision.requiresApproval) {
|
|
434
|
+
const remembered = await rememberedDecision(decision);
|
|
435
|
+
if (remembered && decision.approvalKey) {
|
|
436
|
+
await approvalMemory.touch(WORKSPACE_KEY, decision.approvalKey);
|
|
437
|
+
await audit({ event: "git.approval_memory_used", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { operation, actionKey: decision.approvalKey, command: decision.redactedCommand } });
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const approved = chatApproval.consume(approvalToken, approvalCommand, ".", decision);
|
|
441
|
+
if (!approved) {
|
|
442
|
+
const pending = chatApproval.preflight(approvalCommand, ".", decision);
|
|
443
|
+
await audit({ event: "git.approval_required", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { operation, command: decision.redactedCommand, rules: decision.matchedRules, expiresAt: pending.expiresAt, approvalPolicy: decision.approvalPolicy, approvalKey: decision.approvalKey } });
|
|
444
|
+
return pending;
|
|
445
|
+
}
|
|
446
|
+
const learned = await approvalMemory.remember(WORKSPACE_KEY, decision);
|
|
447
|
+
await audit({ event: learned ? "git.approval_remembered" : "git.chat_approved", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { operation, command: decision.redactedCommand, rules: decision.matchedRules, actionKey: learned?.actionKey } });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const result = await git(args, 120_000);
|
|
451
|
+
if (result.exitCode !== 0)
|
|
452
|
+
throw new Error(result.stderr || result.stdout || `git ${operation} failed`);
|
|
453
|
+
await audit({ event: "git.write", requestId, workspaceKey: WORKSPACE_KEY, status: "completed", detail: { operation, outputBytes: Buffer.byteLength(result.stdout + result.stderr, "utf8") } });
|
|
454
|
+
return { exitCode: result.exitCode, output: result.stdout + result.stderr };
|
|
455
|
+
}
|
|
456
|
+
async function callInstalledMcp(server, tool, args, requestId, approvalToken) {
|
|
457
|
+
const info = await mcpHub.toolInfo(server, tool);
|
|
458
|
+
const readOnlyHint = info.annotations?.readOnlyHint === true;
|
|
459
|
+
const rule = `mcp:${server}:${tool}`;
|
|
460
|
+
const decision = {
|
|
461
|
+
riskLevel: "REVIEW",
|
|
462
|
+
matchedRules: [rule],
|
|
463
|
+
requiresApproval: true,
|
|
464
|
+
blocked: false,
|
|
465
|
+
redactedCommand: `mcp ${server}.${tool}`,
|
|
466
|
+
reason: readOnlyHint
|
|
467
|
+
? "external MCP call; server advertises readOnlyHint, but external annotations are advisory"
|
|
468
|
+
: "external MCP call may have side effects",
|
|
469
|
+
approvalPolicy: "always",
|
|
470
|
+
};
|
|
471
|
+
await audit({ event: "policy.mcp", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { server, tool, readOnlyHint, rule } });
|
|
472
|
+
const approvalCommand = `mcp ${server}.${tool}`;
|
|
473
|
+
const approved = chatApproval.consume(approvalToken, approvalCommand, ".", decision);
|
|
474
|
+
if (!approved) {
|
|
475
|
+
const pending = chatApproval.preflight(approvalCommand, ".", decision);
|
|
476
|
+
await audit({ event: "mcp.approval_required", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, detail: { server, tool, rule, expiresAt: pending.expiresAt } });
|
|
477
|
+
return pending;
|
|
478
|
+
}
|
|
479
|
+
await audit({ event: "mcp.chat_approved", requestId, workspaceKey: WORKSPACE_KEY, riskLevel: decision.riskLevel, status: "approved", detail: { server, tool, rule } });
|
|
480
|
+
const startedAt = Date.now();
|
|
481
|
+
mcpConnectAuthorized = true;
|
|
482
|
+
try {
|
|
483
|
+
const result = await mcpHub.callTool(server, tool, args);
|
|
484
|
+
await audit({ event: "mcp.call", requestId, workspaceKey: WORKSPACE_KEY, tool: `${server}.${tool}`, status: "ok", detail: { durationMs: Date.now() - startedAt } });
|
|
485
|
+
return result;
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
await audit({ event: "mcp.call", requestId, workspaceKey: WORKSPACE_KEY, tool: `${server}.${tool}`, status: "failed", detail: { durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) } });
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
mcpConnectAuthorized = false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
async function handleTool(tool, args, request) {
|
|
496
|
+
if (tool === "project_info") {
|
|
497
|
+
const project = await context.map();
|
|
498
|
+
return {
|
|
499
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
500
|
+
projectRoot: root,
|
|
501
|
+
projectName: WORKSPACE_NAME,
|
|
502
|
+
deviceId: DEVICE_ID,
|
|
503
|
+
deviceName: DEVICE_NAME,
|
|
504
|
+
workspaceId: WORKSPACE_ID,
|
|
505
|
+
workspaceKey: WORKSPACE_KEY,
|
|
506
|
+
project,
|
|
507
|
+
instructions: (await readInstructions(".")).instructionFiles,
|
|
508
|
+
semantic: await semantic.info(),
|
|
509
|
+
executionSecurity: hostPolicyInfo(),
|
|
510
|
+
sandbox: hostPolicyInfo(),
|
|
511
|
+
shellEnabled: ALLOW_SHELL,
|
|
512
|
+
approvalMode: APPROVAL_MODE,
|
|
513
|
+
terminalApproval: "chat-mediated",
|
|
514
|
+
approvalMemory: "local-workspace-scoped",
|
|
515
|
+
networkPolicy: NETWORK_POLICY,
|
|
516
|
+
metadataEpoch,
|
|
517
|
+
capabilities: ["protocol-v2", "gitignore-aware-retrieval", "sensitive-path-policy", "polyglot-semantic-router", "lsp", "context-engine", "transactional-edits", "diagnostic-regression", "process-manager-v2", "pty-when-installed", "cancellation", "host-policy-execution", "structured-command-policy", "approval-memory", "idempotency", "git-write-approval", "terminal-chat-approval", "terminal-history", "mcp-hub", "audit"],
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (tool === "approval_list")
|
|
521
|
+
return { approvals: await approvalMemory.list(WORKSPACE_KEY) };
|
|
522
|
+
if (tool === "approval_revoke") {
|
|
523
|
+
const identifier = String(args.id ?? args.actionKey ?? "").trim();
|
|
524
|
+
if (!identifier)
|
|
525
|
+
throw new Error("approval_revoke requires an approval id or actionKey.");
|
|
526
|
+
return { removed: await approvalMemory.revoke(identifier, WORKSPACE_KEY) };
|
|
527
|
+
}
|
|
528
|
+
if (tool === "approval_reset")
|
|
529
|
+
return { removed: await approvalMemory.reset(WORKSPACE_KEY) };
|
|
530
|
+
if (tool === "mcp_list")
|
|
531
|
+
return { servers: await mcpHub.listServers() };
|
|
532
|
+
if (tool === "mcp_search_tools")
|
|
533
|
+
return mcpHub.searchTools(String(args.query ?? ""), { limit: args.limit ?? 8, server: args.server, refresh: !!args.refresh });
|
|
534
|
+
if (tool === "mcp_tool_info")
|
|
535
|
+
return mcpHub.toolInfo(String(args.server), String(args.tool));
|
|
536
|
+
if (tool === "mcp_call")
|
|
537
|
+
return callInstalledMcp(String(args.server), String(args.tool), (args.arguments ?? {}), request.requestId, args.approvalToken);
|
|
538
|
+
if (tool === "read_instructions")
|
|
539
|
+
return readInstructions(args.path ?? ".");
|
|
540
|
+
if (tool === "project_map")
|
|
541
|
+
return context.map(!!args.force);
|
|
542
|
+
if (tool === "context_for_task")
|
|
543
|
+
return context.relevant(String(args.taskHint ?? ""), args.limit ?? 30);
|
|
544
|
+
if (tool === "list_files")
|
|
545
|
+
return listFiles(args.path ?? ".", args.maxDepth ?? 4, !!args.includeIgnored);
|
|
546
|
+
if (tool === "file_info")
|
|
547
|
+
return fileMeta(await safeExistingPath(String(args.path)));
|
|
548
|
+
if (tool === "read_file")
|
|
549
|
+
return readOne(String(args.path));
|
|
550
|
+
if (tool === "read_file_range")
|
|
551
|
+
return readOne(String(args.path), Number(args.startLine), Number(args.endLine));
|
|
552
|
+
if (tool === "read_files") {
|
|
553
|
+
const files = [];
|
|
554
|
+
let total = 0;
|
|
555
|
+
for (const p of args.paths ?? []) {
|
|
556
|
+
const f = await readOne(String(p));
|
|
557
|
+
total += Number(f.size ?? 0);
|
|
558
|
+
if (total > MAX_BATCH_BYTES)
|
|
559
|
+
throw new Error("Batch exceeds read limit.");
|
|
560
|
+
files.push(f);
|
|
561
|
+
}
|
|
562
|
+
return { files, totalBytes: total };
|
|
563
|
+
}
|
|
564
|
+
if (tool === "search_code")
|
|
565
|
+
return searchCode(String(args.query), args.path ?? ".", args.maxResults ?? 200, !!args.fixedStrings, !!args.includeIgnored);
|
|
566
|
+
if (tool === "inspect_dependency")
|
|
567
|
+
return inspectDependency(String(args.name), args.ecosystem ?? "auto");
|
|
568
|
+
if (tool === "read_dependency") {
|
|
569
|
+
const project = await context.map();
|
|
570
|
+
if (args.ecosystem && args.ecosystem !== "node")
|
|
571
|
+
throw new Error("Targeted read_dependency currently uses filesystem paths for installed Node dependencies; use inspect_dependency plus read_file for other ecosystems.");
|
|
572
|
+
const base = await fs.realpath(path.join(root, "node_modules", String(args.name)));
|
|
573
|
+
const target = path.resolve(base, String(args.path ?? "package.json"));
|
|
574
|
+
if (target !== base && !target.startsWith(base + path.sep))
|
|
575
|
+
throw new Error("Dependency path escape.");
|
|
576
|
+
void project;
|
|
577
|
+
return readOne(rel(target), args.startLine, args.endLine);
|
|
578
|
+
}
|
|
579
|
+
if (tool === "search_dependency")
|
|
580
|
+
return searchCode(String(args.query), `node_modules/${String(args.name)}`, args.maxResults ?? 100, !!args.fixedStrings, true);
|
|
581
|
+
if (tool === "semantic_info")
|
|
582
|
+
return semantic.info();
|
|
583
|
+
if (tool === "workspace_symbols" || tool === "find_symbol")
|
|
584
|
+
return { symbols: await semantic.workspaceSymbols(String(args.query ?? ""), args.limit ?? 200) };
|
|
585
|
+
if (tool === "document_symbols")
|
|
586
|
+
return { symbols: await semantic.documentSymbols(String(args.path), args.limit ?? 500) };
|
|
587
|
+
if (tool === "find_definition")
|
|
588
|
+
return { definitions: await semantic.definition({ path: args.path, line: args.line, column: args.column, name: args.name ?? args.query, limit: args.limit ?? 100 }) };
|
|
589
|
+
if (tool === "find_references")
|
|
590
|
+
return { references: await semantic.references({ path: args.path, line: args.line, column: args.column, name: args.name ?? args.query, limit: args.limit ?? 500 }) };
|
|
591
|
+
if (tool === "find_implementations")
|
|
592
|
+
return { implementations: await semantic.implementations({ path: String(args.path), line: Number(args.line), column: Number(args.column), limit: args.limit ?? 200 }) };
|
|
593
|
+
if (tool === "get_hover")
|
|
594
|
+
return semantic.hover({ path: String(args.path), line: Number(args.line), column: Number(args.column) });
|
|
595
|
+
if (tool === "get_diagnostics")
|
|
596
|
+
return { diagnostics: await semantic.diagnostics(args.path, args.limit ?? 500) };
|
|
597
|
+
if (tool === "get_callers")
|
|
598
|
+
return { callers: semantic.callers(String(args.name), args.limit ?? 300) };
|
|
599
|
+
if (tool === "get_callees")
|
|
600
|
+
return { callees: semantic.callees(String(args.name), args.limit ?? 300) };
|
|
601
|
+
if (tool === "get_import_graph")
|
|
602
|
+
return { edges: semantic.importGraph(args.limit ?? 2000) };
|
|
603
|
+
if (tool === "snapshot_diagnostics")
|
|
604
|
+
return verification.snapshotDiagnostics(args.paths ?? []);
|
|
605
|
+
if (tool === "verify_changes")
|
|
606
|
+
return verification.verify(args.paths ?? [], args.baselineId);
|
|
607
|
+
if (tool === "apply_edits") {
|
|
608
|
+
const result = await editing.applyEdits(args.files ?? []);
|
|
609
|
+
semantic.invalidate();
|
|
610
|
+
context.invalidate();
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
if (tool === "format_changed_files") {
|
|
614
|
+
const result = await editing.formatChangedFiles(args.paths ?? []);
|
|
615
|
+
semantic.invalidate();
|
|
616
|
+
context.invalidate();
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
if (tool === "write_file") {
|
|
620
|
+
const file = await safeWritePath(String(args.path));
|
|
621
|
+
const exists = await fs.stat(file).then(() => true).catch(() => false);
|
|
622
|
+
if (args.expectedHash && exists) {
|
|
623
|
+
const actual = hashBuffer(await fs.readFile(file));
|
|
624
|
+
if (actual !== args.expectedHash)
|
|
625
|
+
throw new Error(`File changed since read. Expected ${args.expectedHash}, got ${actual}.`);
|
|
626
|
+
}
|
|
627
|
+
const content = String(args.content ?? "");
|
|
628
|
+
await fs.writeFile(file, content, "utf8");
|
|
629
|
+
semantic.invalidate();
|
|
630
|
+
context.invalidate();
|
|
631
|
+
return { ...(await fileMeta(file)), bytesWritten: Buffer.byteLength(content, "utf8") };
|
|
632
|
+
}
|
|
633
|
+
if (tool === "edit_file") {
|
|
634
|
+
const file = await safeExistingPath(String(args.path));
|
|
635
|
+
const original = await fs.readFile(file, "utf8");
|
|
636
|
+
if (args.expectedHash) {
|
|
637
|
+
const actual = hashBuffer(Buffer.from(original));
|
|
638
|
+
if (actual !== args.expectedHash)
|
|
639
|
+
throw new Error(`File changed since read. Expected ${args.expectedHash}, got ${actual}.`);
|
|
640
|
+
}
|
|
641
|
+
const oldText = String(args.oldText);
|
|
642
|
+
const count = original.split(oldText).length - 1;
|
|
643
|
+
if (!count)
|
|
644
|
+
throw new Error("oldText not found.");
|
|
645
|
+
if (!args.replaceAll && count !== 1)
|
|
646
|
+
throw new Error(`oldText occurs ${count} times.`);
|
|
647
|
+
const updated = args.replaceAll ? original.split(oldText).join(String(args.newText ?? "")) : original.replace(oldText, String(args.newText ?? ""));
|
|
648
|
+
await fs.writeFile(file, updated, "utf8");
|
|
649
|
+
semantic.invalidate();
|
|
650
|
+
context.invalidate();
|
|
651
|
+
return { ...(await fileMeta(file)), replacements: args.replaceAll ? count : 1 };
|
|
652
|
+
}
|
|
653
|
+
if (tool === "apply_patch") {
|
|
654
|
+
const patchText = String(args.patch ?? "");
|
|
655
|
+
validatePatchPaths(patchText);
|
|
656
|
+
const check = await runDirect("git", ["apply", "--check", "--whitespace=nowarn", "-"], { input: patchText, timeoutMs: 20_000 });
|
|
657
|
+
if (check.exitCode !== 0)
|
|
658
|
+
throw new Error(`Patch check failed: ${check.stderr || check.stdout}`);
|
|
659
|
+
const applied = await runDirect("git", ["apply", "--whitespace=nowarn", "-"], { input: patchText, timeoutMs: 20_000 });
|
|
660
|
+
if (applied.exitCode !== 0)
|
|
661
|
+
throw new Error(`Patch failed: ${applied.stderr || applied.stdout}`);
|
|
662
|
+
semantic.invalidate();
|
|
663
|
+
context.invalidate();
|
|
664
|
+
return { applied: true };
|
|
665
|
+
}
|
|
666
|
+
if (tool === "git_status") {
|
|
667
|
+
const r = await git(["status", "--short", "--branch"]);
|
|
668
|
+
return { exitCode: r.exitCode, output: r.stdout + r.stderr };
|
|
669
|
+
}
|
|
670
|
+
if (tool === "git_diff") {
|
|
671
|
+
const argv = ["diff", "--no-ext-diff", "--unified=3"];
|
|
672
|
+
if (args.cached)
|
|
673
|
+
argv.push("--cached");
|
|
674
|
+
if (args.path)
|
|
675
|
+
argv.push("--", rel(await safeExistingPath(String(args.path))));
|
|
676
|
+
const r = await git(argv);
|
|
677
|
+
return { exitCode: r.exitCode, diff: r.stdout + r.stderr };
|
|
678
|
+
}
|
|
679
|
+
if (tool === "git_log") {
|
|
680
|
+
const r = await git(["log", `-${Math.min(args.limit ?? 20, 100)}`, "--date=iso", "--pretty=format:%h%x09%ad%x09%an%x09%s", ...(args.path ? ["--", String(args.path)] : [])]);
|
|
681
|
+
return { output: r.stdout };
|
|
682
|
+
}
|
|
683
|
+
if (tool === "git_show") {
|
|
684
|
+
const r = await git(["show", "--stat", "--oneline", "--decorate", String(args.ref ?? "HEAD")]);
|
|
685
|
+
return { output: r.stdout + r.stderr };
|
|
686
|
+
}
|
|
687
|
+
if (tool === "git_blame") {
|
|
688
|
+
const p = String(args.path);
|
|
689
|
+
if (isSensitivePath(p))
|
|
690
|
+
throw new Error(`Access blocked by sensitive-path policy: ${p}`);
|
|
691
|
+
const r = await git(["blame", "--line-porcelain", ...(args.startLine && args.endLine ? ["-L", `${args.startLine},${args.endLine}`] : []), "--", p]);
|
|
692
|
+
return { output: r.stdout };
|
|
693
|
+
}
|
|
694
|
+
if (tool === "git_file_history") {
|
|
695
|
+
const p = String(args.path);
|
|
696
|
+
if (isSensitivePath(p))
|
|
697
|
+
throw new Error(`Access blocked by sensitive-path policy: ${p}`);
|
|
698
|
+
const r = await git(["log", "--follow", `-${Math.min(args.limit ?? 30, 100)}`, "--date=iso", "--pretty=format:%h%x09%ad%x09%an%x09%s", "--", p]);
|
|
699
|
+
return { output: r.stdout };
|
|
700
|
+
}
|
|
701
|
+
if (tool === "git_stage") {
|
|
702
|
+
const paths = (args.paths ?? []).map(String);
|
|
703
|
+
return gitWrite("add", paths.join(" "), ["add", "--", ...paths], request.requestId, args.approvalToken);
|
|
704
|
+
}
|
|
705
|
+
if (tool === "git_unstage") {
|
|
706
|
+
const paths = (args.paths ?? []).map(String);
|
|
707
|
+
return gitWrite("restore --staged", paths.join(" "), ["restore", "--staged", "--", ...paths], request.requestId, args.approvalToken);
|
|
708
|
+
}
|
|
709
|
+
if (tool === "git_commit") {
|
|
710
|
+
const staged = await git(["diff", "--cached", "--name-only"]);
|
|
711
|
+
const stagedPaths = staged.stdout.split("\n").filter(Boolean);
|
|
712
|
+
if (!stagedPaths.length)
|
|
713
|
+
throw new Error("No staged changes to commit.");
|
|
714
|
+
if (Array.isArray(args.expectedPaths) && stagedPaths.some((p) => !args.expectedPaths.includes(p)))
|
|
715
|
+
throw new Error(`Unexpected staged changes: ${stagedPaths.filter((p) => !args.expectedPaths.includes(p)).join(", ")}`);
|
|
716
|
+
return gitWrite("commit", String(args.message), ["commit", "-m", String(args.message)], request.requestId, args.approvalToken);
|
|
717
|
+
}
|
|
718
|
+
if (tool === "git_push") {
|
|
719
|
+
if (args.force)
|
|
720
|
+
throw new Error("Force push is blocked by default.");
|
|
721
|
+
const argv = ["push"];
|
|
722
|
+
if (args.remote)
|
|
723
|
+
argv.push(String(args.remote));
|
|
724
|
+
if (args.branch)
|
|
725
|
+
argv.push(String(args.branch));
|
|
726
|
+
return gitWrite("push", argv.slice(1).join(" "), argv, request.requestId, args.approvalToken);
|
|
727
|
+
}
|
|
728
|
+
if (tool === "sandbox_info")
|
|
729
|
+
return hostPolicyInfo();
|
|
730
|
+
if (tool === "sandbox_smoke_test")
|
|
731
|
+
return { ok: true, executionMode: "host-policy", sandbox: false, note: "OS sandboxing is disabled; deterministic policy + ChatGPT approvals are authoritative." };
|
|
732
|
+
if (tool === "terminal_preflight")
|
|
733
|
+
return terminalPreflight(String(args.command ?? ""), String(args.cwd ?? "."), request.requestId);
|
|
734
|
+
if (tool === "terminal_history")
|
|
735
|
+
return terminalHistory.query({ workspaceKey: WORKSPACE_KEY, query: String(args.query ?? ""), limit: args.limit ?? 50, event: args.event ?? "started" });
|
|
736
|
+
if (tool === "run_command") {
|
|
737
|
+
const started = await runGuardedCommand(String(args.command), { cwd: args.cwd ?? ".", timeoutMs: args.timeoutMs ?? 0, requestId: request.requestId, ownerSessionId: request.sessionId, approvalToken: args.approvalToken });
|
|
738
|
+
if (!("processId" in started))
|
|
739
|
+
return started;
|
|
740
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(args.yieldMs ?? 1000, 0), 10_000)));
|
|
741
|
+
return processManager.snapshot(started.processId);
|
|
742
|
+
}
|
|
743
|
+
if (tool === "exec_start")
|
|
744
|
+
return runGuardedCommand(String(args.command), { cwd: args.cwd ?? ".", timeoutMs: args.timeoutMs ?? 0, requestId: request.requestId, ownerSessionId: request.sessionId, usePty: false, approvalToken: args.approvalToken });
|
|
745
|
+
if (tool === "pty_start")
|
|
746
|
+
return runGuardedCommand(String(args.command), { cwd: args.cwd ?? ".", timeoutMs: args.timeoutMs ?? 0, requestId: request.requestId, ownerSessionId: request.sessionId, usePty: true, approvalToken: args.approvalToken });
|
|
747
|
+
if (tool === "exec_poll" || tool === "pty_poll")
|
|
748
|
+
return processManager.snapshot(String(args.processId), { stdout: args.stdoutCursor, stderr: args.stderrCursor });
|
|
749
|
+
if (tool === "process_poll")
|
|
750
|
+
return processManager.snapshot(String(args.processId), { stdout: args.cursor, stderr: args.cursor });
|
|
751
|
+
if (tool === "exec_write" || tool === "pty_write" || tool === "process_write")
|
|
752
|
+
return processManager.write(String(args.processId), String(args.input ?? ""));
|
|
753
|
+
if (tool === "pty_resize")
|
|
754
|
+
return processManager.resize(String(args.processId), Number(args.cols), Number(args.rows));
|
|
755
|
+
if (tool === "exec_signal" || tool === "pty_signal")
|
|
756
|
+
return processManager.signal(String(args.processId), (args.signal ?? "SIGTERM"));
|
|
757
|
+
if (tool === "exec_cancel")
|
|
758
|
+
return processManager.cancel(String(args.processId), String(args.reason ?? "cancelled"));
|
|
759
|
+
if (tool === "exec_kill" || tool === "pty_kill" || tool === "process_kill")
|
|
760
|
+
return processManager.signal(String(args.processId), (args.signal ?? "SIGTERM"));
|
|
761
|
+
if (tool === "process_list")
|
|
762
|
+
return { processes: processManager.list() };
|
|
763
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
764
|
+
}
|
|
765
|
+
const watcher = chokidar.watch(root, {
|
|
766
|
+
ignored: (p) => {
|
|
767
|
+
const relative = rel(p);
|
|
768
|
+
return isSensitivePath(relative) || /(^|\/)(\.git|node_modules|\.next|dist|build|target|\.venv|venv)(\/|$)/.test(relative);
|
|
769
|
+
},
|
|
770
|
+
ignoreInitial: true,
|
|
771
|
+
persistent: true,
|
|
772
|
+
});
|
|
773
|
+
watcher.on("all", (event, changed) => {
|
|
774
|
+
const relative = rel(changed);
|
|
775
|
+
semantic.invalidate();
|
|
776
|
+
context.invalidate();
|
|
777
|
+
metadataEpoch++;
|
|
778
|
+
if (relative === ".gitignore")
|
|
779
|
+
void reloadIgnore();
|
|
780
|
+
log("debug", "workspace.changed", { event, path: relative, metadataEpoch });
|
|
781
|
+
});
|
|
782
|
+
async function clientCapabilities() {
|
|
783
|
+
const semanticInfo = await semantic.info();
|
|
784
|
+
let pty = false;
|
|
785
|
+
try {
|
|
786
|
+
const dynamicImport = new Function("m", "return import(m)");
|
|
787
|
+
await dynamicImport("node-pty");
|
|
788
|
+
pty = true;
|
|
789
|
+
}
|
|
790
|
+
catch { }
|
|
791
|
+
return {
|
|
792
|
+
filesystem: true,
|
|
793
|
+
git: true,
|
|
794
|
+
shell: ALLOW_SHELL,
|
|
795
|
+
pty,
|
|
796
|
+
sandbox: "policy-only",
|
|
797
|
+
semanticProviders: ["typescript", ...semanticInfo.providers.filter((p) => p.installed).map((p) => p.id)],
|
|
798
|
+
idempotency: true,
|
|
799
|
+
cancellation: true,
|
|
800
|
+
approvals: true,
|
|
801
|
+
terminalChatApproval: true,
|
|
802
|
+
terminalHistory: true,
|
|
803
|
+
approvalMemory: true,
|
|
804
|
+
hostPolicyExecution: true,
|
|
805
|
+
mcpHub: true,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
async function executeRequest(message) {
|
|
809
|
+
const requestId = String(message.requestId ?? message.id ?? "");
|
|
810
|
+
const tool = String(message.tool ?? "");
|
|
811
|
+
const idempotencyKey = typeof message.idempotencyKey === "string" ? message.idempotencyKey : undefined;
|
|
812
|
+
if (isSideEffectingTool(tool) && idempotencyKey) {
|
|
813
|
+
const existing = await journal.get(idempotencyKey);
|
|
814
|
+
if (existing?.status === "completed")
|
|
815
|
+
return existing.result;
|
|
816
|
+
if (existing?.status === "started")
|
|
817
|
+
throw new Error(`Duplicate request is already/was ambiguously started: ${idempotencyKey}`);
|
|
818
|
+
await journal.start(idempotencyKey, tool);
|
|
819
|
+
try {
|
|
820
|
+
const result = await handleTool(tool, message.args ?? {}, { requestId, sessionId: message.sessionId });
|
|
821
|
+
await journal.complete(idempotencyKey, result);
|
|
822
|
+
return result;
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
await journal.fail(idempotencyKey, error instanceof Error ? error.message : String(error));
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return handleTool(tool, message.args ?? {}, { requestId, sessionId: message.sessionId });
|
|
830
|
+
}
|
|
831
|
+
async function connect() {
|
|
832
|
+
log("info", "client.connecting", { server: SERVER_URL, protocolVersion: PROTOCOL_VERSION, deviceId: DEVICE_ID, workspaceId: WORKSPACE_ID, projectRoot: root });
|
|
833
|
+
const ws = new WebSocket(SERVER_URL);
|
|
834
|
+
activeSocket = ws;
|
|
835
|
+
ws.on("open", async () => {
|
|
836
|
+
const capabilities = await clientCapabilities();
|
|
837
|
+
ws.send(JSON.stringify({
|
|
838
|
+
type: "register",
|
|
839
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
840
|
+
token: localCredential ? undefined : LEGACY_DEVICE_TOKEN,
|
|
841
|
+
credentialId: localCredential?.credentialId,
|
|
842
|
+
credentialSecret: localCredential?.credentialSecret,
|
|
843
|
+
deviceId: DEVICE_ID,
|
|
844
|
+
deviceName: DEVICE_NAME,
|
|
845
|
+
workspaceId: WORKSPACE_ID,
|
|
846
|
+
workspaceName: WORKSPACE_NAME,
|
|
847
|
+
projectRoot: root,
|
|
848
|
+
capabilities,
|
|
849
|
+
}));
|
|
850
|
+
});
|
|
851
|
+
ws.on("message", async (raw) => {
|
|
852
|
+
let message;
|
|
853
|
+
try {
|
|
854
|
+
message = JSON.parse(raw.toString());
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (message.type === "registered") {
|
|
860
|
+
reconnectDelay = 1000;
|
|
861
|
+
log("info", "client.registered", { protocolVersion: message.protocolVersion, deviceId: DEVICE_ID, workspaceId: WORKSPACE_ID, shell: ALLOW_SHELL, approvalMode: APPROVAL_MODE });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (message.type === "ping") {
|
|
865
|
+
ws.send(JSON.stringify({ type: "pong", protocolVersion: PROTOCOL_VERSION, ts: Date.now(), deviceId: DEVICE_ID, workspaceId: WORKSPACE_ID }));
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (message.type === "tool_cancel") {
|
|
869
|
+
const requestId = String(message.requestId ?? "");
|
|
870
|
+
const result = processManager.cancelRequest(requestId, String(message.reason ?? "tool cancelled"));
|
|
871
|
+
await audit({ event: "tool.cancel", requestId, workspaceKey: WORKSPACE_KEY, status: result.cancelled ? "cancelled" : "not-found" });
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (message.type !== "tool_call")
|
|
875
|
+
return;
|
|
876
|
+
const requestId = String(message.requestId ?? message.id ?? "");
|
|
877
|
+
const startedAt = Date.now();
|
|
878
|
+
log("info", "tool.received", { requestId, tool: message.tool, args: summarizeToolArgs(message.tool, message.args) });
|
|
879
|
+
await audit({ event: "tool.received", requestId, workspaceKey: WORKSPACE_KEY, tool: message.tool });
|
|
880
|
+
try {
|
|
881
|
+
const result = await executeRequest(message);
|
|
882
|
+
ws.send(JSON.stringify({ type: "tool_result", protocolVersion: PROTOCOL_VERSION, requestId, id: requestId, ok: true, result, metadata: { durationMs: Date.now() - startedAt } }));
|
|
883
|
+
log("info", "tool.completed", { requestId, tool: message.tool, durationMs: Date.now() - startedAt });
|
|
884
|
+
await audit({ event: "tool.completed", requestId, workspaceKey: WORKSPACE_KEY, tool: message.tool, status: "ok" });
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
const normalized = normalizeError(error);
|
|
888
|
+
ws.send(JSON.stringify({ type: "tool_result", protocolVersion: PROTOCOL_VERSION, requestId, id: requestId, ok: false, ...normalized, error: normalized.errorMessage, metadata: { durationMs: Date.now() - startedAt } }));
|
|
889
|
+
log("error", "tool.failed", { requestId, tool: message.tool, durationMs: Date.now() - startedAt, error });
|
|
890
|
+
await audit({ event: "tool.failed", requestId, workspaceKey: WORKSPACE_KEY, tool: message.tool, status: normalized.errorCode, detail: normalized.errorMessage });
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
ws.on("close", (code, reason) => {
|
|
894
|
+
log("warn", "client.disconnected", { code, reason: reason.toString(), reconnectInMs: reconnectDelay });
|
|
895
|
+
if (activeSocket === ws)
|
|
896
|
+
activeSocket = null;
|
|
897
|
+
const jitter = Math.floor(Math.random() * Math.min(1000, reconnectDelay / 2));
|
|
898
|
+
setTimeout(() => void connect(), reconnectDelay + jitter);
|
|
899
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30_000);
|
|
900
|
+
});
|
|
901
|
+
ws.on("error", (error) => log("error", "client.socket_error", { error }));
|
|
902
|
+
}
|
|
903
|
+
async function shutdown() {
|
|
904
|
+
await Promise.allSettled([semantic.shutdown(), mcpHub.shutdown(), watcher.close()]);
|
|
905
|
+
activeSocket?.close();
|
|
906
|
+
}
|
|
907
|
+
process.on("SIGINT", async () => { await shutdown(); process.exit(0); });
|
|
908
|
+
process.on("SIGTERM", async () => { await shutdown(); process.exit(0); });
|
|
909
|
+
log("info", "client.started", { version: "1.5.0-beta.1", protocolVersion: PROTOCOL_VERSION, deviceId: DEVICE_ID, workspaceId: WORKSPACE_ID, projectRoot: root, shell: ALLOW_SHELL, approvalMode: APPROVAL_MODE, terminalApproval: "chat-mediated", networkPolicy: NETWORK_POLICY, hostname: os.hostname() });
|
|
910
|
+
void connect();
|