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.
@@ -0,0 +1,261 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ const MAX_BUFFER_BYTES = Number(process.env.CODELOCAL_MAX_PROCESS_BUFFER_BYTES ?? 2 * 1024 * 1024);
5
+ const MAX_PROCESSES = Number(process.env.CODELOCAL_MAX_PROCESSES ?? 64);
6
+ function append(buffer, value) {
7
+ const bytes = Buffer.byteLength(value, "utf8");
8
+ buffer.totalBytes += bytes;
9
+ buffer.text += value;
10
+ const currentBytes = Buffer.byteLength(buffer.text, "utf8");
11
+ if (currentBytes > MAX_BUFFER_BYTES) {
12
+ const keep = buffer.text.slice(-MAX_BUFFER_BYTES);
13
+ const keptBytes = Buffer.byteLength(keep, "utf8");
14
+ buffer.baseOffset += currentBytes - keptBytes;
15
+ buffer.text = keep;
16
+ }
17
+ }
18
+ function readBuffer(buffer, cursor) {
19
+ const requested = Math.max(cursor ?? buffer.baseOffset, buffer.baseOffset);
20
+ const relative = Math.max(0, requested - buffer.baseOffset);
21
+ return {
22
+ text: buffer.text.slice(relative),
23
+ cursor: buffer.totalBytes,
24
+ truncatedBeforeCursor: (cursor ?? buffer.baseOffset) < buffer.baseOffset,
25
+ };
26
+ }
27
+ async function loadNodePty() {
28
+ try {
29
+ const dynamicImport = new Function("m", "return import(m)");
30
+ return await dynamicImport("node-pty");
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ function hostShell(command, cwd) {
37
+ const shell = process.env.SHELL || (process.platform === "win32" ? "cmd.exe" : "/bin/zsh");
38
+ // Use the runtime's inherited environment without starting a login shell. This preserves
39
+ // PATH/credential helpers while avoiding arbitrary profile startup hooks on every command.
40
+ const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-c", command];
41
+ return { command: shell, args, cwd };
42
+ }
43
+ export class ProcessManager {
44
+ workspaceRoot;
45
+ workspaceKey;
46
+ onOutput;
47
+ onSettled;
48
+ records = new Map();
49
+ requestToProcess = new Map();
50
+ settledNotified = new Set();
51
+ constructor(workspaceRoot, workspaceKey, onOutput, onSettled) {
52
+ this.workspaceRoot = workspaceRoot;
53
+ this.workspaceKey = workspaceKey;
54
+ this.onOutput = onOutput;
55
+ this.onSettled = onSettled;
56
+ }
57
+ prune() {
58
+ const finished = [...this.records.values()].filter((x) => x.status !== "running").sort((a, b) => a.startedAt - b.startedAt);
59
+ while (this.records.size >= MAX_PROCESSES && finished.length) {
60
+ const victim = finished.shift();
61
+ this.records.delete(victim.processId);
62
+ this.settledNotified.delete(victim.processId);
63
+ }
64
+ if (this.records.size >= MAX_PROCESSES)
65
+ throw new Error(`Too many active CodeLocal processes (${MAX_PROCESSES}).`);
66
+ }
67
+ baseRecord(command, cwd, executionMode, ownerSessionId) {
68
+ return {
69
+ processId: randomUUID(),
70
+ workspaceKey: this.workspaceKey,
71
+ ownerSessionId,
72
+ pid: null,
73
+ command,
74
+ cwd,
75
+ startedAt: Date.now(),
76
+ lastActivityAt: Date.now(),
77
+ status: "running",
78
+ exitCode: null,
79
+ signal: null,
80
+ stdout: { text: "", baseOffset: 0, totalBytes: 0 },
81
+ stderr: { text: "", baseOffset: 0, totalBytes: 0 },
82
+ timeoutAt: null,
83
+ pty: false,
84
+ executionMode,
85
+ };
86
+ }
87
+ emit(record, stream, text) {
88
+ record.lastActivityAt = Date.now();
89
+ append(stream === "stdout" ? record.stdout : record.stderr, text);
90
+ this.onOutput?.(record, stream, text);
91
+ }
92
+ notifySettled(record) {
93
+ if (this.settledNotified.has(record.processId))
94
+ return;
95
+ this.settledNotified.add(record.processId);
96
+ Promise.resolve(this.onSettled?.(record)).catch(() => undefined);
97
+ }
98
+ async start(command, options) {
99
+ this.prune();
100
+ const executionMode = "host-policy";
101
+ const record = this.baseRecord(command, options.cwd, executionMode, options.ownerSessionId);
102
+ this.records.set(record.processId, record);
103
+ if (options.requestId)
104
+ this.requestToProcess.set(options.requestId, record.processId);
105
+ const wrap = async () => hostShell(command, options.cwd);
106
+ if (options.usePty) {
107
+ const nodePty = await loadNodePty();
108
+ if (nodePty) {
109
+ const wrapped = await wrap();
110
+ const terminal = nodePty.spawn(wrapped.command, wrapped.args, {
111
+ name: process.env.TERM || "xterm-256color",
112
+ cols: options.cols ?? 120,
113
+ rows: options.rows ?? 36,
114
+ cwd: wrapped.cwd,
115
+ env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat", CODELOCAL_EXECUTION_MODE: executionMode },
116
+ });
117
+ record.pty = true;
118
+ record.terminal = terminal;
119
+ record.pid = terminal.pid;
120
+ terminal.onData((data) => this.emit(record, "stdout", data));
121
+ terminal.onExit(({ exitCode, signal }) => {
122
+ record.status = record.status === "cancelled" ? "cancelled" : "exited";
123
+ record.exitCode = exitCode;
124
+ record.signal = signal == null ? null : String(signal);
125
+ record.lastActivityAt = Date.now();
126
+ this.notifySettled(record);
127
+ });
128
+ this.scheduleTimeout(record, options.timeoutMs);
129
+ return this.snapshot(record.processId);
130
+ }
131
+ }
132
+ const wrapped = await wrap();
133
+ const child = spawn(wrapped.command, wrapped.args, {
134
+ cwd: wrapped.cwd,
135
+ env: { ...process.env, PAGER: "cat", GIT_PAGER: "cat", CI: process.env.CI ?? "1", CODELOCAL_EXECUTION_MODE: executionMode },
136
+ stdio: "pipe",
137
+ });
138
+ record.child = child;
139
+ record.pid = child.pid ?? null;
140
+ child.stdout.on("data", (d) => this.emit(record, "stdout", d.toString()));
141
+ child.stderr.on("data", (d) => this.emit(record, "stderr", d.toString()));
142
+ child.on("error", (error) => {
143
+ record.status = "failed";
144
+ record.exitCode = -1;
145
+ this.emit(record, "stderr", `\n[process error] ${error.message}\n`);
146
+ });
147
+ child.on("close", (code, signal) => {
148
+ if (record.status !== "cancelled" && record.status !== "failed")
149
+ record.status = "exited";
150
+ record.exitCode = code;
151
+ record.signal = signal ?? null;
152
+ record.lastActivityAt = Date.now();
153
+ this.notifySettled(record);
154
+ });
155
+ this.scheduleTimeout(record, options.timeoutMs);
156
+ return this.snapshot(record.processId);
157
+ }
158
+ scheduleTimeout(record, timeoutMs) {
159
+ if (!timeoutMs || timeoutMs <= 0)
160
+ return;
161
+ record.timeoutAt = Date.now() + timeoutMs;
162
+ setTimeout(() => {
163
+ const current = this.records.get(record.processId);
164
+ if (current?.status === "running")
165
+ this.cancel(record.processId, "timeout");
166
+ }, timeoutMs).unref?.();
167
+ }
168
+ snapshot(processId, cursors = {}) {
169
+ const record = this.records.get(processId);
170
+ if (!record)
171
+ throw new Error("Unknown processId.");
172
+ return {
173
+ processId: record.processId,
174
+ workspaceKey: record.workspaceKey,
175
+ ownerSessionId: record.ownerSessionId ?? null,
176
+ pid: record.pid,
177
+ command: record.command,
178
+ cwd: path.relative(this.workspaceRoot, record.cwd).split(path.sep).join("/") || ".",
179
+ startedAt: record.startedAt,
180
+ lastActivityAt: record.lastActivityAt,
181
+ status: record.status,
182
+ running: record.status === "running",
183
+ exitCode: record.exitCode,
184
+ signal: record.signal,
185
+ timeoutAt: record.timeoutAt,
186
+ pty: record.pty,
187
+ executionMode: record.executionMode,
188
+ stdout: readBuffer(record.stdout, cursors.stdout),
189
+ stderr: readBuffer(record.stderr, cursors.stderr),
190
+ };
191
+ }
192
+ list() {
193
+ return [...this.records.values()].map((record) => ({
194
+ processId: record.processId,
195
+ pid: record.pid,
196
+ command: record.command,
197
+ cwd: path.relative(this.workspaceRoot, record.cwd).split(path.sep).join("/") || ".",
198
+ status: record.status,
199
+ exitCode: record.exitCode,
200
+ signal: record.signal,
201
+ startedAt: record.startedAt,
202
+ lastActivityAt: record.lastActivityAt,
203
+ pty: record.pty,
204
+ executionMode: record.executionMode,
205
+ }));
206
+ }
207
+ write(processId, input) {
208
+ const record = this.records.get(processId);
209
+ if (!record || record.status !== "running")
210
+ throw new Error("Process not running.");
211
+ if (record.terminal)
212
+ record.terminal.write(input);
213
+ else if (record.child)
214
+ record.child.stdin.write(input);
215
+ else
216
+ throw new Error("Process stdin unavailable.");
217
+ record.lastActivityAt = Date.now();
218
+ return { written: Buffer.byteLength(input, "utf8") };
219
+ }
220
+ resize(processId, cols, rows) {
221
+ const record = this.records.get(processId);
222
+ if (!record?.terminal)
223
+ throw new Error("PTY resize is not available for this process.");
224
+ record.terminal.resize(cols, rows);
225
+ return { resized: true, cols, rows };
226
+ }
227
+ signal(processId, signal = "SIGTERM") {
228
+ const record = this.records.get(processId);
229
+ if (!record)
230
+ throw new Error("Unknown processId.");
231
+ if (record.status !== "running")
232
+ return { signalled: false, status: record.status };
233
+ if (record.terminal)
234
+ record.terminal.kill(signal);
235
+ else
236
+ record.child?.kill(signal);
237
+ record.signal = signal;
238
+ record.lastActivityAt = Date.now();
239
+ return { signalled: true, signal };
240
+ }
241
+ cancel(processId, reason = "cancelled") {
242
+ const record = this.records.get(processId);
243
+ if (!record)
244
+ throw new Error("Unknown processId.");
245
+ if (record.status === "running") {
246
+ record.status = "cancelled";
247
+ this.emit(record, "stderr", `\n[CodeLocal] ${reason}\n`);
248
+ if (record.terminal)
249
+ record.terminal.kill("SIGTERM");
250
+ else
251
+ record.child?.kill("SIGTERM");
252
+ }
253
+ return { cancelled: true, processId };
254
+ }
255
+ cancelRequest(requestId, reason = "tool request cancelled") {
256
+ const processId = this.requestToProcess.get(requestId);
257
+ if (!processId)
258
+ return { cancelled: false, reason: "no process associated with request" };
259
+ return this.cancel(processId, reason);
260
+ }
261
+ }
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export const PROTOCOL_VERSION = 2;
3
+ export const MIN_PROTOCOL_VERSION = 1;
4
+ export function requestId() {
5
+ return randomUUID();
6
+ }
7
+ export function normalizeError(error) {
8
+ const message = error instanceof Error ? error.message : String(error);
9
+ const lower = message.toLowerCase();
10
+ if (lower.includes("sensitive-path"))
11
+ return { errorCode: "SENSITIVE_PATH", errorMessage: message };
12
+ if (lower.includes("escapes project_root") || lower.includes("path escape") || lower.includes("unsafe patch path"))
13
+ return { errorCode: "PATH_ESCAPE", errorMessage: message };
14
+ if (lower.includes("changed since read") || lower.includes("hash mismatch") || lower.includes("conflict"))
15
+ return { errorCode: "CONFLICT", errorMessage: message };
16
+ if (lower.includes("approval") && (lower.includes("denied") || lower.includes("rejected")))
17
+ return { errorCode: "APPROVAL_DENIED", errorMessage: message };
18
+ if (lower.includes("blocked") || lower.includes("policy"))
19
+ return { errorCode: "POLICY_BLOCKED", errorMessage: message };
20
+ if (lower.includes("not found") || lower.includes("enoent") || lower.includes("unknown process"))
21
+ return { errorCode: "NOT_FOUND", errorMessage: message };
22
+ if (lower.includes("unsupported") || lower.includes("not available"))
23
+ return { errorCode: "UNSUPPORTED", errorMessage: message };
24
+ if (lower.includes("cancel"))
25
+ return { errorCode: "TOOL_CANCELLED", errorMessage: message };
26
+ return { errorCode: "TOOL_FAILED", errorMessage: message };
27
+ }
28
+ export function protocolCompatible(version) {
29
+ return typeof version === "number" && version >= MIN_PROTOCOL_VERSION && version <= PROTOCOL_VERSION;
30
+ }
31
+ export function isSideEffectingTool(tool) {
32
+ return new Set([
33
+ "write_file",
34
+ "edit_file",
35
+ "apply_patch",
36
+ "apply_edits",
37
+ "format_changed_files",
38
+ "run_command",
39
+ "exec_start",
40
+ "pty_start",
41
+ "process_write",
42
+ "process_kill",
43
+ "exec_cancel",
44
+ "git_stage",
45
+ "git_unstage",
46
+ "git_commit",
47
+ "git_push",
48
+ "approval_revoke",
49
+ "approval_reset",
50
+ "mcp_call",
51
+ ]).has(tool);
52
+ }
@@ -0,0 +1,162 @@
1
+ import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { terminalHeader, terminalStatus } from "./log.js";
5
+ import { WorkspaceRegistry } from "./workspace-registry.js";
6
+ function deviceHeaders(credential) {
7
+ return {
8
+ "content-type": "application/json",
9
+ "x-codelocal-credential-id": credential.credentialId,
10
+ authorization: `Device ${credential.credentialSecret}`,
11
+ };
12
+ }
13
+ function registrySignature(workspaces) {
14
+ return JSON.stringify(workspaces.map((workspace) => [workspace.workspaceId, workspace.workspaceName]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
15
+ }
16
+ export class RuntimeDaemon {
17
+ options;
18
+ registry = new WorkspaceRegistry();
19
+ children = new Map();
20
+ stopped = false;
21
+ workspaces = [];
22
+ syncedSignature = "";
23
+ constructor(options) {
24
+ this.options = options;
25
+ }
26
+ stopUnauthorizedChildren(workspaces) {
27
+ const allowed = new Set(workspaces.map((workspace) => workspace.workspaceId));
28
+ for (const [workspaceId, child] of this.children) {
29
+ if (allowed.has(workspaceId))
30
+ continue;
31
+ if (child.exitCode == null && !child.killed)
32
+ child.kill("SIGTERM");
33
+ this.children.delete(workspaceId);
34
+ terminalStatus("warn", "Workspace", `${workspaceId} deactivated · authorization removed`);
35
+ }
36
+ }
37
+ async syncRegistry(force = false) {
38
+ const workspaces = await this.registry.list();
39
+ this.stopUnauthorizedChildren(workspaces);
40
+ const signature = registrySignature(workspaces);
41
+ this.workspaces = workspaces;
42
+ if (!force && signature === this.syncedSignature)
43
+ return workspaces;
44
+ const response = await fetch(`${this.options.baseUrl}/api/client/workspaces/sync`, {
45
+ method: "POST",
46
+ headers: deviceHeaders(this.options.credential),
47
+ body: JSON.stringify({ workspaces: workspaces.map(({ workspaceId, workspaceName, grantedAt, lastActivatedAt }) => ({ workspaceId, workspaceName, grantedAt, lastActivatedAt })) }),
48
+ signal: AbortSignal.timeout(10_000),
49
+ });
50
+ if (response.status === 401 || response.status === 403)
51
+ throw new Error("Stored CodeLocal device credential is no longer valid.");
52
+ if (!response.ok)
53
+ throw new Error(`Unable to sync authorized workspaces (${response.status}).`);
54
+ this.syncedSignature = signature;
55
+ return workspaces;
56
+ }
57
+ async childFor(workspace) {
58
+ const existing = this.children.get(workspace.workspaceId);
59
+ if (existing && existing.exitCode == null && !existing.killed)
60
+ return existing;
61
+ const entry = fileURLToPath(new URL("./client-entry-v2.js", import.meta.url));
62
+ const child = spawn(process.execPath, [entry], {
63
+ env: {
64
+ ...process.env,
65
+ PROJECT_ROOT: workspace.localPath,
66
+ SERVER_URL: this.options.serverUrl,
67
+ CODELOCAL_WORKSPACE_ID: workspace.workspaceId,
68
+ CODELOCAL_WORKSPACE_NAME: workspace.workspaceName,
69
+ CODELOCAL_ALLOW_SHELL: process.env.CODELOCAL_ALLOW_SHELL ?? "1",
70
+ CODELOCAL_APPROVAL_MODE: process.env.CODELOCAL_APPROVAL_MODE ?? "prompt",
71
+ CODELOCAL_LOG_FORMAT: process.env.CODELOCAL_LOG_FORMAT ?? "pretty",
72
+ CODELOCAL_DAEMON_CHILD: "1",
73
+ },
74
+ stdio: "inherit",
75
+ shell: false,
76
+ });
77
+ this.children.set(workspace.workspaceId, child);
78
+ child.once("exit", () => {
79
+ if (this.children.get(workspace.workspaceId) === child)
80
+ this.children.delete(workspace.workspaceId);
81
+ });
82
+ await this.registry.markActivated(workspace.workspaceId);
83
+ return child;
84
+ }
85
+ async activate(workspaceId) {
86
+ const workspace = await this.registry.get(workspaceId);
87
+ if (!workspace)
88
+ throw new Error(`Workspace is not authorized on this machine: ${workspaceId}`);
89
+ await this.childFor(workspace);
90
+ return workspace;
91
+ }
92
+ async revokeWorkspace(workspaceId) {
93
+ const workspace = await this.registry.get(workspaceId);
94
+ const removed = await this.registry.revoke(workspaceId);
95
+ if (!removed)
96
+ return false;
97
+ const child = this.children.get(workspaceId);
98
+ if (child && child.exitCode == null && !child.killed)
99
+ child.kill("SIGTERM");
100
+ this.children.delete(workspaceId);
101
+ await this.syncRegistry(true);
102
+ terminalStatus("warn", "Workspace", `${workspace?.workspaceName ?? workspaceId} removed`);
103
+ return true;
104
+ }
105
+ async pollOnce() {
106
+ const response = await fetch(`${this.options.baseUrl}/api/client/runtime/poll`, {
107
+ method: "POST",
108
+ headers: deviceHeaders(this.options.credential),
109
+ body: JSON.stringify({ workspaceIds: this.workspaces.map((workspace) => workspace.workspaceId) }),
110
+ signal: AbortSignal.timeout(10_000),
111
+ });
112
+ if (response.status === 401 || response.status === 403)
113
+ throw new Error("CodeLocal runtime device authorization was revoked.");
114
+ if (!response.ok)
115
+ throw new Error(`Runtime poll failed (${response.status}).`);
116
+ return await response.json();
117
+ }
118
+ async run() {
119
+ const workspaces = await this.syncRegistry(true);
120
+ terminalHeader();
121
+ terminalStatus("success", "Runtime", "Online");
122
+ terminalStatus("success", "Cloud", "Connected");
123
+ terminalStatus("info", "Workspaces", `${workspaces.length} authorized`);
124
+ if (!workspaces.length)
125
+ terminalStatus("warn", "Workspace", "None granted · run codelocal grant /path/to/project");
126
+ terminalStatus("muted", "Status", "Waiting for ChatGPT…");
127
+ if (this.options.initialWorkspaceId) {
128
+ const activated = await this.activate(this.options.initialWorkspaceId);
129
+ terminalStatus("accent", "Workspace", `${activated.workspaceName} active`);
130
+ }
131
+ while (!this.stopped) {
132
+ try {
133
+ await this.syncRegistry();
134
+ const message = await this.pollOnce();
135
+ if (message.revocation?.workspaceId) {
136
+ await this.revokeWorkspace(message.revocation.workspaceId);
137
+ continue;
138
+ }
139
+ if (message.activation?.workspaceId) {
140
+ const workspace = await this.activate(message.activation.workspaceId);
141
+ terminalStatus("accent", "ChatGPT", `Activated ${workspace.workspaceName}`);
142
+ }
143
+ }
144
+ catch (error) {
145
+ if (this.stopped)
146
+ break;
147
+ terminalStatus("error", "Runtime", error instanceof Error ? error.message : String(error));
148
+ await sleep(Math.max(1500, this.options.pollMs ?? 2500));
149
+ }
150
+ if (!this.stopped)
151
+ await sleep(this.options.pollMs ?? 2500);
152
+ }
153
+ }
154
+ async stop() {
155
+ this.stopped = true;
156
+ for (const child of this.children.values()) {
157
+ if (child.exitCode == null && !child.killed)
158
+ child.kill("SIGTERM");
159
+ }
160
+ this.children.clear();
161
+ }
162
+ }