local-commander-agent 0.3.48 → 0.3.49
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/CHANGELOG.md +218 -212
- package/README.md +216 -216
- package/bin/command-path.mjs +223 -223
- package/bin/localcommander.mjs +872 -872
- package/bin/monitor-agents.mjs +162 -162
- package/bin/monitor-context.d.mts +7 -7
- package/bin/monitor-context.mjs +65 -65
- package/bin/monitor-mcp.mjs +136 -136
- package/bin/monitor-savings.d.mts +20 -20
- package/bin/monitor-savings.mjs +71 -71
- package/bin/monitor.mjs +530 -530
- package/bin/network-package.mjs +38 -38
- package/bin/posix.mjs +84 -84
- package/package.json +57 -57
- package/runtime/JobHost.exe +0 -0
- package/runtime/LocalCommanderAgent.exe +0 -0
- package/runtime/OwnerBridge.exe +0 -0
- package/runtime/agent.mjs +29 -5
package/bin/localcommander.mjs
CHANGED
|
@@ -1,872 +1,872 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { constants as fsConstants } from "node:fs";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { copyNetworkBundle, implicitSetupAllowed, inspectNetworkBundle } from "./network-package.mjs";
|
|
9
|
-
import { runMonitor } from "./monitor.mjs";
|
|
10
|
-
import { installCommandFiles, installStartMenuShortcut, removeCommandFile, removeStartMenuShortcut, updateUserPath, installBrowserShortcut, removeBrowserShortcut } from "./command-path.mjs";
|
|
11
|
-
|
|
12
|
-
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
-
const PACKAGE_META = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
14
|
-
const VERSION = String(PACKAGE_META.version || "");
|
|
15
|
-
const DEFAULT_ORIGIN = String(PACKAGE_META.localCommander?.controlPlaneOrigin || "");
|
|
16
|
-
const RUNTIME_FILES = ["LocalCommanderAgent.exe", "OwnerBridge.exe", "JobHost.exe", "agent.mjs", "node.exe", "NODE-LICENSE.txt"];
|
|
17
|
-
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
18
|
-
const RUN_VALUE = "LocalCommanderAgent";
|
|
19
|
-
const MAX_STDIO = 1024 * 1024;
|
|
20
|
-
// Owner decision #204: this package ships without the AI connection (#112). The code stays; set this to true
|
|
21
|
-
// (and build with LC_AI_CONNECTIVITY_BUNDLE) to bring it back. Turning it off and uninstalling still clean up.
|
|
22
|
-
const AI_CONNECTION_SHIPPED = false;
|
|
23
|
-
|
|
24
|
-
function fail(code) {
|
|
25
|
-
const error = new Error(code);
|
|
26
|
-
error.code = code;
|
|
27
|
-
throw error;
|
|
28
|
-
}
|
|
29
|
-
function requireWindows() {
|
|
30
|
-
if (process.platform !== "win32" || process.arch !== "x64") fail("WINDOWS_X64_REQUIRED");
|
|
31
|
-
}
|
|
32
|
-
function appDataRoot() {
|
|
33
|
-
const value = process.env.LOCALAPPDATA;
|
|
34
|
-
if (!value || !path.isAbsolute(value) || /[\u0000-\u001f]/u.test(value)) fail("APP_DATA_MISSING");
|
|
35
|
-
return path.join(value, "LocalCommander");
|
|
36
|
-
}
|
|
37
|
-
function stateDirectory() {
|
|
38
|
-
const override = process.env.LC_AGENT_STATE_DIR;
|
|
39
|
-
if (override) {
|
|
40
|
-
if (!path.isAbsolute(override) || /[\u0000-\u001f]/u.test(override)) fail("STATE_DIR_INVALID");
|
|
41
|
-
return path.resolve(override);
|
|
42
|
-
}
|
|
43
|
-
return path.join(appDataRoot(), "Agent");
|
|
44
|
-
}
|
|
45
|
-
function versionsDirectory() {
|
|
46
|
-
return path.join(appDataRoot(), "Versions");
|
|
47
|
-
}
|
|
48
|
-
function currentPath() {
|
|
49
|
-
return path.join(appDataRoot(), "current.json");
|
|
50
|
-
}
|
|
51
|
-
function runtimeSource() {
|
|
52
|
-
if (PACKAGE_ROOT.toLowerCase() === path.join(versionsDirectory(), VERSION).toLowerCase()) return PACKAGE_ROOT;
|
|
53
|
-
return path.join(PACKAGE_ROOT, "runtime");
|
|
54
|
-
}
|
|
55
|
-
function processFile() {
|
|
56
|
-
return path.join(stateDirectory(), "agent-process.json");
|
|
57
|
-
}
|
|
58
|
-
function under(parent, child) {
|
|
59
|
-
const rel = path.relative(parent, child);
|
|
60
|
-
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
61
|
-
}
|
|
62
|
-
function boundedText(value) {
|
|
63
|
-
const text = String(value ?? "");
|
|
64
|
-
return text.length > MAX_STDIO ? text.slice(0, MAX_STDIO) : text;
|
|
65
|
-
}
|
|
66
|
-
async function regular(file) {
|
|
67
|
-
const stat = await fs.lstat(file);
|
|
68
|
-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1) fail("PACKAGE_RUNTIME_INVALID");
|
|
69
|
-
return stat;
|
|
70
|
-
}
|
|
71
|
-
async function ensurePlainDirectory(dir) {
|
|
72
|
-
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
73
|
-
const stat = await fs.lstat(dir);
|
|
74
|
-
if (!stat.isDirectory() || stat.isSymbolicLink()) fail("INSTALL_DIRECTORY_INVALID");
|
|
75
|
-
return dir;
|
|
76
|
-
}
|
|
77
|
-
async function ensureInstallDirectories() {
|
|
78
|
-
const root = await ensurePlainDirectory(appDataRoot());
|
|
79
|
-
const versions = await ensurePlainDirectory(versionsDirectory());
|
|
80
|
-
if (!under(root, versions)) fail("INSTALL_DIRECTORY_INVALID");
|
|
81
|
-
return { root, versions };
|
|
82
|
-
}
|
|
83
|
-
async function runtimeValid(dir) {
|
|
84
|
-
try {
|
|
85
|
-
for (const name of RUNTIME_FILES) await regular(path.join(dir, name));
|
|
86
|
-
return true;
|
|
87
|
-
} catch {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
async function writeJsonAtomic(file, value) {
|
|
92
|
-
await ensurePlainDirectory(path.dirname(file));
|
|
93
|
-
const existing = await fs.lstat(file).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
94
|
-
if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail("INSTALL_STATE_INVALID");
|
|
95
|
-
const temp = file + "." + process.pid + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
96
|
-
await fs.writeFile(temp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
97
|
-
try {
|
|
98
|
-
await fs.rename(temp, file);
|
|
99
|
-
} catch (error) {
|
|
100
|
-
await fs.rm(temp, { force: true });
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
async function readCurrent() {
|
|
105
|
-
try {
|
|
106
|
-
const current = currentPath();
|
|
107
|
-
const stat = await fs.lstat(current);
|
|
108
|
-
if (!stat.isFile() || stat.isSymbolicLink()) fail("INSTALL_STATE_INVALID");
|
|
109
|
-
const raw = JSON.parse(await fs.readFile(current, "utf8"));
|
|
110
|
-
if (!raw || typeof raw !== "object" || typeof raw.version !== "string" || typeof raw.runtimeDir !== "string") fail("INSTALL_STATE_INVALID");
|
|
111
|
-
const dir = path.resolve(raw.runtimeDir);
|
|
112
|
-
if (!under(versionsDirectory(), dir) || !(await runtimeValid(dir))) fail("INSTALL_STATE_INVALID");
|
|
113
|
-
return { version: raw.version, runtimeDir: dir };
|
|
114
|
-
} catch (error) {
|
|
115
|
-
if (error && error.code === "ENOENT") return null;
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
async function copyRuntime() {
|
|
120
|
-
const source = runtimeSource();
|
|
121
|
-
if (!(await runtimeValid(source))) fail("PACKAGE_RUNTIME_MISSING");
|
|
122
|
-
const { versions } = await ensureInstallDirectories();
|
|
123
|
-
const target = path.join(versions, VERSION);
|
|
124
|
-
if (await runtimeValid(target)) {
|
|
125
|
-
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
126
|
-
return target;
|
|
127
|
-
}
|
|
128
|
-
const targetStat = await fs.lstat(target).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
129
|
-
if (targetStat) {
|
|
130
|
-
if (!targetStat.isDirectory() || targetStat.isSymbolicLink() || !under(versions, target)) fail("INSTALL_TARGET_INVALID");
|
|
131
|
-
await fs.rm(target, { recursive: true, force: true });
|
|
132
|
-
}
|
|
133
|
-
const temp = path.join(versions, "." + VERSION + "." + process.pid + "." + randomBytes(6).toString("hex"));
|
|
134
|
-
await fs.mkdir(temp, { recursive: false, mode: 0o700 });
|
|
135
|
-
try {
|
|
136
|
-
for (const name of RUNTIME_FILES) {
|
|
137
|
-
const sourceFile = path.join(source, name);
|
|
138
|
-
const sourceStat = await regular(sourceFile);
|
|
139
|
-
const destination = path.join(temp, name);
|
|
140
|
-
await fs.copyFile(sourceFile, destination, fsConstants.COPYFILE_EXCL);
|
|
141
|
-
const copied = await regular(destination);
|
|
142
|
-
if (copied.size !== sourceStat.size) fail("PACKAGE_COPY_INVALID");
|
|
143
|
-
}
|
|
144
|
-
try {
|
|
145
|
-
await fs.rename(temp, target);
|
|
146
|
-
} catch (error) {
|
|
147
|
-
if (!(await runtimeValid(target))) throw error;
|
|
148
|
-
await fs.rm(temp, { recursive: true, force: true });
|
|
149
|
-
}
|
|
150
|
-
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
151
|
-
return target;
|
|
152
|
-
} catch (error) {
|
|
153
|
-
await fs.rm(temp, { recursive: true, force: true });
|
|
154
|
-
throw error;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
function runReg(args, allowMissing = false) {
|
|
158
|
-
const result = spawnSync("reg.exe", args, { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
159
|
-
if (result.error) fail("AUTOSTART_COMMAND_FAILED");
|
|
160
|
-
if (result.status !== 0 && !(allowMissing && result.status === 1)) fail("AUTOSTART_COMMAND_FAILED");
|
|
161
|
-
}
|
|
162
|
-
function registerAutostart(runtimeDir) {
|
|
163
|
-
const exe = path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
164
|
-
if (exe.includes('"')) fail("INSTALL_PATH_INVALID");
|
|
165
|
-
runReg(["ADD", RUN_KEY, "/v", RUN_VALUE, "/t", "REG_SZ", "/d", '"' + exe + '" run', "/f"]);
|
|
166
|
-
}
|
|
167
|
-
function removeAutostart() {
|
|
168
|
-
runReg(["DELETE", RUN_KEY, "/v", RUN_VALUE, "/f"], true);
|
|
169
|
-
}
|
|
170
|
-
function agentEnv() {
|
|
171
|
-
const env = { ...process.env, LC_AGENT_STATE_DIR: stateDirectory() };
|
|
172
|
-
delete env.NODE_OPTIONS;
|
|
173
|
-
delete env.NODE_PATH;
|
|
174
|
-
return env;
|
|
175
|
-
}
|
|
176
|
-
function agentExe(runtimeDir) {
|
|
177
|
-
return path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
178
|
-
}
|
|
179
|
-
function execAgent(runtimeDir, args, timeout = 30_000) {
|
|
180
|
-
const result = spawnSync(agentExe(runtimeDir), args, {
|
|
181
|
-
windowsHide: true,
|
|
182
|
-
encoding: "utf8",
|
|
183
|
-
timeout,
|
|
184
|
-
env: agentEnv()
|
|
185
|
-
});
|
|
186
|
-
if (result.error) fail("AGENT_COMMAND_FAILED");
|
|
187
|
-
return { status: result.status ?? -1, stdout: boundedText(result.stdout), stderr: boundedText(result.stderr) };
|
|
188
|
-
}
|
|
189
|
-
async function fabricCommand(runtimeDir, command) {
|
|
190
|
-
const chunks = []; let bytes = 0;
|
|
191
|
-
for await (const chunk of process.stdin) {
|
|
192
|
-
bytes += chunk.length;
|
|
193
|
-
if (bytes > 16_384) fail("FABRIC_REQUEST_LIMIT");
|
|
194
|
-
chunks.push(chunk);
|
|
195
|
-
}
|
|
196
|
-
// Private stdin is explicit; no credential arguments, shell, listener or foreground window.
|
|
197
|
-
const result = spawnSync(path.join(runtimeDir, "node.exe"), [path.join(runtimeDir, "agent.mjs"), command], {
|
|
198
|
-
input: Buffer.concat(chunks), windowsHide: true, encoding: "utf8", timeout: 30_000,
|
|
199
|
-
maxBuffer: 2 * 1024 * 1024, env: agentEnv()
|
|
200
|
-
});
|
|
201
|
-
if (result.error || result.status !== 0) fail(/^[A-Z0-9_]{1,80}$/u.test(result.stderr?.trim()) ? result.stderr.trim() : "FABRIC_REQUEST_FAILED");
|
|
202
|
-
process.stdout.write(result.stdout);
|
|
203
|
-
}
|
|
204
|
-
function openBrowser(url) {
|
|
205
|
-
let parsed;
|
|
206
|
-
try { parsed = new URL(url); } catch { fail("PAIRING_URL_INVALID"); }
|
|
207
|
-
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))) fail("PAIRING_URL_INVALID");
|
|
208
|
-
const child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", parsed.toString()], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
209
|
-
child.unref();
|
|
210
|
-
}
|
|
211
|
-
async function identityStatus(runtimeDir) {
|
|
212
|
-
const result = execAgent(runtimeDir, ["identity-status"], 15_000);
|
|
213
|
-
if (result.status !== 0) fail(result.stderr.trim() || "IDENTITY_STATUS_FAILED");
|
|
214
|
-
try {
|
|
215
|
-
const parsed = JSON.parse(result.stdout.trim());
|
|
216
|
-
return { paired: parsed.paired === true, accountEmail: typeof parsed.accountEmail === "string" ? parsed.accountEmail : null, deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : null };
|
|
217
|
-
} catch { fail("IDENTITY_STATUS_INVALID"); }
|
|
218
|
-
}
|
|
219
|
-
async function pair(runtimeDir) {
|
|
220
|
-
const origin = process.env.LC_CONTROL_PLANE_ORIGIN || DEFAULT_ORIGIN;
|
|
221
|
-
if (!origin) fail("CONTROL_PLANE_ORIGIN_MISSING");
|
|
222
|
-
const child = spawn(agentExe(runtimeDir), ["pair"], {
|
|
223
|
-
windowsHide: true,
|
|
224
|
-
env: { ...agentEnv(), LC_CONTROL_PLANE_ORIGIN: origin },
|
|
225
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
226
|
-
});
|
|
227
|
-
let stdout = "", stderr = "", opened = false;
|
|
228
|
-
child.stdout.setEncoding("utf8");
|
|
229
|
-
child.stderr.setEncoding("utf8");
|
|
230
|
-
child.stdout.on("data", chunk => {
|
|
231
|
-
stdout = boundedText(stdout + chunk);
|
|
232
|
-
if (opened) return;
|
|
233
|
-
const lineEnd = stdout.indexOf("\n");
|
|
234
|
-
if (lineEnd < 0) return;
|
|
235
|
-
const line = stdout.slice(0, lineEnd).trim();
|
|
236
|
-
try {
|
|
237
|
-
const begin = JSON.parse(line);
|
|
238
|
-
if (typeof begin.approvalUrl !== "string" || typeof begin.confirmationCode !== "string") fail("PAIRING_RESPONSE_INVALID");
|
|
239
|
-
opened = true;
|
|
240
|
-
// #218: the link carries the code, so the website opens the dialog with it filled in.
|
|
241
|
-
const link = new URL(begin.approvalUrl);
|
|
242
|
-
if (/^[A-Za-z2-9]{8}$/.test(begin.confirmationCode)) link.searchParams.set("code", begin.confirmationCode);
|
|
243
|
-
process.stdout.write("Pairing code: " + begin.confirmationCode + "\n");
|
|
244
|
-
process.stdout.write("Opening: " + link + "\n");
|
|
245
|
-
openBrowser(link.toString());
|
|
246
|
-
} catch {
|
|
247
|
-
child.kill();
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
child.stderr.on("data", chunk => { stderr = boundedText(stderr + chunk); });
|
|
251
|
-
const exitCode = await new Promise((resolve, reject) => {
|
|
252
|
-
child.once("error", reject);
|
|
253
|
-
child.once("exit", code => resolve(code ?? -1));
|
|
254
|
-
}).catch(() => -1);
|
|
255
|
-
if (exitCode !== 0) fail(stderr.trim() || "PAIRING_FAILED");
|
|
256
|
-
if (!opened) fail("PAIRING_RESPONSE_INVALID");
|
|
257
|
-
process.stdout.write("Device paired.\n");
|
|
258
|
-
}
|
|
259
|
-
async function configureAccess(runtimeDir) {
|
|
260
|
-
const result = execAgent(runtimeDir, ["configure-access"], 300_000);
|
|
261
|
-
if (result.status !== 0) fail(result.stderr.trim() || "ACCESS_CONFIGURATION_FAILED");
|
|
262
|
-
const answer = result.stdout.trim();
|
|
263
|
-
if (answer === "ACCESS_CANCELLED") return false;
|
|
264
|
-
if (answer !== "ACCESS_CONFIGURED") fail("ACCESS_CONFIGURATION_INVALID");
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
async function restartIfRunning() {
|
|
268
|
-
const record = await readProcessRecord();
|
|
269
|
-
if (record && pidAlive(record.pid)) { await stopAgent(); await startAgent(); }
|
|
270
|
-
}
|
|
271
|
-
// Owner opt-in encrypted content audit (#131 pilot). The recovery key goes from the agent straight
|
|
272
|
-
// into the clipboard; it is never printed.
|
|
273
|
-
function copyAuditKey(runtimeDir) {
|
|
274
|
-
const key = execAgent(runtimeDir, ["audit-key"], 15_000);
|
|
275
|
-
if (key.status !== 0) fail(key.stderr.trim() || "AUDIT_KEY_MISSING");
|
|
276
|
-
const clip = spawnSync(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "clip.exe"), [], { input: key.stdout.trim(), windowsHide: true, timeout: 10_000 });
|
|
277
|
-
if (clip.error || clip.status !== 0) fail("CLIPBOARD_FAILED");
|
|
278
|
-
}
|
|
279
|
-
async function auditCommand(runtimeDir, sub) {
|
|
280
|
-
const action = sub[0] || "status";
|
|
281
|
-
const agent = (args, timeout = 30_000) => {
|
|
282
|
-
const result = execAgent(runtimeDir, args, timeout);
|
|
283
|
-
if (result.status !== 0) fail(result.stderr.trim() || "AUDIT_ACTION_FAILED");
|
|
284
|
-
return result.stdout;
|
|
285
|
-
};
|
|
286
|
-
if (action === "status" && sub.length <= 1) { process.stdout.write(agent(["audit-status"], 120_000)); return; }
|
|
287
|
-
if (action === "on" && sub.length === 1) {
|
|
288
|
-
const info = JSON.parse(agent(["audit-on"]).trim());
|
|
289
|
-
if (info.created) copyAuditKey(runtimeDir);
|
|
290
|
-
await restartIfRunning();
|
|
291
|
-
process.stdout.write([
|
|
292
|
-
"Encrypted content audit is ON for this computer (pilot).",
|
|
293
|
-
"Files: " + info.directory + " (kept " + info.retentionDays + " days, capture pauses at " + Math.round(info.maxBytes / 1048576) + " MB)",
|
|
294
|
-
info.created
|
|
295
|
-
? "A new recovery key (" + info.keyId + ") was copied to the clipboard. Paste it into your password manager now, then clear the clipboard.\nWithout it nobody, including Local Commander, can read the audit files on another computer."
|
|
296
|
-
: "Using your existing recovery key (" + info.keyId + "). Run 'localcommander audit copy-key' to copy it again.",
|
|
297
|
-
""
|
|
298
|
-
].join("\n"));
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
if (action === "off" && sub.length === 1) {
|
|
302
|
-
agent(["audit-off"]);
|
|
303
|
-
await restartIfRunning();
|
|
304
|
-
process.stdout.write("Encrypted content audit is OFF. Existing files stay until they expire.\n");
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (action === "copy-key" && sub.length === 1) {
|
|
308
|
-
copyAuditKey(runtimeDir);
|
|
309
|
-
process.stdout.write("Recovery key copied to the clipboard.\n");
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (action === "restore" && sub.length === 3) {
|
|
313
|
-
// Uses the owner's own key copy from LCMCP_AUDIT_RECOVERY_KEY; writes a new plaintext file.
|
|
314
|
-
process.stdout.write(agent(["audit-restore", path.resolve(sub[1]), path.resolve(sub[2])], 600_000));
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
fail("ARGUMENT_INVALID");
|
|
318
|
-
}
|
|
319
|
-
// Owner opt-out for take_screenshot under Full Access (#486), on by default. Only this owner-run command changes it (the agent
|
|
320
|
-
// state folder is protected from every file tool); the agent checks it on each capture, so no restart is needed.
|
|
321
|
-
const SCREENSHOT_OFF_FILE = "screenshot-disabled-by-owner";
|
|
322
|
-
async function screenshotCommand(args) {
|
|
323
|
-
const action = args[0] || "status";
|
|
324
|
-
if (args.length > 1 || !["status", "on", "off"].includes(action)) fail("ARGUMENT_INVALID");
|
|
325
|
-
if (!(await readCurrent())) fail("AGENT_NOT_INSTALLED");
|
|
326
|
-
const file = path.join(stateDirectory(), SCREENSHOT_OFF_FILE);
|
|
327
|
-
if (action === "off") await fs.writeFile(file, "", { flag: "wx" }).catch(error => { if (error?.code !== "EEXIST") throw error; });
|
|
328
|
-
if (action === "on") await fs.rm(file, { force: true });
|
|
329
|
-
const off = await fs.lstat(file).then(() => true, error => error?.code !== "ENOENT");
|
|
330
|
-
process.stdout.write(JSON.stringify({ screenshot: off ? "off" : "on" }) + "\n");
|
|
331
|
-
}
|
|
332
|
-
// Public AI-provider domains routed through the tunnel; everything else stays direct. Mirror of
|
|
333
|
-
// packages/connectivity/src/profiles.ts (stable public suffixes).
|
|
334
|
-
const PROXY_PROVIDERS = {
|
|
335
|
-
openai: ["chatgpt.com", "openai.com", "oaistatic.com", "oaiusercontent.com", "oaistatsig.com", "cdn.openaimerge.com"],
|
|
336
|
-
anthropic: ["claude.ai", "claude.com", "anthropic.com"],
|
|
337
|
-
cursor: ["cursor.sh", "cursor.com", "cursor-cdn.com", "marketplace.cursorapi.com"]
|
|
338
|
-
};
|
|
339
|
-
// Proxy-mode files live in their own folder, never in the private store dir (which rejects unknown files).
|
|
340
|
-
function proxyDir() { return path.join(path.dirname(stateDirectory()), "Proxy"); }
|
|
341
|
-
function proxyModeFile() { return path.join(proxyDir(), "proxy-mode.json"); }
|
|
342
|
-
function proxyStateFile() { return path.join(proxyDir(), "proxy.json"); }
|
|
343
|
-
function proxyPacFile() { return path.join(appDataRoot(), "ai-proxy.pac"); }
|
|
344
|
-
async function readProxyState() {
|
|
345
|
-
try {
|
|
346
|
-
const raw = JSON.parse(await fs.readFile(proxyStateFile(), "utf8"));
|
|
347
|
-
if (!raw || typeof raw.port !== "number" || typeof raw.password !== "string" || typeof raw.username !== "string" || !Array.isArray(raw.providers)) return null;
|
|
348
|
-
if (typeof raw.expiresAt === "number" && raw.expiresAt < Date.now()) return { ...raw, stale: true };
|
|
349
|
-
return raw;
|
|
350
|
-
} catch { return null; }
|
|
351
|
-
}
|
|
352
|
-
function buildPac(providers, port) {
|
|
353
|
-
const hosts = [...new Set(providers.flatMap(p => PROXY_PROVIDERS[p] ?? []))];
|
|
354
|
-
const tests = hosts.map(h => `shExpMatch(host, "${h}") || shExpMatch(host, "*.${h}")`).join(" ||\n ");
|
|
355
|
-
return `function FindProxyForURL(url, host) {\n host = host.toLowerCase();\n if (${tests}) {\n return "PROXY 127.0.0.1:${port}";\n }\n return "DIRECT";\n}\n`;
|
|
356
|
-
}
|
|
357
|
-
function proxyUrl(state) { return state.password ? `http://${state.username}:${state.password}@127.0.0.1:${state.port}` : `http://127.0.0.1:${state.port}`; }
|
|
358
|
-
function browserLauncherFile() { return path.join(appDataRoot(), "open-ai-browser.cmd"); }
|
|
359
|
-
function browserProfileDir() { return path.join(appDataRoot(), "ai-browser"); }
|
|
360
|
-
const BROWSER_SHORTCUT = "AI Browser (Local Commander)";
|
|
361
|
-
async function findBrowser() {
|
|
362
|
-
const candidates = [
|
|
363
|
-
[process.env.ProgramFiles, "Google\\Chrome\\Application\\chrome.exe"],
|
|
364
|
-
[process.env["ProgramFiles(x86)"], "Google\\Chrome\\Application\\chrome.exe"],
|
|
365
|
-
[process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe"],
|
|
366
|
-
[process.env["ProgramFiles(x86)"], "Microsoft\\Edge\\Application\\msedge.exe"],
|
|
367
|
-
[process.env.ProgramFiles, "Microsoft\\Edge\\Application\\msedge.exe"]
|
|
368
|
-
];
|
|
369
|
-
for (const [base, rel] of candidates) {
|
|
370
|
-
if (!base) continue;
|
|
371
|
-
const exe = path.join(base, rel);
|
|
372
|
-
try { if ((await fs.lstat(exe)).isFile()) return exe; } catch { /* try next */ }
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
375
|
-
}
|
|
376
|
-
// A double-click launcher: opens Chrome/Edge through the proxy in a dedicated profile that remembers
|
|
377
|
-
// the proxy sign-in, so after the first time it is seamless. The port is stable across restarts.
|
|
378
|
-
function buildBrowserLauncher(port) {
|
|
379
|
-
const prof = browserProfileDir();
|
|
380
|
-
const flags = `--proxy-server=http://127.0.0.1:${port} --user-data-dir="${prof}"`;
|
|
381
|
-
const chrome = ["%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe", "%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe", "%LocalAppData%\\Google\\Chrome\\Application\\chrome.exe"];
|
|
382
|
-
const edge = ["%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe", "%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe"];
|
|
383
|
-
const lines = ["@echo off", "rem Local Commander - open AI sites through the proxy."];
|
|
384
|
-
for (const exe of [...chrome, ...edge]) lines.push(`if exist "${exe}" start "" "${exe}" ${flags} & goto :eof`);
|
|
385
|
-
lines.push('echo Could not find Chrome or Edge. Set your browser proxy to 127.0.0.1:' + port + ' (user lc-core).', "pause");
|
|
386
|
-
return lines.join("\r\n") + "\r\n";
|
|
387
|
-
}
|
|
388
|
-
function printProxyGuidance(state, shortcut) {
|
|
389
|
-
const url = proxyUrl(state), pac = proxyPacFile(), launcher = browserLauncherFile();
|
|
390
|
-
process.stdout.write("AI connection proxy is active (providers: " + state.providers.join(", ") + ").\n");
|
|
391
|
-
process.stdout.write("Loopback HTTP proxy: 127.0.0.1:" + state.port + " (no sign-in). Stable across restarts - set up once.\n\n");
|
|
392
|
-
if (shortcut) process.stdout.write("Browser: open \"" + BROWSER_SHORTCUT + "\" from the Start menu or your Desktop. It opens ChatGPT/Claude already routed - no password.\n\n");
|
|
393
|
-
else {
|
|
394
|
-
process.stdout.write("Browser: double-click this launcher (opens Chrome/Edge already routed - no password):\n");
|
|
395
|
-
process.stdout.write(" " + launcher + "\n\n");
|
|
396
|
-
}
|
|
397
|
-
process.stdout.write("CLIs (Claude Code, Codex, Antigravity, any tool that reads a proxy env var) - in the shell that runs the tool:\n");
|
|
398
|
-
process.stdout.write(" CMD: set HTTPS_PROXY=" + url + "\n");
|
|
399
|
-
process.stdout.write(" PowerShell: $env:HTTPS_PROXY=\"" + url + "\"\n");
|
|
400
|
-
process.stdout.write(" (also set HTTP_PROXY and ALL_PROXY to the same value for tools that read those)\n");
|
|
401
|
-
process.stdout.write(" Non-AI hosts are reached directly, so the one variable is safe for the whole session.\n\n");
|
|
402
|
-
process.stdout.write("Alternative browser setup (PAC): Settings > Network & Internet > Proxy > Use setup script =\n");
|
|
403
|
-
process.stdout.write(" file:///" + pac.replace(/\\/g, "/") + "\n");
|
|
404
|
-
}
|
|
405
|
-
async function proxyModeEnabled() {
|
|
406
|
-
try { return JSON.parse(await fs.readFile(proxyModeFile(), "utf8"))?.enabled === true; } catch { return false; }
|
|
407
|
-
}
|
|
408
|
-
// The running agent rewrites proxy.json every 15 s while it holds a lease and removes it on any
|
|
409
|
-
// failure, so a missing or expired file never means "refreshing": say which condition it is.
|
|
410
|
-
async function explainNoProxyLease(state) {
|
|
411
|
-
const current = await readCurrent().catch(() => null);
|
|
412
|
-
const versions = current && current.version !== VERSION
|
|
413
|
-
? "Installed agent: " + current.version + "; this command: " + VERSION + ". If the installed agent predates proxy mode, install this version: npx local-commander-agent@" + VERSION + " update\n" : "";
|
|
414
|
-
const record = await readProcessRecord();
|
|
415
|
-
if (!record || !pidAlive(record.pid)) return "The Local Commander agent is not running, so no AI connection lease can be obtained. Start it with: localcommander start\nIf it stops again, see why with: localcommander logs\n" + versions;
|
|
416
|
-
if (state?.stale) {
|
|
417
|
-
const age = typeof state.updatedAt === "number" ? " (last refreshed " + Math.max(0, Math.round((Date.now() - state.updatedAt) / 1000)) + " s ago)" : "";
|
|
418
|
-
return "The agent is running but has stopped refreshing the AI connection lease" + age + ". Check: localcommander logs\n" + versions;
|
|
419
|
-
}
|
|
420
|
-
return "The agent is running but has no AI connection lease. The server has not granted one to this computer yet. If this lasts more than a minute, check: localcommander logs\n" + versions;
|
|
421
|
-
}
|
|
422
|
-
async function networkProxy(sub) {
|
|
423
|
-
if (sub.length > 2) fail("ARGUMENT_INVALID");
|
|
424
|
-
const action = sub[0] || "status";
|
|
425
|
-
if (!["on", "off", "status"].includes(action)) fail("ARGUMENT_INVALID");
|
|
426
|
-
if (action === "status") {
|
|
427
|
-
const state = await readProxyState();
|
|
428
|
-
if (state && !state.stale) printProxyGuidance(state, (await findBrowser()) ? BROWSER_SHORTCUT : null);
|
|
429
|
-
else if (!state && !(await proxyModeEnabled())) process.stdout.write("AI connection proxy is not active. Enable it with: localcommander network proxy on\n");
|
|
430
|
-
else process.stdout.write("AI connection proxy is enabled but has no active lease.\n" + await explainNoProxyLease(state));
|
|
431
|
-
return;
|
|
432
|
-
}
|
|
433
|
-
if (action === "off") {
|
|
434
|
-
await writeJsonAtomic(proxyModeFile(), { enabled: false });
|
|
435
|
-
await restartIfRunning();
|
|
436
|
-
await fs.rm(proxyStateFile(), { force: true }).catch(() => {});
|
|
437
|
-
await fs.rm(proxyPacFile(), { force: true }).catch(() => {});
|
|
438
|
-
await fs.rm(browserLauncherFile(), { force: true }).catch(() => {});
|
|
439
|
-
try { removeBrowserShortcut(BROWSER_SHORTCUT); } catch { /* shortcut optional */ }
|
|
440
|
-
process.stdout.write("AI connection proxy disabled.\n");
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
const requested = sub[1] ? sub[1].split(",").map(p => p.trim().toLowerCase()) : Object.keys(PROXY_PROVIDERS);
|
|
444
|
-
const providers = requested.filter(p => Object.hasOwn(PROXY_PROVIDERS, p));
|
|
445
|
-
if (!providers.length) fail("ARGUMENT_INVALID");
|
|
446
|
-
await writeJsonAtomic(proxyModeFile(), { enabled: true, providers });
|
|
447
|
-
const record = await readProcessRecord();
|
|
448
|
-
if (record && pidAlive(record.pid)) await stopAgent();
|
|
449
|
-
// A forced stop skips the agent's cleanup; never mistake the old agent's state file for a new lease.
|
|
450
|
-
await fs.rm(proxyStateFile(), { force: true }).catch(() => {});
|
|
451
|
-
await startAgent();
|
|
452
|
-
// The agent reconnects and fetches a lease before it can publish the proxy coordinates.
|
|
453
|
-
let state = null;
|
|
454
|
-
for (let i = 0; i < 30 && !(state && !state.stale); i++) {
|
|
455
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
456
|
-
state = await readProxyState();
|
|
457
|
-
const running = await readProcessRecord();
|
|
458
|
-
if (!(state && !state.stale) && (!running || !pidAlive(running.pid))) break;
|
|
459
|
-
}
|
|
460
|
-
if (state && !state.stale) {
|
|
461
|
-
await fs.writeFile(proxyPacFile(), buildPac(state.providers, state.port), { mode: 0o600 });
|
|
462
|
-
await fs.writeFile(browserLauncherFile(), buildBrowserLauncher(state.port), { mode: 0o600 });
|
|
463
|
-
let shortcut = null;
|
|
464
|
-
try {
|
|
465
|
-
const exe = await findBrowser();
|
|
466
|
-
if (exe) shortcut = installBrowserShortcut(BROWSER_SHORTCUT, exe, `--proxy-server=http://127.0.0.1:${state.port} --user-data-dir="${browserProfileDir()}"`);
|
|
467
|
-
} catch { /* shortcut is a convenience; the launcher .cmd still works */ }
|
|
468
|
-
printProxyGuidance(state, shortcut);
|
|
469
|
-
} else process.stdout.write("AI connection proxy enabled, but no lease was obtained.\n" + await explainNoProxyLease(state));
|
|
470
|
-
}
|
|
471
|
-
async function approveWorkspace(runtimeDir) {
|
|
472
|
-
const result = execAgent(runtimeDir, ["approve-workspace"], 180_000);
|
|
473
|
-
if (result.status !== 0) fail(result.stderr.trim() || "WORKSPACE_APPROVAL_FAILED");
|
|
474
|
-
const answer = result.stdout.trim();
|
|
475
|
-
if (answer === "WORKSPACE_CANCELLED") return false;
|
|
476
|
-
if (answer !== "WORKSPACE_APPROVED") fail("WORKSPACE_APPROVAL_INVALID");
|
|
477
|
-
process.stdout.write("Workspace approved.\n");
|
|
478
|
-
return true;
|
|
479
|
-
}
|
|
480
|
-
// Owner-only local permission surface. These run the installed runtime's own
|
|
481
|
-
// commands on this PC; the cloud, the AI client and MCP calls cannot reach them.
|
|
482
|
-
const PERMISSION_COMMANDS = { "revoke-workspace": 1, "allow-write": 1, "deny-write": 1, "allow-command": 3, "deny-command": 2, "allow-localhost": 2, "deny-localhost": 2 };
|
|
483
|
-
async function listWorkspaces(runtimeDir) {
|
|
484
|
-
const result = execAgent(runtimeDir, ["list-workspaces"], 15_000);
|
|
485
|
-
if (result.status !== 0) fail(result.stderr.trim() || "WORKSPACE_LIST_FAILED");
|
|
486
|
-
try { process.stdout.write(JSON.stringify(JSON.parse(result.stdout.trim() || "[]"), null, 2) + "\n"); }
|
|
487
|
-
catch { fail("WORKSPACE_LIST_INVALID"); }
|
|
488
|
-
}
|
|
489
|
-
async function changePermission(runtimeDir, command, args) {
|
|
490
|
-
if (args.length !== PERMISSION_COMMANDS[command]) fail("ARGUMENT_INVALID");
|
|
491
|
-
// Covers the 60 s native owner review plus DPAPI/state work (see planning/OWNER_PERMISSIONS.md).
|
|
492
|
-
const result = execAgent(runtimeDir, [command, ...args], 90_000);
|
|
493
|
-
if (result.status !== 0) fail(result.stderr.trim() || "PERMISSION_UPDATE_FAILED");
|
|
494
|
-
const record = await readProcessRecord();
|
|
495
|
-
if (record && pidAlive(record.pid)) {
|
|
496
|
-
await stopAgent();
|
|
497
|
-
await startAgent();
|
|
498
|
-
process.stdout.write("Permission updated; Local Commander restarted.\n");
|
|
499
|
-
} else process.stdout.write("Permission updated.\n");
|
|
500
|
-
}
|
|
501
|
-
function pidAlive(pid) {
|
|
502
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
503
|
-
try { process.kill(pid, 0); return true; }
|
|
504
|
-
catch (error) { return error && error.code === "EPERM"; }
|
|
505
|
-
}
|
|
506
|
-
async function readProcessRecord() {
|
|
507
|
-
try {
|
|
508
|
-
const parsed = JSON.parse(await fs.readFile(processFile(), "utf8"));
|
|
509
|
-
if (!parsed || typeof parsed !== "object" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.runtimeDir !== "string") return null;
|
|
510
|
-
const runtimeDir = path.resolve(parsed.runtimeDir);
|
|
511
|
-
if (!under(versionsDirectory(), runtimeDir)) return null;
|
|
512
|
-
return { pid: parsed.pid, runtimeDir, version: typeof parsed.version === "string" ? parsed.version : null, startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null };
|
|
513
|
-
} catch { return null; }
|
|
514
|
-
}
|
|
515
|
-
async function statusObject() {
|
|
516
|
-
const current = await readCurrent();
|
|
517
|
-
const record = await readProcessRecord();
|
|
518
|
-
const running = !!record && pidAlive(record.pid);
|
|
519
|
-
if (record && !running) await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
520
|
-
const identity = current ? await identityStatus(current.runtimeDir).catch(() => ({ paired: false, accountEmail: null, deviceId: null })) : { paired: false, accountEmail: null, deviceId: null };
|
|
521
|
-
return {
|
|
522
|
-
installed: !!current,
|
|
523
|
-
version: current?.version ?? null,
|
|
524
|
-
running,
|
|
525
|
-
pid: running ? record.pid : null,
|
|
526
|
-
paired: identity.paired,
|
|
527
|
-
accountEmail: identity.accountEmail,
|
|
528
|
-
deviceId: identity.deviceId,
|
|
529
|
-
stateDirectory: stateDirectory()
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
async function verifyOwnedProcess(record) {
|
|
533
|
-
if (!pidAlive(record.pid)) return false;
|
|
534
|
-
const script = '$p=Get-CimInstance Win32_Process -Filter "ProcessId = ' + record.pid + '"; if($null -eq $p){exit 3}; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Write-Output $p.ExecutablePath; Write-Output $p.CommandLine';
|
|
535
|
-
const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
536
|
-
if (result.status === 3) return false;
|
|
537
|
-
if (result.status !== 0) fail("PROCESS_IDENTITY_CHECK_FAILED");
|
|
538
|
-
const lines = String(result.stdout || "").split(/\r?\n/u).filter(Boolean);
|
|
539
|
-
const executable = path.resolve(lines.shift() || "");
|
|
540
|
-
const expectedNode = path.resolve(record.runtimeDir, "node.exe");
|
|
541
|
-
const commandLine = lines.join("\n").toLowerCase();
|
|
542
|
-
return executable.toLowerCase() === expectedNode.toLowerCase() && commandLine.includes(path.join(record.runtimeDir, "agent.mjs").toLowerCase());
|
|
543
|
-
}
|
|
544
|
-
async function stopAgent() {
|
|
545
|
-
const record = await readProcessRecord();
|
|
546
|
-
if (!record || !pidAlive(record.pid)) {
|
|
547
|
-
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
548
|
-
return false;
|
|
549
|
-
}
|
|
550
|
-
if (!(await verifyOwnedProcess(record))) fail("PROCESS_IDENTITY_MISMATCH");
|
|
551
|
-
const result = spawnSync("taskkill.exe", ["/PID", String(record.pid), "/T", "/F"], { windowsHide: true, encoding: "utf8", timeout: 15_000 });
|
|
552
|
-
if (result.status !== 0 && pidAlive(record.pid)) fail("AGENT_STOP_FAILED");
|
|
553
|
-
for (let i = 0; i < 20 && pidAlive(record.pid); i += 1) await new Promise(resolve => setTimeout(resolve, 100));
|
|
554
|
-
if (pidAlive(record.pid)) fail("AGENT_STOP_TIMEOUT");
|
|
555
|
-
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
async function startAgent() {
|
|
559
|
-
const current = await readCurrent();
|
|
560
|
-
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
561
|
-
const identity = await identityStatus(current.runtimeDir);
|
|
562
|
-
if (!identity.paired) fail("ENROLLMENT_MISSING");
|
|
563
|
-
const status = await statusObject();
|
|
564
|
-
if (status.running) return false;
|
|
565
|
-
const child = spawn(agentExe(current.runtimeDir), ["run"], { detached: true, windowsHide: true, stdio: "ignore", env: agentEnv() });
|
|
566
|
-
child.unref();
|
|
567
|
-
for (let i = 0; i < 40; i += 1) {
|
|
568
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
569
|
-
const record = await readProcessRecord();
|
|
570
|
-
if (record && pidAlive(record.pid)) return true;
|
|
571
|
-
}
|
|
572
|
-
fail("AGENT_START_TIMEOUT");
|
|
573
|
-
}
|
|
574
|
-
async function bootstrap({ chooseWorkspace = true } = {}) {
|
|
575
|
-
const previous = await readCurrent();
|
|
576
|
-
const target = path.join(versionsDirectory(), VERSION);
|
|
577
|
-
const running = await readProcessRecord();
|
|
578
|
-
if (running && pidAlive(running.pid) && path.resolve(running.runtimeDir).toLowerCase() !== path.resolve(target).toLowerCase()) await stopAgent();
|
|
579
|
-
const runtimeDir = await copyRuntime();
|
|
580
|
-
const identity = await identityStatus(runtimeDir);
|
|
581
|
-
if (!identity.paired) await pair(runtimeDir);
|
|
582
|
-
if (chooseWorkspace) {
|
|
583
|
-
const list = execAgent(runtimeDir, ["list-workspaces"], 15_000);
|
|
584
|
-
if (list.status !== 0) fail("WORKSPACE_LIST_FAILED");
|
|
585
|
-
let existing;
|
|
586
|
-
try { existing = JSON.parse(list.stdout); } catch { fail("WORKSPACE_LIST_INVALID"); }
|
|
587
|
-
if (!Array.isArray(existing)) fail("WORKSPACE_LIST_INVALID");
|
|
588
|
-
if (existing.length === 0 && !(await configureAccess(runtimeDir))) fail("ACCESS_SETUP_CANCELLED");
|
|
589
|
-
}
|
|
590
|
-
// Antivirus can remove or lock the network files; the verified bundle is then skipped, but the agent still updates and starts.
|
|
591
|
-
let networkCopied = false, networkManifest = null, networkError = null;
|
|
592
|
-
if (AI_CONNECTION_SHIPPED) try {
|
|
593
|
-
networkCopied = await copyNetworkBundle(path.join(runtimeSource(), "network"), path.join(runtimeDir, "network"));
|
|
594
|
-
if (networkCopied) networkManifest = (await inspectNetworkBundle(path.join(runtimeDir, "network")))?.manifest;
|
|
595
|
-
} catch (error) {
|
|
596
|
-
networkCopied = false;
|
|
597
|
-
networkError = error && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : error && /^E[A-Z]{2,20}$/u.test(error.code) ? error.code : "NETWORK_BUNDLE_UNREADABLE";
|
|
598
|
-
}
|
|
599
|
-
if (networkError) process.stdout.write("The AI connection files could not be installed (" + networkError + "). Antivirus may have removed or blocked them. Local Commander starts without the AI connection.\n");
|
|
600
|
-
else if (networkCopied && implicitSetupAllowed(networkManifest)) {
|
|
601
|
-
const network = execAgent(runtimeDir, ["network-setup"], 330_000);
|
|
602
|
-
if (network.status !== 0) process.stdout.write("AI network setup was not completed. Local Commander will start without changing your network. Use localcommander network setup to retry.\n");
|
|
603
|
-
} else if (networkCopied) process.stdout.write("This build includes the AI connection pilot. It changes nothing until you run: localcommander network setup\n");
|
|
604
|
-
registerAutostart(runtimeDir);
|
|
605
|
-
await startAgent();
|
|
606
|
-
const commandBin = await installCommandFiles(PACKAGE_ROOT, appDataRoot(), VERSION);
|
|
607
|
-
updateUserPath(commandBin);
|
|
608
|
-
// The Start menu entry is a convenience: a conflicting user shortcut or shell failure must not undo a working installation.
|
|
609
|
-
let shortcut = null, shortcutError = null;
|
|
610
|
-
try { shortcut = installStartMenuShortcut(commandBin); } catch (error) { shortcutError = error && typeof error.message === "string" ? error.message : "SHORTCUT_UPDATE_FAILED"; }
|
|
611
|
-
if (previous && previous.version !== VERSION) process.stdout.write("Updated Local Commander from " + previous.version + " to " + VERSION + ".\n");
|
|
612
|
-
process.stdout.write("Local Commander " + VERSION + " is installed and running.\n");
|
|
613
|
-
process.stdout.write("The localcommander command is on your user PATH. Open a new CMD or terminal window to use it.\n");
|
|
614
|
-
process.stdout.write("Watch connections and tasks with: localcommander monitor --watch\n");
|
|
615
|
-
if (shortcut) process.stdout.write('Start menu: "' + shortcut + '" opens the same live view without a terminal.\n');
|
|
616
|
-
else process.stdout.write("The Start menu shortcut was not created (" + shortcutError + "); the localcommander command still works.\n");
|
|
617
|
-
}
|
|
618
|
-
async function uninstall(purgeState) {
|
|
619
|
-
await stopAgent();
|
|
620
|
-
const current = await readCurrent();
|
|
621
|
-
if (current) {
|
|
622
|
-
const network = execAgent(current.runtimeDir, ["network-uninstall"], 330_000);
|
|
623
|
-
if (network.status !== 0) process.stdout.write("The network service could not be removed automatically. The agent is stopped, so its VPN authorization cannot renew. Remove LocalCommanderNetwork through the approved administrator workflow.\n");
|
|
624
|
-
}
|
|
625
|
-
removeAutostart();
|
|
626
|
-
try { removeStartMenuShortcut(); } catch (error) { process.stdout.write("The Start menu shortcut was not removed (" + (error?.message || "SHORTCUT_UPDATE_FAILED") + ").\n"); }
|
|
627
|
-
updateUserPath(path.join(appDataRoot(), "bin"), true);
|
|
628
|
-
await removeCommandFile(appDataRoot());
|
|
629
|
-
const versions = versionsDirectory();
|
|
630
|
-
if (under(appDataRoot(), versions)) await fs.rm(versions, { recursive: true, force: true });
|
|
631
|
-
await fs.rm(currentPath(), { force: true });
|
|
632
|
-
if (purgeState) {
|
|
633
|
-
const state = stateDirectory();
|
|
634
|
-
const expected = path.join(appDataRoot(), "Agent");
|
|
635
|
-
if (path.resolve(state).toLowerCase() !== path.resolve(expected).toLowerCase()) fail("CUSTOM_STATE_PURGE_REFUSED");
|
|
636
|
-
await fs.rm(state, { recursive: true, force: true });
|
|
637
|
-
}
|
|
638
|
-
process.stdout.write(purgeState ? "Local Commander removed, including local enrollment state.\n" : "Local Commander removed. Protected enrollment state was preserved.\n");
|
|
639
|
-
}
|
|
640
|
-
function allowCommandHelp() {
|
|
641
|
-
return [
|
|
642
|
-
"Usage: npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
643
|
-
"Exactly three arguments are required. Quote executable paths containing spaces.",
|
|
644
|
-
"Find the workspace ID with: npx local-commander-agent@latest workspaces",
|
|
645
|
-
"",
|
|
646
|
-
"CMD: find Git with where git.exe, then copy the intended full path:",
|
|
647
|
-
' npx local-commander-agent@latest allow-command WORKSPACE_ID git "C:\\Path To Git\\git.exe"',
|
|
648
|
-
"Replace WORKSPACE_ID and the example path with your actual values.",
|
|
649
|
-
"",
|
|
650
|
-
"PowerShell only:",
|
|
651
|
-
" $gitExe = @(Get-Command git.exe -CommandType Application -ErrorAction Stop)[0].Source",
|
|
652
|
-
' npx.cmd local-commander-agent@latest allow-command WORKSPACE_ID git "$gitExe"',
|
|
653
|
-
"CMD does not evaluate (Get-Command ...).Source. Local Commander never evaluates shell expressions.",
|
|
654
|
-
"A valid command still requires local owner approval.",
|
|
655
|
-
""
|
|
656
|
-
].join("\n");
|
|
657
|
-
}
|
|
658
|
-
// #104 slice 2: direct local MCP for AI clients on this computer, with no cloud round trip per call.
|
|
659
|
-
// The owner's opt-in lives outside the agent state directory; the agent re-reads it on every call.
|
|
660
|
-
function localMcpDirectory() { return path.join(appDataRoot(), "LocalMcp"); }
|
|
661
|
-
const LOCAL_MCP_CLIENTS = [
|
|
662
|
-
" Claude Code: claude mcp add local-commander -- cmd /c localcommander mcp",
|
|
663
|
-
" Codex: ~/.codex/config.toml -> [mcp_servers.local-commander] command = \"cmd\", args = [\"/c\", \"localcommander\", \"mcp\"]",
|
|
664
|
-
" Other clients: { \"command\": \"cmd\", \"args\": [\"/c\", \"localcommander\", \"mcp\"] }"
|
|
665
|
-
];
|
|
666
|
-
async function localMcpCommand(sub) {
|
|
667
|
-
if (sub.length > 1) fail("ARGUMENT_INVALID");
|
|
668
|
-
const action = sub[0], settings = path.join(localMcpDirectory(), "settings.json");
|
|
669
|
-
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
670
|
-
if (action === undefined) {
|
|
671
|
-
// Stdio passes straight through: the AI client speaks MCP to the agent runtime, never to this launcher.
|
|
672
|
-
const child = spawn(path.join(current.runtimeDir, "node.exe"), [path.join(current.runtimeDir, "agent.mjs"), "mcp"], { stdio: "inherit", windowsHide: true, env: agentEnv() });
|
|
673
|
-
await new Promise(resolve => {
|
|
674
|
-
child.once("exit", code => { process.exitCode = code ?? 1; resolve(); });
|
|
675
|
-
child.once("error", () => { process.exitCode = 1; resolve(); });
|
|
676
|
-
});
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
if (action === "on" || action === "off") {
|
|
680
|
-
await writeJsonAtomic(settings, { enabled: action === "on", updatedAt: new Date().toISOString() });
|
|
681
|
-
await restartIfRunning();
|
|
682
|
-
process.stdout.write(action === "on" ? [
|
|
683
|
-
"Direct local MCP is ON. AI clients on this computer can use your approved workspaces without the cloud.",
|
|
684
|
-
"Add Local Commander to a client's MCP settings:", ...LOCAL_MCP_CLIENTS,
|
|
685
|
-
"Turn it off at any time with: localcommander mcp off", ""].join("\n")
|
|
686
|
-
: "Direct local MCP is OFF. AI clients on this computer can no longer use Local Commander directly.\n");
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
if (action === "status") {
|
|
690
|
-
let enabled = false, endpoint = null;
|
|
691
|
-
try { enabled = JSON.parse(await fs.readFile(settings, "utf8"))?.enabled === true; } catch {}
|
|
692
|
-
try { endpoint = JSON.parse(await fs.readFile(path.join(localMcpDirectory(), "endpoint.json"), "utf8")); } catch {}
|
|
693
|
-
const serving = enabled && Number.isSafeInteger(endpoint?.pid) && pidAlive(endpoint.pid);
|
|
694
|
-
process.stdout.write([
|
|
695
|
-
"Direct local MCP: " + (enabled ? "ON" : "OFF") + (enabled ? (serving ? " (agent is serving on 127.0.0.1)" : " (agent not serving: run localcommander restart)") : ""),
|
|
696
|
-
...(enabled ? ["MCP settings for AI clients on this computer:", ...LOCAL_MCP_CLIENTS] : ["Turn it on with: localcommander mcp on"]), ""].join("\n"));
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
fail("ARGUMENT_INVALID");
|
|
700
|
-
}
|
|
701
|
-
function help() {
|
|
702
|
-
return [
|
|
703
|
-
"Local Commander agent " + VERSION,
|
|
704
|
-
"",
|
|
705
|
-
"Usage:",
|
|
706
|
-
" npx local-commander-agent@latest install/pair/start",
|
|
707
|
-
" npx local-commander-agent@latest status show local state",
|
|
708
|
-
" localcommander monitor --watch live connections and tasks (also: Start menu > Local Commander Monitor)",
|
|
709
|
-
" localcommander monitor --json monitoring snapshot as JSON",
|
|
710
|
-
" localcommander logs --follow recent production relay events",
|
|
711
|
-
" npx local-commander-agent@latest start start background agent",
|
|
712
|
-
" npx local-commander-agent@latest stop stop background agent",
|
|
713
|
-
" npx local-commander-agent@latest restart restart background agent",
|
|
714
|
-
" npx local-commander-agent@latest update install this package version and restart",
|
|
715
|
-
" npx local-commander-agent@latest configure-access choose all connected drives or selected folders",
|
|
716
|
-
" npx local-commander-agent@latest approve-workspace add another read/write folder",
|
|
717
|
-
" npx local-commander-agent@latest revoke-workspace <workspaceGrantId>",
|
|
718
|
-
" npx local-commander-agent@latest workspaces list approved workspaces and permissions",
|
|
719
|
-
...(AI_CONNECTION_SHIPPED ? [
|
|
720
|
-
" localcommander network [status|auto|on|off|setup|panel] owner-local AI connection controls",
|
|
721
|
-
" localcommander network proxy [on|off|status] [providers] driver-free AI proxy for browser + CLI (no TUN)"
|
|
722
|
-
] : []),
|
|
723
|
-
" localcommander mcp on|off|status direct local MCP for AI clients on this computer (no cloud per call)",
|
|
724
|
-
" localcommander mcp stdio MCP server; start it from an AI client's MCP settings",
|
|
725
|
-
" localcommander audit [status|on|off|copy-key] owner-local encrypted content audit (pilot)",
|
|
726
|
-
" localcommander audit restore <file> <output> decrypt with your key in LCMCP_AUDIT_RECOVERY_KEY",
|
|
727
|
-
" localcommander fabric-owner owner mailbox request through private stdin",
|
|
728
|
-
" localcommander fabric-worker restricted worker request through private stdin",
|
|
729
|
-
" npx local-commander-agent@latest allow-write <workspaceGrantId>",
|
|
730
|
-
" npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
731
|
-
" npx local-commander-agent@latest allow-command --help CMD and PowerShell examples",
|
|
732
|
-
" npx local-commander-agent@latest allow-localhost <workspaceGrantId> <port>",
|
|
733
|
-
" npx local-commander-agent@latest deny-write|deny-command|deny-localhost ...",
|
|
734
|
-
" npx local-commander-agent@latest uninstall [--purge-state]",
|
|
735
|
-
""
|
|
736
|
-
].join("\n");
|
|
737
|
-
}
|
|
738
|
-
async function main() {
|
|
739
|
-
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(VERSION)) fail("PACKAGE_VERSION_INVALID");
|
|
740
|
-
// macOS and Linux run the per-platform runtime package through its own lifecycle (#253).
|
|
741
|
-
if (process.platform !== "win32") return (await import("./posix.mjs")).runPosix(process.argv.slice(2), VERSION);
|
|
742
|
-
requireWindows();
|
|
743
|
-
const command = process.argv[2] || "bootstrap";
|
|
744
|
-
const args = process.argv.slice(3);
|
|
745
|
-
if (command === "-h" || command === "--help" || command === "help") {
|
|
746
|
-
process.stdout.write(help());
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
if (command === "allow-command") {
|
|
750
|
-
if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
|
|
751
|
-
process.stdout.write(allowCommandHelp());
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
// Reject shell fragments before reading installed state or opening owner consent.
|
|
755
|
-
if (args.length !== PERMISSION_COMMANDS[command] || !path.win32.isAbsolute(args[2])) fail("ARGUMENT_INVALID");
|
|
756
|
-
}
|
|
757
|
-
if (command === "bootstrap" || command === "install") {
|
|
758
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
759
|
-
await bootstrap();
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
if (command === "update") {
|
|
763
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
764
|
-
await bootstrap({ chooseWorkspace: false });
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
if (command === "start") {
|
|
768
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
769
|
-
await startAgent();
|
|
770
|
-
process.stdout.write("Local Commander is running.\n");
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
if (command === "stop") {
|
|
774
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
775
|
-
const stopped = await stopAgent();
|
|
776
|
-
process.stdout.write(stopped ? "Local Commander stopped.\n" : "Local Commander was not running.\n");
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
if (command === "restart") {
|
|
780
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
781
|
-
await stopAgent();
|
|
782
|
-
await startAgent();
|
|
783
|
-
process.stdout.write("Local Commander restarted.\n");
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
if (command === "status") {
|
|
787
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
788
|
-
process.stdout.write(JSON.stringify(await statusObject(), null, 2) + "\n");
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
if (command === "monitor" || command === "logs") {
|
|
792
|
-
await runMonitor(path.join(appDataRoot(), "Monitor"), command, args);
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
if (command === "network") {
|
|
796
|
-
// While the AI connection is not shipped, only checking it and switching it off stay available (for PCs that enabled the pilot).
|
|
797
|
-
if (!AI_CONNECTION_SHIPPED && !(args.length === 0 || (args.length === 1 && ["status", "off"].includes(args[0])) || (args.length === 2 && args[0] === "proxy" && args[1] === "off"))) fail("AI_CONNECTION_UNAVAILABLE");
|
|
798
|
-
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
799
|
-
if (args[0] === "proxy") { await networkProxy(args.slice(1)); return; }
|
|
800
|
-
if (args.length > 1 || !["status", "auto", "on", "off", "setup", "panel"].includes(args[0] || "status")) fail("ARGUMENT_INVALID");
|
|
801
|
-
const action = args[0] || "status";
|
|
802
|
-
const childArgs = ["auto", "on", "off"].includes(action) ? ["network-mode", action] : ["network-" + action];
|
|
803
|
-
const result = execAgent(current.runtimeDir, childArgs, action === "setup" ? 330_000 : 15_000);
|
|
804
|
-
if (result.status !== 0) fail(result.stderr.trim() || "NETWORK_ACTION_FAILED");
|
|
805
|
-
process.stdout.write(result.stdout);
|
|
806
|
-
if (action === "setup") await restartIfRunning();
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
if (command === "screenshot") {
|
|
810
|
-
await screenshotCommand(args);
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
if (command === "audit") {
|
|
814
|
-
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
815
|
-
await auditCommand(current.runtimeDir, args);
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
818
|
-
if (command === "mcp") {
|
|
819
|
-
await localMcpCommand(args);
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
if (command === "fabric-owner" || command === "fabric-worker") {
|
|
823
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
824
|
-
const current = await readCurrent();
|
|
825
|
-
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
826
|
-
await fabricCommand(current.runtimeDir, command);
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
if (command === "configure-access") {
|
|
830
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
831
|
-
const current = await readCurrent();
|
|
832
|
-
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
833
|
-
if (await configureAccess(current.runtimeDir)) {
|
|
834
|
-
await restartIfRunning();
|
|
835
|
-
process.stdout.write("Filesystem access configured.\n");
|
|
836
|
-
} else process.stdout.write("Access selection cancelled; existing permissions were preserved.\n");
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
if (command === "approve-workspace") {
|
|
840
|
-
if (args.length) fail("ARGUMENT_INVALID");
|
|
841
|
-
const current = await readCurrent();
|
|
842
|
-
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
843
|
-
if (await approveWorkspace(current.runtimeDir)) await restartIfRunning();
|
|
844
|
-
else process.stdout.write("Workspace selection cancelled.\n");
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
if (command === "workspaces" || Object.hasOwn(PERMISSION_COMMANDS, command)) {
|
|
848
|
-
if (command === "workspaces" && args.length) fail("ARGUMENT_INVALID");
|
|
849
|
-
const current = await readCurrent();
|
|
850
|
-
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
851
|
-
if (command === "workspaces") await listWorkspaces(current.runtimeDir);
|
|
852
|
-
else await changePermission(current.runtimeDir, command, args);
|
|
853
|
-
return;
|
|
854
|
-
}
|
|
855
|
-
if (command === "uninstall") {
|
|
856
|
-
if (args.length > 1 || (args.length === 1 && args[0] !== "--purge-state")) fail("ARGUMENT_INVALID");
|
|
857
|
-
await uninstall(args[0] === "--purge-state");
|
|
858
|
-
return;
|
|
859
|
-
}
|
|
860
|
-
fail("ARGUMENT_INVALID");
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
void main().catch(error => {
|
|
864
|
-
const code = error && typeof error.message === "string" && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : "LOCAL_COMMANDER_FAILED";
|
|
865
|
-
// Unlabelled failures show only the system error class and operation (e.g. EPERM copyfile), never paths or messages.
|
|
866
|
-
const detail = code === "LOCAL_COMMANDER_FAILED" && error && typeof error.code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/u.test(error.code)
|
|
867
|
-
? " (" + error.code + (typeof error.syscall === "string" && /^[a-z_]{1,20}$/u.test(error.syscall) ? " " + error.syscall : "") + ")" : "";
|
|
868
|
-
process.stderr.write(code + detail + "\n");
|
|
869
|
-
if (code === "ARGUMENT_INVALID" && process.argv[2] === "allow-command") process.stderr.write(allowCommandHelp());
|
|
870
|
-
if (code === "AI_CONNECTION_UNAVAILABLE") process.stderr.write("The AI connection is not available in this version of Local Commander.\n");
|
|
871
|
-
process.exitCode = 1;
|
|
872
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { copyNetworkBundle, implicitSetupAllowed, inspectNetworkBundle } from "./network-package.mjs";
|
|
9
|
+
import { runMonitor } from "./monitor.mjs";
|
|
10
|
+
import { installCommandFiles, installStartMenuShortcut, removeCommandFile, removeStartMenuShortcut, updateUserPath, installBrowserShortcut, removeBrowserShortcut } from "./command-path.mjs";
|
|
11
|
+
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const PACKAGE_META = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
14
|
+
const VERSION = String(PACKAGE_META.version || "");
|
|
15
|
+
const DEFAULT_ORIGIN = String(PACKAGE_META.localCommander?.controlPlaneOrigin || "");
|
|
16
|
+
const RUNTIME_FILES = ["LocalCommanderAgent.exe", "OwnerBridge.exe", "JobHost.exe", "agent.mjs", "node.exe", "NODE-LICENSE.txt"];
|
|
17
|
+
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
18
|
+
const RUN_VALUE = "LocalCommanderAgent";
|
|
19
|
+
const MAX_STDIO = 1024 * 1024;
|
|
20
|
+
// Owner decision #204: this package ships without the AI connection (#112). The code stays; set this to true
|
|
21
|
+
// (and build with LC_AI_CONNECTIVITY_BUNDLE) to bring it back. Turning it off and uninstalling still clean up.
|
|
22
|
+
const AI_CONNECTION_SHIPPED = false;
|
|
23
|
+
|
|
24
|
+
function fail(code) {
|
|
25
|
+
const error = new Error(code);
|
|
26
|
+
error.code = code;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
function requireWindows() {
|
|
30
|
+
if (process.platform !== "win32" || process.arch !== "x64") fail("WINDOWS_X64_REQUIRED");
|
|
31
|
+
}
|
|
32
|
+
function appDataRoot() {
|
|
33
|
+
const value = process.env.LOCALAPPDATA;
|
|
34
|
+
if (!value || !path.isAbsolute(value) || /[\u0000-\u001f]/u.test(value)) fail("APP_DATA_MISSING");
|
|
35
|
+
return path.join(value, "LocalCommander");
|
|
36
|
+
}
|
|
37
|
+
function stateDirectory() {
|
|
38
|
+
const override = process.env.LC_AGENT_STATE_DIR;
|
|
39
|
+
if (override) {
|
|
40
|
+
if (!path.isAbsolute(override) || /[\u0000-\u001f]/u.test(override)) fail("STATE_DIR_INVALID");
|
|
41
|
+
return path.resolve(override);
|
|
42
|
+
}
|
|
43
|
+
return path.join(appDataRoot(), "Agent");
|
|
44
|
+
}
|
|
45
|
+
function versionsDirectory() {
|
|
46
|
+
return path.join(appDataRoot(), "Versions");
|
|
47
|
+
}
|
|
48
|
+
function currentPath() {
|
|
49
|
+
return path.join(appDataRoot(), "current.json");
|
|
50
|
+
}
|
|
51
|
+
function runtimeSource() {
|
|
52
|
+
if (PACKAGE_ROOT.toLowerCase() === path.join(versionsDirectory(), VERSION).toLowerCase()) return PACKAGE_ROOT;
|
|
53
|
+
return path.join(PACKAGE_ROOT, "runtime");
|
|
54
|
+
}
|
|
55
|
+
function processFile() {
|
|
56
|
+
return path.join(stateDirectory(), "agent-process.json");
|
|
57
|
+
}
|
|
58
|
+
function under(parent, child) {
|
|
59
|
+
const rel = path.relative(parent, child);
|
|
60
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
61
|
+
}
|
|
62
|
+
function boundedText(value) {
|
|
63
|
+
const text = String(value ?? "");
|
|
64
|
+
return text.length > MAX_STDIO ? text.slice(0, MAX_STDIO) : text;
|
|
65
|
+
}
|
|
66
|
+
async function regular(file) {
|
|
67
|
+
const stat = await fs.lstat(file);
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 1) fail("PACKAGE_RUNTIME_INVALID");
|
|
69
|
+
return stat;
|
|
70
|
+
}
|
|
71
|
+
async function ensurePlainDirectory(dir) {
|
|
72
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
const stat = await fs.lstat(dir);
|
|
74
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail("INSTALL_DIRECTORY_INVALID");
|
|
75
|
+
return dir;
|
|
76
|
+
}
|
|
77
|
+
async function ensureInstallDirectories() {
|
|
78
|
+
const root = await ensurePlainDirectory(appDataRoot());
|
|
79
|
+
const versions = await ensurePlainDirectory(versionsDirectory());
|
|
80
|
+
if (!under(root, versions)) fail("INSTALL_DIRECTORY_INVALID");
|
|
81
|
+
return { root, versions };
|
|
82
|
+
}
|
|
83
|
+
async function runtimeValid(dir) {
|
|
84
|
+
try {
|
|
85
|
+
for (const name of RUNTIME_FILES) await regular(path.join(dir, name));
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function writeJsonAtomic(file, value) {
|
|
92
|
+
await ensurePlainDirectory(path.dirname(file));
|
|
93
|
+
const existing = await fs.lstat(file).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
94
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail("INSTALL_STATE_INVALID");
|
|
95
|
+
const temp = file + "." + process.pid + "." + randomBytes(6).toString("hex") + ".tmp";
|
|
96
|
+
await fs.writeFile(temp, JSON.stringify(value, null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
97
|
+
try {
|
|
98
|
+
await fs.rename(temp, file);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await fs.rm(temp, { force: true });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function readCurrent() {
|
|
105
|
+
try {
|
|
106
|
+
const current = currentPath();
|
|
107
|
+
const stat = await fs.lstat(current);
|
|
108
|
+
if (!stat.isFile() || stat.isSymbolicLink()) fail("INSTALL_STATE_INVALID");
|
|
109
|
+
const raw = JSON.parse(await fs.readFile(current, "utf8"));
|
|
110
|
+
if (!raw || typeof raw !== "object" || typeof raw.version !== "string" || typeof raw.runtimeDir !== "string") fail("INSTALL_STATE_INVALID");
|
|
111
|
+
const dir = path.resolve(raw.runtimeDir);
|
|
112
|
+
if (!under(versionsDirectory(), dir) || !(await runtimeValid(dir))) fail("INSTALL_STATE_INVALID");
|
|
113
|
+
return { version: raw.version, runtimeDir: dir };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error && error.code === "ENOENT") return null;
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function copyRuntime() {
|
|
120
|
+
const source = runtimeSource();
|
|
121
|
+
if (!(await runtimeValid(source))) fail("PACKAGE_RUNTIME_MISSING");
|
|
122
|
+
const { versions } = await ensureInstallDirectories();
|
|
123
|
+
const target = path.join(versions, VERSION);
|
|
124
|
+
if (await runtimeValid(target)) {
|
|
125
|
+
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
126
|
+
return target;
|
|
127
|
+
}
|
|
128
|
+
const targetStat = await fs.lstat(target).catch(error => error?.code === "ENOENT" ? null : Promise.reject(error));
|
|
129
|
+
if (targetStat) {
|
|
130
|
+
if (!targetStat.isDirectory() || targetStat.isSymbolicLink() || !under(versions, target)) fail("INSTALL_TARGET_INVALID");
|
|
131
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
const temp = path.join(versions, "." + VERSION + "." + process.pid + "." + randomBytes(6).toString("hex"));
|
|
134
|
+
await fs.mkdir(temp, { recursive: false, mode: 0o700 });
|
|
135
|
+
try {
|
|
136
|
+
for (const name of RUNTIME_FILES) {
|
|
137
|
+
const sourceFile = path.join(source, name);
|
|
138
|
+
const sourceStat = await regular(sourceFile);
|
|
139
|
+
const destination = path.join(temp, name);
|
|
140
|
+
await fs.copyFile(sourceFile, destination, fsConstants.COPYFILE_EXCL);
|
|
141
|
+
const copied = await regular(destination);
|
|
142
|
+
if (copied.size !== sourceStat.size) fail("PACKAGE_COPY_INVALID");
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await fs.rename(temp, target);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!(await runtimeValid(target))) throw error;
|
|
148
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
await writeJsonAtomic(currentPath(), { version: VERSION, runtimeDir: target });
|
|
151
|
+
return target;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
await fs.rm(temp, { recursive: true, force: true });
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function runReg(args, allowMissing = false) {
|
|
158
|
+
const result = spawnSync("reg.exe", args, { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
159
|
+
if (result.error) fail("AUTOSTART_COMMAND_FAILED");
|
|
160
|
+
if (result.status !== 0 && !(allowMissing && result.status === 1)) fail("AUTOSTART_COMMAND_FAILED");
|
|
161
|
+
}
|
|
162
|
+
function registerAutostart(runtimeDir) {
|
|
163
|
+
const exe = path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
164
|
+
if (exe.includes('"')) fail("INSTALL_PATH_INVALID");
|
|
165
|
+
runReg(["ADD", RUN_KEY, "/v", RUN_VALUE, "/t", "REG_SZ", "/d", '"' + exe + '" run', "/f"]);
|
|
166
|
+
}
|
|
167
|
+
function removeAutostart() {
|
|
168
|
+
runReg(["DELETE", RUN_KEY, "/v", RUN_VALUE, "/f"], true);
|
|
169
|
+
}
|
|
170
|
+
function agentEnv() {
|
|
171
|
+
const env = { ...process.env, LC_AGENT_STATE_DIR: stateDirectory() };
|
|
172
|
+
delete env.NODE_OPTIONS;
|
|
173
|
+
delete env.NODE_PATH;
|
|
174
|
+
return env;
|
|
175
|
+
}
|
|
176
|
+
function agentExe(runtimeDir) {
|
|
177
|
+
return path.join(runtimeDir, "LocalCommanderAgent.exe");
|
|
178
|
+
}
|
|
179
|
+
function execAgent(runtimeDir, args, timeout = 30_000) {
|
|
180
|
+
const result = spawnSync(agentExe(runtimeDir), args, {
|
|
181
|
+
windowsHide: true,
|
|
182
|
+
encoding: "utf8",
|
|
183
|
+
timeout,
|
|
184
|
+
env: agentEnv()
|
|
185
|
+
});
|
|
186
|
+
if (result.error) fail("AGENT_COMMAND_FAILED");
|
|
187
|
+
return { status: result.status ?? -1, stdout: boundedText(result.stdout), stderr: boundedText(result.stderr) };
|
|
188
|
+
}
|
|
189
|
+
async function fabricCommand(runtimeDir, command) {
|
|
190
|
+
const chunks = []; let bytes = 0;
|
|
191
|
+
for await (const chunk of process.stdin) {
|
|
192
|
+
bytes += chunk.length;
|
|
193
|
+
if (bytes > 16_384) fail("FABRIC_REQUEST_LIMIT");
|
|
194
|
+
chunks.push(chunk);
|
|
195
|
+
}
|
|
196
|
+
// Private stdin is explicit; no credential arguments, shell, listener or foreground window.
|
|
197
|
+
const result = spawnSync(path.join(runtimeDir, "node.exe"), [path.join(runtimeDir, "agent.mjs"), command], {
|
|
198
|
+
input: Buffer.concat(chunks), windowsHide: true, encoding: "utf8", timeout: 30_000,
|
|
199
|
+
maxBuffer: 2 * 1024 * 1024, env: agentEnv()
|
|
200
|
+
});
|
|
201
|
+
if (result.error || result.status !== 0) fail(/^[A-Z0-9_]{1,80}$/u.test(result.stderr?.trim()) ? result.stderr.trim() : "FABRIC_REQUEST_FAILED");
|
|
202
|
+
process.stdout.write(result.stdout);
|
|
203
|
+
}
|
|
204
|
+
function openBrowser(url) {
|
|
205
|
+
let parsed;
|
|
206
|
+
try { parsed = new URL(url); } catch { fail("PAIRING_URL_INVALID"); }
|
|
207
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname))) fail("PAIRING_URL_INVALID");
|
|
208
|
+
const child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", parsed.toString()], { detached: true, windowsHide: true, stdio: "ignore" });
|
|
209
|
+
child.unref();
|
|
210
|
+
}
|
|
211
|
+
async function identityStatus(runtimeDir) {
|
|
212
|
+
const result = execAgent(runtimeDir, ["identity-status"], 15_000);
|
|
213
|
+
if (result.status !== 0) fail(result.stderr.trim() || "IDENTITY_STATUS_FAILED");
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(result.stdout.trim());
|
|
216
|
+
return { paired: parsed.paired === true, accountEmail: typeof parsed.accountEmail === "string" ? parsed.accountEmail : null, deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : null };
|
|
217
|
+
} catch { fail("IDENTITY_STATUS_INVALID"); }
|
|
218
|
+
}
|
|
219
|
+
async function pair(runtimeDir) {
|
|
220
|
+
const origin = process.env.LC_CONTROL_PLANE_ORIGIN || DEFAULT_ORIGIN;
|
|
221
|
+
if (!origin) fail("CONTROL_PLANE_ORIGIN_MISSING");
|
|
222
|
+
const child = spawn(agentExe(runtimeDir), ["pair"], {
|
|
223
|
+
windowsHide: true,
|
|
224
|
+
env: { ...agentEnv(), LC_CONTROL_PLANE_ORIGIN: origin },
|
|
225
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
226
|
+
});
|
|
227
|
+
let stdout = "", stderr = "", opened = false;
|
|
228
|
+
child.stdout.setEncoding("utf8");
|
|
229
|
+
child.stderr.setEncoding("utf8");
|
|
230
|
+
child.stdout.on("data", chunk => {
|
|
231
|
+
stdout = boundedText(stdout + chunk);
|
|
232
|
+
if (opened) return;
|
|
233
|
+
const lineEnd = stdout.indexOf("\n");
|
|
234
|
+
if (lineEnd < 0) return;
|
|
235
|
+
const line = stdout.slice(0, lineEnd).trim();
|
|
236
|
+
try {
|
|
237
|
+
const begin = JSON.parse(line);
|
|
238
|
+
if (typeof begin.approvalUrl !== "string" || typeof begin.confirmationCode !== "string") fail("PAIRING_RESPONSE_INVALID");
|
|
239
|
+
opened = true;
|
|
240
|
+
// #218: the link carries the code, so the website opens the dialog with it filled in.
|
|
241
|
+
const link = new URL(begin.approvalUrl);
|
|
242
|
+
if (/^[A-Za-z2-9]{8}$/.test(begin.confirmationCode)) link.searchParams.set("code", begin.confirmationCode);
|
|
243
|
+
process.stdout.write("Pairing code: " + begin.confirmationCode + "\n");
|
|
244
|
+
process.stdout.write("Opening: " + link + "\n");
|
|
245
|
+
openBrowser(link.toString());
|
|
246
|
+
} catch {
|
|
247
|
+
child.kill();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
child.stderr.on("data", chunk => { stderr = boundedText(stderr + chunk); });
|
|
251
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
252
|
+
child.once("error", reject);
|
|
253
|
+
child.once("exit", code => resolve(code ?? -1));
|
|
254
|
+
}).catch(() => -1);
|
|
255
|
+
if (exitCode !== 0) fail(stderr.trim() || "PAIRING_FAILED");
|
|
256
|
+
if (!opened) fail("PAIRING_RESPONSE_INVALID");
|
|
257
|
+
process.stdout.write("Device paired.\n");
|
|
258
|
+
}
|
|
259
|
+
async function configureAccess(runtimeDir) {
|
|
260
|
+
const result = execAgent(runtimeDir, ["configure-access"], 300_000);
|
|
261
|
+
if (result.status !== 0) fail(result.stderr.trim() || "ACCESS_CONFIGURATION_FAILED");
|
|
262
|
+
const answer = result.stdout.trim();
|
|
263
|
+
if (answer === "ACCESS_CANCELLED") return false;
|
|
264
|
+
if (answer !== "ACCESS_CONFIGURED") fail("ACCESS_CONFIGURATION_INVALID");
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
async function restartIfRunning() {
|
|
268
|
+
const record = await readProcessRecord();
|
|
269
|
+
if (record && pidAlive(record.pid)) { await stopAgent(); await startAgent(); }
|
|
270
|
+
}
|
|
271
|
+
// Owner opt-in encrypted content audit (#131 pilot). The recovery key goes from the agent straight
|
|
272
|
+
// into the clipboard; it is never printed.
|
|
273
|
+
function copyAuditKey(runtimeDir) {
|
|
274
|
+
const key = execAgent(runtimeDir, ["audit-key"], 15_000);
|
|
275
|
+
if (key.status !== 0) fail(key.stderr.trim() || "AUDIT_KEY_MISSING");
|
|
276
|
+
const clip = spawnSync(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "clip.exe"), [], { input: key.stdout.trim(), windowsHide: true, timeout: 10_000 });
|
|
277
|
+
if (clip.error || clip.status !== 0) fail("CLIPBOARD_FAILED");
|
|
278
|
+
}
|
|
279
|
+
async function auditCommand(runtimeDir, sub) {
|
|
280
|
+
const action = sub[0] || "status";
|
|
281
|
+
const agent = (args, timeout = 30_000) => {
|
|
282
|
+
const result = execAgent(runtimeDir, args, timeout);
|
|
283
|
+
if (result.status !== 0) fail(result.stderr.trim() || "AUDIT_ACTION_FAILED");
|
|
284
|
+
return result.stdout;
|
|
285
|
+
};
|
|
286
|
+
if (action === "status" && sub.length <= 1) { process.stdout.write(agent(["audit-status"], 120_000)); return; }
|
|
287
|
+
if (action === "on" && sub.length === 1) {
|
|
288
|
+
const info = JSON.parse(agent(["audit-on"]).trim());
|
|
289
|
+
if (info.created) copyAuditKey(runtimeDir);
|
|
290
|
+
await restartIfRunning();
|
|
291
|
+
process.stdout.write([
|
|
292
|
+
"Encrypted content audit is ON for this computer (pilot).",
|
|
293
|
+
"Files: " + info.directory + " (kept " + info.retentionDays + " days, capture pauses at " + Math.round(info.maxBytes / 1048576) + " MB)",
|
|
294
|
+
info.created
|
|
295
|
+
? "A new recovery key (" + info.keyId + ") was copied to the clipboard. Paste it into your password manager now, then clear the clipboard.\nWithout it nobody, including Local Commander, can read the audit files on another computer."
|
|
296
|
+
: "Using your existing recovery key (" + info.keyId + "). Run 'localcommander audit copy-key' to copy it again.",
|
|
297
|
+
""
|
|
298
|
+
].join("\n"));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (action === "off" && sub.length === 1) {
|
|
302
|
+
agent(["audit-off"]);
|
|
303
|
+
await restartIfRunning();
|
|
304
|
+
process.stdout.write("Encrypted content audit is OFF. Existing files stay until they expire.\n");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (action === "copy-key" && sub.length === 1) {
|
|
308
|
+
copyAuditKey(runtimeDir);
|
|
309
|
+
process.stdout.write("Recovery key copied to the clipboard.\n");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (action === "restore" && sub.length === 3) {
|
|
313
|
+
// Uses the owner's own key copy from LCMCP_AUDIT_RECOVERY_KEY; writes a new plaintext file.
|
|
314
|
+
process.stdout.write(agent(["audit-restore", path.resolve(sub[1]), path.resolve(sub[2])], 600_000));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
fail("ARGUMENT_INVALID");
|
|
318
|
+
}
|
|
319
|
+
// Owner opt-out for take_screenshot under Full Access (#486), on by default. Only this owner-run command changes it (the agent
|
|
320
|
+
// state folder is protected from every file tool); the agent checks it on each capture, so no restart is needed.
|
|
321
|
+
const SCREENSHOT_OFF_FILE = "screenshot-disabled-by-owner";
|
|
322
|
+
async function screenshotCommand(args) {
|
|
323
|
+
const action = args[0] || "status";
|
|
324
|
+
if (args.length > 1 || !["status", "on", "off"].includes(action)) fail("ARGUMENT_INVALID");
|
|
325
|
+
if (!(await readCurrent())) fail("AGENT_NOT_INSTALLED");
|
|
326
|
+
const file = path.join(stateDirectory(), SCREENSHOT_OFF_FILE);
|
|
327
|
+
if (action === "off") await fs.writeFile(file, "", { flag: "wx" }).catch(error => { if (error?.code !== "EEXIST") throw error; });
|
|
328
|
+
if (action === "on") await fs.rm(file, { force: true });
|
|
329
|
+
const off = await fs.lstat(file).then(() => true, error => error?.code !== "ENOENT");
|
|
330
|
+
process.stdout.write(JSON.stringify({ screenshot: off ? "off" : "on" }) + "\n");
|
|
331
|
+
}
|
|
332
|
+
// Public AI-provider domains routed through the tunnel; everything else stays direct. Mirror of
|
|
333
|
+
// packages/connectivity/src/profiles.ts (stable public suffixes).
|
|
334
|
+
const PROXY_PROVIDERS = {
|
|
335
|
+
openai: ["chatgpt.com", "openai.com", "oaistatic.com", "oaiusercontent.com", "oaistatsig.com", "cdn.openaimerge.com"],
|
|
336
|
+
anthropic: ["claude.ai", "claude.com", "anthropic.com"],
|
|
337
|
+
cursor: ["cursor.sh", "cursor.com", "cursor-cdn.com", "marketplace.cursorapi.com"]
|
|
338
|
+
};
|
|
339
|
+
// Proxy-mode files live in their own folder, never in the private store dir (which rejects unknown files).
|
|
340
|
+
function proxyDir() { return path.join(path.dirname(stateDirectory()), "Proxy"); }
|
|
341
|
+
function proxyModeFile() { return path.join(proxyDir(), "proxy-mode.json"); }
|
|
342
|
+
function proxyStateFile() { return path.join(proxyDir(), "proxy.json"); }
|
|
343
|
+
function proxyPacFile() { return path.join(appDataRoot(), "ai-proxy.pac"); }
|
|
344
|
+
async function readProxyState() {
|
|
345
|
+
try {
|
|
346
|
+
const raw = JSON.parse(await fs.readFile(proxyStateFile(), "utf8"));
|
|
347
|
+
if (!raw || typeof raw.port !== "number" || typeof raw.password !== "string" || typeof raw.username !== "string" || !Array.isArray(raw.providers)) return null;
|
|
348
|
+
if (typeof raw.expiresAt === "number" && raw.expiresAt < Date.now()) return { ...raw, stale: true };
|
|
349
|
+
return raw;
|
|
350
|
+
} catch { return null; }
|
|
351
|
+
}
|
|
352
|
+
function buildPac(providers, port) {
|
|
353
|
+
const hosts = [...new Set(providers.flatMap(p => PROXY_PROVIDERS[p] ?? []))];
|
|
354
|
+
const tests = hosts.map(h => `shExpMatch(host, "${h}") || shExpMatch(host, "*.${h}")`).join(" ||\n ");
|
|
355
|
+
return `function FindProxyForURL(url, host) {\n host = host.toLowerCase();\n if (${tests}) {\n return "PROXY 127.0.0.1:${port}";\n }\n return "DIRECT";\n}\n`;
|
|
356
|
+
}
|
|
357
|
+
function proxyUrl(state) { return state.password ? `http://${state.username}:${state.password}@127.0.0.1:${state.port}` : `http://127.0.0.1:${state.port}`; }
|
|
358
|
+
function browserLauncherFile() { return path.join(appDataRoot(), "open-ai-browser.cmd"); }
|
|
359
|
+
function browserProfileDir() { return path.join(appDataRoot(), "ai-browser"); }
|
|
360
|
+
const BROWSER_SHORTCUT = "AI Browser (Local Commander)";
|
|
361
|
+
async function findBrowser() {
|
|
362
|
+
const candidates = [
|
|
363
|
+
[process.env.ProgramFiles, "Google\\Chrome\\Application\\chrome.exe"],
|
|
364
|
+
[process.env["ProgramFiles(x86)"], "Google\\Chrome\\Application\\chrome.exe"],
|
|
365
|
+
[process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe"],
|
|
366
|
+
[process.env["ProgramFiles(x86)"], "Microsoft\\Edge\\Application\\msedge.exe"],
|
|
367
|
+
[process.env.ProgramFiles, "Microsoft\\Edge\\Application\\msedge.exe"]
|
|
368
|
+
];
|
|
369
|
+
for (const [base, rel] of candidates) {
|
|
370
|
+
if (!base) continue;
|
|
371
|
+
const exe = path.join(base, rel);
|
|
372
|
+
try { if ((await fs.lstat(exe)).isFile()) return exe; } catch { /* try next */ }
|
|
373
|
+
}
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
// A double-click launcher: opens Chrome/Edge through the proxy in a dedicated profile that remembers
|
|
377
|
+
// the proxy sign-in, so after the first time it is seamless. The port is stable across restarts.
|
|
378
|
+
function buildBrowserLauncher(port) {
|
|
379
|
+
const prof = browserProfileDir();
|
|
380
|
+
const flags = `--proxy-server=http://127.0.0.1:${port} --user-data-dir="${prof}"`;
|
|
381
|
+
const chrome = ["%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe", "%ProgramFiles(x86)%\\Google\\Chrome\\Application\\chrome.exe", "%LocalAppData%\\Google\\Chrome\\Application\\chrome.exe"];
|
|
382
|
+
const edge = ["%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe", "%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe"];
|
|
383
|
+
const lines = ["@echo off", "rem Local Commander - open AI sites through the proxy."];
|
|
384
|
+
for (const exe of [...chrome, ...edge]) lines.push(`if exist "${exe}" start "" "${exe}" ${flags} & goto :eof`);
|
|
385
|
+
lines.push('echo Could not find Chrome or Edge. Set your browser proxy to 127.0.0.1:' + port + ' (user lc-core).', "pause");
|
|
386
|
+
return lines.join("\r\n") + "\r\n";
|
|
387
|
+
}
|
|
388
|
+
function printProxyGuidance(state, shortcut) {
|
|
389
|
+
const url = proxyUrl(state), pac = proxyPacFile(), launcher = browserLauncherFile();
|
|
390
|
+
process.stdout.write("AI connection proxy is active (providers: " + state.providers.join(", ") + ").\n");
|
|
391
|
+
process.stdout.write("Loopback HTTP proxy: 127.0.0.1:" + state.port + " (no sign-in). Stable across restarts - set up once.\n\n");
|
|
392
|
+
if (shortcut) process.stdout.write("Browser: open \"" + BROWSER_SHORTCUT + "\" from the Start menu or your Desktop. It opens ChatGPT/Claude already routed - no password.\n\n");
|
|
393
|
+
else {
|
|
394
|
+
process.stdout.write("Browser: double-click this launcher (opens Chrome/Edge already routed - no password):\n");
|
|
395
|
+
process.stdout.write(" " + launcher + "\n\n");
|
|
396
|
+
}
|
|
397
|
+
process.stdout.write("CLIs (Claude Code, Codex, Antigravity, any tool that reads a proxy env var) - in the shell that runs the tool:\n");
|
|
398
|
+
process.stdout.write(" CMD: set HTTPS_PROXY=" + url + "\n");
|
|
399
|
+
process.stdout.write(" PowerShell: $env:HTTPS_PROXY=\"" + url + "\"\n");
|
|
400
|
+
process.stdout.write(" (also set HTTP_PROXY and ALL_PROXY to the same value for tools that read those)\n");
|
|
401
|
+
process.stdout.write(" Non-AI hosts are reached directly, so the one variable is safe for the whole session.\n\n");
|
|
402
|
+
process.stdout.write("Alternative browser setup (PAC): Settings > Network & Internet > Proxy > Use setup script =\n");
|
|
403
|
+
process.stdout.write(" file:///" + pac.replace(/\\/g, "/") + "\n");
|
|
404
|
+
}
|
|
405
|
+
async function proxyModeEnabled() {
|
|
406
|
+
try { return JSON.parse(await fs.readFile(proxyModeFile(), "utf8"))?.enabled === true; } catch { return false; }
|
|
407
|
+
}
|
|
408
|
+
// The running agent rewrites proxy.json every 15 s while it holds a lease and removes it on any
|
|
409
|
+
// failure, so a missing or expired file never means "refreshing": say which condition it is.
|
|
410
|
+
async function explainNoProxyLease(state) {
|
|
411
|
+
const current = await readCurrent().catch(() => null);
|
|
412
|
+
const versions = current && current.version !== VERSION
|
|
413
|
+
? "Installed agent: " + current.version + "; this command: " + VERSION + ". If the installed agent predates proxy mode, install this version: npx local-commander-agent@" + VERSION + " update\n" : "";
|
|
414
|
+
const record = await readProcessRecord();
|
|
415
|
+
if (!record || !pidAlive(record.pid)) return "The Local Commander agent is not running, so no AI connection lease can be obtained. Start it with: localcommander start\nIf it stops again, see why with: localcommander logs\n" + versions;
|
|
416
|
+
if (state?.stale) {
|
|
417
|
+
const age = typeof state.updatedAt === "number" ? " (last refreshed " + Math.max(0, Math.round((Date.now() - state.updatedAt) / 1000)) + " s ago)" : "";
|
|
418
|
+
return "The agent is running but has stopped refreshing the AI connection lease" + age + ". Check: localcommander logs\n" + versions;
|
|
419
|
+
}
|
|
420
|
+
return "The agent is running but has no AI connection lease. The server has not granted one to this computer yet. If this lasts more than a minute, check: localcommander logs\n" + versions;
|
|
421
|
+
}
|
|
422
|
+
async function networkProxy(sub) {
|
|
423
|
+
if (sub.length > 2) fail("ARGUMENT_INVALID");
|
|
424
|
+
const action = sub[0] || "status";
|
|
425
|
+
if (!["on", "off", "status"].includes(action)) fail("ARGUMENT_INVALID");
|
|
426
|
+
if (action === "status") {
|
|
427
|
+
const state = await readProxyState();
|
|
428
|
+
if (state && !state.stale) printProxyGuidance(state, (await findBrowser()) ? BROWSER_SHORTCUT : null);
|
|
429
|
+
else if (!state && !(await proxyModeEnabled())) process.stdout.write("AI connection proxy is not active. Enable it with: localcommander network proxy on\n");
|
|
430
|
+
else process.stdout.write("AI connection proxy is enabled but has no active lease.\n" + await explainNoProxyLease(state));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (action === "off") {
|
|
434
|
+
await writeJsonAtomic(proxyModeFile(), { enabled: false });
|
|
435
|
+
await restartIfRunning();
|
|
436
|
+
await fs.rm(proxyStateFile(), { force: true }).catch(() => {});
|
|
437
|
+
await fs.rm(proxyPacFile(), { force: true }).catch(() => {});
|
|
438
|
+
await fs.rm(browserLauncherFile(), { force: true }).catch(() => {});
|
|
439
|
+
try { removeBrowserShortcut(BROWSER_SHORTCUT); } catch { /* shortcut optional */ }
|
|
440
|
+
process.stdout.write("AI connection proxy disabled.\n");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const requested = sub[1] ? sub[1].split(",").map(p => p.trim().toLowerCase()) : Object.keys(PROXY_PROVIDERS);
|
|
444
|
+
const providers = requested.filter(p => Object.hasOwn(PROXY_PROVIDERS, p));
|
|
445
|
+
if (!providers.length) fail("ARGUMENT_INVALID");
|
|
446
|
+
await writeJsonAtomic(proxyModeFile(), { enabled: true, providers });
|
|
447
|
+
const record = await readProcessRecord();
|
|
448
|
+
if (record && pidAlive(record.pid)) await stopAgent();
|
|
449
|
+
// A forced stop skips the agent's cleanup; never mistake the old agent's state file for a new lease.
|
|
450
|
+
await fs.rm(proxyStateFile(), { force: true }).catch(() => {});
|
|
451
|
+
await startAgent();
|
|
452
|
+
// The agent reconnects and fetches a lease before it can publish the proxy coordinates.
|
|
453
|
+
let state = null;
|
|
454
|
+
for (let i = 0; i < 30 && !(state && !state.stale); i++) {
|
|
455
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
456
|
+
state = await readProxyState();
|
|
457
|
+
const running = await readProcessRecord();
|
|
458
|
+
if (!(state && !state.stale) && (!running || !pidAlive(running.pid))) break;
|
|
459
|
+
}
|
|
460
|
+
if (state && !state.stale) {
|
|
461
|
+
await fs.writeFile(proxyPacFile(), buildPac(state.providers, state.port), { mode: 0o600 });
|
|
462
|
+
await fs.writeFile(browserLauncherFile(), buildBrowserLauncher(state.port), { mode: 0o600 });
|
|
463
|
+
let shortcut = null;
|
|
464
|
+
try {
|
|
465
|
+
const exe = await findBrowser();
|
|
466
|
+
if (exe) shortcut = installBrowserShortcut(BROWSER_SHORTCUT, exe, `--proxy-server=http://127.0.0.1:${state.port} --user-data-dir="${browserProfileDir()}"`);
|
|
467
|
+
} catch { /* shortcut is a convenience; the launcher .cmd still works */ }
|
|
468
|
+
printProxyGuidance(state, shortcut);
|
|
469
|
+
} else process.stdout.write("AI connection proxy enabled, but no lease was obtained.\n" + await explainNoProxyLease(state));
|
|
470
|
+
}
|
|
471
|
+
async function approveWorkspace(runtimeDir) {
|
|
472
|
+
const result = execAgent(runtimeDir, ["approve-workspace"], 180_000);
|
|
473
|
+
if (result.status !== 0) fail(result.stderr.trim() || "WORKSPACE_APPROVAL_FAILED");
|
|
474
|
+
const answer = result.stdout.trim();
|
|
475
|
+
if (answer === "WORKSPACE_CANCELLED") return false;
|
|
476
|
+
if (answer !== "WORKSPACE_APPROVED") fail("WORKSPACE_APPROVAL_INVALID");
|
|
477
|
+
process.stdout.write("Workspace approved.\n");
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
// Owner-only local permission surface. These run the installed runtime's own
|
|
481
|
+
// commands on this PC; the cloud, the AI client and MCP calls cannot reach them.
|
|
482
|
+
const PERMISSION_COMMANDS = { "revoke-workspace": 1, "allow-write": 1, "deny-write": 1, "allow-command": 3, "deny-command": 2, "allow-localhost": 2, "deny-localhost": 2 };
|
|
483
|
+
async function listWorkspaces(runtimeDir) {
|
|
484
|
+
const result = execAgent(runtimeDir, ["list-workspaces"], 15_000);
|
|
485
|
+
if (result.status !== 0) fail(result.stderr.trim() || "WORKSPACE_LIST_FAILED");
|
|
486
|
+
try { process.stdout.write(JSON.stringify(JSON.parse(result.stdout.trim() || "[]"), null, 2) + "\n"); }
|
|
487
|
+
catch { fail("WORKSPACE_LIST_INVALID"); }
|
|
488
|
+
}
|
|
489
|
+
async function changePermission(runtimeDir, command, args) {
|
|
490
|
+
if (args.length !== PERMISSION_COMMANDS[command]) fail("ARGUMENT_INVALID");
|
|
491
|
+
// Covers the 60 s native owner review plus DPAPI/state work (see planning/OWNER_PERMISSIONS.md).
|
|
492
|
+
const result = execAgent(runtimeDir, [command, ...args], 90_000);
|
|
493
|
+
if (result.status !== 0) fail(result.stderr.trim() || "PERMISSION_UPDATE_FAILED");
|
|
494
|
+
const record = await readProcessRecord();
|
|
495
|
+
if (record && pidAlive(record.pid)) {
|
|
496
|
+
await stopAgent();
|
|
497
|
+
await startAgent();
|
|
498
|
+
process.stdout.write("Permission updated; Local Commander restarted.\n");
|
|
499
|
+
} else process.stdout.write("Permission updated.\n");
|
|
500
|
+
}
|
|
501
|
+
function pidAlive(pid) {
|
|
502
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
503
|
+
try { process.kill(pid, 0); return true; }
|
|
504
|
+
catch (error) { return error && error.code === "EPERM"; }
|
|
505
|
+
}
|
|
506
|
+
async function readProcessRecord() {
|
|
507
|
+
try {
|
|
508
|
+
const parsed = JSON.parse(await fs.readFile(processFile(), "utf8"));
|
|
509
|
+
if (!parsed || typeof parsed !== "object" || !Number.isInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.runtimeDir !== "string") return null;
|
|
510
|
+
const runtimeDir = path.resolve(parsed.runtimeDir);
|
|
511
|
+
if (!under(versionsDirectory(), runtimeDir)) return null;
|
|
512
|
+
return { pid: parsed.pid, runtimeDir, version: typeof parsed.version === "string" ? parsed.version : null, startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : null };
|
|
513
|
+
} catch { return null; }
|
|
514
|
+
}
|
|
515
|
+
async function statusObject() {
|
|
516
|
+
const current = await readCurrent();
|
|
517
|
+
const record = await readProcessRecord();
|
|
518
|
+
const running = !!record && pidAlive(record.pid);
|
|
519
|
+
if (record && !running) await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
520
|
+
const identity = current ? await identityStatus(current.runtimeDir).catch(() => ({ paired: false, accountEmail: null, deviceId: null })) : { paired: false, accountEmail: null, deviceId: null };
|
|
521
|
+
return {
|
|
522
|
+
installed: !!current,
|
|
523
|
+
version: current?.version ?? null,
|
|
524
|
+
running,
|
|
525
|
+
pid: running ? record.pid : null,
|
|
526
|
+
paired: identity.paired,
|
|
527
|
+
accountEmail: identity.accountEmail,
|
|
528
|
+
deviceId: identity.deviceId,
|
|
529
|
+
stateDirectory: stateDirectory()
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async function verifyOwnedProcess(record) {
|
|
533
|
+
if (!pidAlive(record.pid)) return false;
|
|
534
|
+
const script = '$p=Get-CimInstance Win32_Process -Filter "ProcessId = ' + record.pid + '"; if($null -eq $p){exit 3}; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Write-Output $p.ExecutablePath; Write-Output $p.CommandLine';
|
|
535
|
+
const result = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, encoding: "utf8", timeout: 10_000 });
|
|
536
|
+
if (result.status === 3) return false;
|
|
537
|
+
if (result.status !== 0) fail("PROCESS_IDENTITY_CHECK_FAILED");
|
|
538
|
+
const lines = String(result.stdout || "").split(/\r?\n/u).filter(Boolean);
|
|
539
|
+
const executable = path.resolve(lines.shift() || "");
|
|
540
|
+
const expectedNode = path.resolve(record.runtimeDir, "node.exe");
|
|
541
|
+
const commandLine = lines.join("\n").toLowerCase();
|
|
542
|
+
return executable.toLowerCase() === expectedNode.toLowerCase() && commandLine.includes(path.join(record.runtimeDir, "agent.mjs").toLowerCase());
|
|
543
|
+
}
|
|
544
|
+
async function stopAgent() {
|
|
545
|
+
const record = await readProcessRecord();
|
|
546
|
+
if (!record || !pidAlive(record.pid)) {
|
|
547
|
+
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (!(await verifyOwnedProcess(record))) fail("PROCESS_IDENTITY_MISMATCH");
|
|
551
|
+
const result = spawnSync("taskkill.exe", ["/PID", String(record.pid), "/T", "/F"], { windowsHide: true, encoding: "utf8", timeout: 15_000 });
|
|
552
|
+
if (result.status !== 0 && pidAlive(record.pid)) fail("AGENT_STOP_FAILED");
|
|
553
|
+
for (let i = 0; i < 20 && pidAlive(record.pid); i += 1) await new Promise(resolve => setTimeout(resolve, 100));
|
|
554
|
+
if (pidAlive(record.pid)) fail("AGENT_STOP_TIMEOUT");
|
|
555
|
+
await fs.rm(processFile(), { force: true }).catch(() => {});
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
async function startAgent() {
|
|
559
|
+
const current = await readCurrent();
|
|
560
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
561
|
+
const identity = await identityStatus(current.runtimeDir);
|
|
562
|
+
if (!identity.paired) fail("ENROLLMENT_MISSING");
|
|
563
|
+
const status = await statusObject();
|
|
564
|
+
if (status.running) return false;
|
|
565
|
+
const child = spawn(agentExe(current.runtimeDir), ["run"], { detached: true, windowsHide: true, stdio: "ignore", env: agentEnv() });
|
|
566
|
+
child.unref();
|
|
567
|
+
for (let i = 0; i < 40; i += 1) {
|
|
568
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
569
|
+
const record = await readProcessRecord();
|
|
570
|
+
if (record && pidAlive(record.pid)) return true;
|
|
571
|
+
}
|
|
572
|
+
fail("AGENT_START_TIMEOUT");
|
|
573
|
+
}
|
|
574
|
+
async function bootstrap({ chooseWorkspace = true } = {}) {
|
|
575
|
+
const previous = await readCurrent();
|
|
576
|
+
const target = path.join(versionsDirectory(), VERSION);
|
|
577
|
+
const running = await readProcessRecord();
|
|
578
|
+
if (running && pidAlive(running.pid) && path.resolve(running.runtimeDir).toLowerCase() !== path.resolve(target).toLowerCase()) await stopAgent();
|
|
579
|
+
const runtimeDir = await copyRuntime();
|
|
580
|
+
const identity = await identityStatus(runtimeDir);
|
|
581
|
+
if (!identity.paired) await pair(runtimeDir);
|
|
582
|
+
if (chooseWorkspace) {
|
|
583
|
+
const list = execAgent(runtimeDir, ["list-workspaces"], 15_000);
|
|
584
|
+
if (list.status !== 0) fail("WORKSPACE_LIST_FAILED");
|
|
585
|
+
let existing;
|
|
586
|
+
try { existing = JSON.parse(list.stdout); } catch { fail("WORKSPACE_LIST_INVALID"); }
|
|
587
|
+
if (!Array.isArray(existing)) fail("WORKSPACE_LIST_INVALID");
|
|
588
|
+
if (existing.length === 0 && !(await configureAccess(runtimeDir))) fail("ACCESS_SETUP_CANCELLED");
|
|
589
|
+
}
|
|
590
|
+
// Antivirus can remove or lock the network files; the verified bundle is then skipped, but the agent still updates and starts.
|
|
591
|
+
let networkCopied = false, networkManifest = null, networkError = null;
|
|
592
|
+
if (AI_CONNECTION_SHIPPED) try {
|
|
593
|
+
networkCopied = await copyNetworkBundle(path.join(runtimeSource(), "network"), path.join(runtimeDir, "network"));
|
|
594
|
+
if (networkCopied) networkManifest = (await inspectNetworkBundle(path.join(runtimeDir, "network")))?.manifest;
|
|
595
|
+
} catch (error) {
|
|
596
|
+
networkCopied = false;
|
|
597
|
+
networkError = error && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : error && /^E[A-Z]{2,20}$/u.test(error.code) ? error.code : "NETWORK_BUNDLE_UNREADABLE";
|
|
598
|
+
}
|
|
599
|
+
if (networkError) process.stdout.write("The AI connection files could not be installed (" + networkError + "). Antivirus may have removed or blocked them. Local Commander starts without the AI connection.\n");
|
|
600
|
+
else if (networkCopied && implicitSetupAllowed(networkManifest)) {
|
|
601
|
+
const network = execAgent(runtimeDir, ["network-setup"], 330_000);
|
|
602
|
+
if (network.status !== 0) process.stdout.write("AI network setup was not completed. Local Commander will start without changing your network. Use localcommander network setup to retry.\n");
|
|
603
|
+
} else if (networkCopied) process.stdout.write("This build includes the AI connection pilot. It changes nothing until you run: localcommander network setup\n");
|
|
604
|
+
registerAutostart(runtimeDir);
|
|
605
|
+
await startAgent();
|
|
606
|
+
const commandBin = await installCommandFiles(PACKAGE_ROOT, appDataRoot(), VERSION);
|
|
607
|
+
updateUserPath(commandBin);
|
|
608
|
+
// The Start menu entry is a convenience: a conflicting user shortcut or shell failure must not undo a working installation.
|
|
609
|
+
let shortcut = null, shortcutError = null;
|
|
610
|
+
try { shortcut = installStartMenuShortcut(commandBin); } catch (error) { shortcutError = error && typeof error.message === "string" ? error.message : "SHORTCUT_UPDATE_FAILED"; }
|
|
611
|
+
if (previous && previous.version !== VERSION) process.stdout.write("Updated Local Commander from " + previous.version + " to " + VERSION + ".\n");
|
|
612
|
+
process.stdout.write("Local Commander " + VERSION + " is installed and running.\n");
|
|
613
|
+
process.stdout.write("The localcommander command is on your user PATH. Open a new CMD or terminal window to use it.\n");
|
|
614
|
+
process.stdout.write("Watch connections and tasks with: localcommander monitor --watch\n");
|
|
615
|
+
if (shortcut) process.stdout.write('Start menu: "' + shortcut + '" opens the same live view without a terminal.\n');
|
|
616
|
+
else process.stdout.write("The Start menu shortcut was not created (" + shortcutError + "); the localcommander command still works.\n");
|
|
617
|
+
}
|
|
618
|
+
async function uninstall(purgeState) {
|
|
619
|
+
await stopAgent();
|
|
620
|
+
const current = await readCurrent();
|
|
621
|
+
if (current) {
|
|
622
|
+
const network = execAgent(current.runtimeDir, ["network-uninstall"], 330_000);
|
|
623
|
+
if (network.status !== 0) process.stdout.write("The network service could not be removed automatically. The agent is stopped, so its VPN authorization cannot renew. Remove LocalCommanderNetwork through the approved administrator workflow.\n");
|
|
624
|
+
}
|
|
625
|
+
removeAutostart();
|
|
626
|
+
try { removeStartMenuShortcut(); } catch (error) { process.stdout.write("The Start menu shortcut was not removed (" + (error?.message || "SHORTCUT_UPDATE_FAILED") + ").\n"); }
|
|
627
|
+
updateUserPath(path.join(appDataRoot(), "bin"), true);
|
|
628
|
+
await removeCommandFile(appDataRoot());
|
|
629
|
+
const versions = versionsDirectory();
|
|
630
|
+
if (under(appDataRoot(), versions)) await fs.rm(versions, { recursive: true, force: true });
|
|
631
|
+
await fs.rm(currentPath(), { force: true });
|
|
632
|
+
if (purgeState) {
|
|
633
|
+
const state = stateDirectory();
|
|
634
|
+
const expected = path.join(appDataRoot(), "Agent");
|
|
635
|
+
if (path.resolve(state).toLowerCase() !== path.resolve(expected).toLowerCase()) fail("CUSTOM_STATE_PURGE_REFUSED");
|
|
636
|
+
await fs.rm(state, { recursive: true, force: true });
|
|
637
|
+
}
|
|
638
|
+
process.stdout.write(purgeState ? "Local Commander removed, including local enrollment state.\n" : "Local Commander removed. Protected enrollment state was preserved.\n");
|
|
639
|
+
}
|
|
640
|
+
function allowCommandHelp() {
|
|
641
|
+
return [
|
|
642
|
+
"Usage: npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
643
|
+
"Exactly three arguments are required. Quote executable paths containing spaces.",
|
|
644
|
+
"Find the workspace ID with: npx local-commander-agent@latest workspaces",
|
|
645
|
+
"",
|
|
646
|
+
"CMD: find Git with where git.exe, then copy the intended full path:",
|
|
647
|
+
' npx local-commander-agent@latest allow-command WORKSPACE_ID git "C:\\Path To Git\\git.exe"',
|
|
648
|
+
"Replace WORKSPACE_ID and the example path with your actual values.",
|
|
649
|
+
"",
|
|
650
|
+
"PowerShell only:",
|
|
651
|
+
" $gitExe = @(Get-Command git.exe -CommandType Application -ErrorAction Stop)[0].Source",
|
|
652
|
+
' npx.cmd local-commander-agent@latest allow-command WORKSPACE_ID git "$gitExe"',
|
|
653
|
+
"CMD does not evaluate (Get-Command ...).Source. Local Commander never evaluates shell expressions.",
|
|
654
|
+
"A valid command still requires local owner approval.",
|
|
655
|
+
""
|
|
656
|
+
].join("\n");
|
|
657
|
+
}
|
|
658
|
+
// #104 slice 2: direct local MCP for AI clients on this computer, with no cloud round trip per call.
|
|
659
|
+
// The owner's opt-in lives outside the agent state directory; the agent re-reads it on every call.
|
|
660
|
+
function localMcpDirectory() { return path.join(appDataRoot(), "LocalMcp"); }
|
|
661
|
+
const LOCAL_MCP_CLIENTS = [
|
|
662
|
+
" Claude Code: claude mcp add local-commander -- cmd /c localcommander mcp",
|
|
663
|
+
" Codex: ~/.codex/config.toml -> [mcp_servers.local-commander] command = \"cmd\", args = [\"/c\", \"localcommander\", \"mcp\"]",
|
|
664
|
+
" Other clients: { \"command\": \"cmd\", \"args\": [\"/c\", \"localcommander\", \"mcp\"] }"
|
|
665
|
+
];
|
|
666
|
+
async function localMcpCommand(sub) {
|
|
667
|
+
if (sub.length > 1) fail("ARGUMENT_INVALID");
|
|
668
|
+
const action = sub[0], settings = path.join(localMcpDirectory(), "settings.json");
|
|
669
|
+
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
670
|
+
if (action === undefined) {
|
|
671
|
+
// Stdio passes straight through: the AI client speaks MCP to the agent runtime, never to this launcher.
|
|
672
|
+
const child = spawn(path.join(current.runtimeDir, "node.exe"), [path.join(current.runtimeDir, "agent.mjs"), "mcp"], { stdio: "inherit", windowsHide: true, env: agentEnv() });
|
|
673
|
+
await new Promise(resolve => {
|
|
674
|
+
child.once("exit", code => { process.exitCode = code ?? 1; resolve(); });
|
|
675
|
+
child.once("error", () => { process.exitCode = 1; resolve(); });
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (action === "on" || action === "off") {
|
|
680
|
+
await writeJsonAtomic(settings, { enabled: action === "on", updatedAt: new Date().toISOString() });
|
|
681
|
+
await restartIfRunning();
|
|
682
|
+
process.stdout.write(action === "on" ? [
|
|
683
|
+
"Direct local MCP is ON. AI clients on this computer can use your approved workspaces without the cloud.",
|
|
684
|
+
"Add Local Commander to a client's MCP settings:", ...LOCAL_MCP_CLIENTS,
|
|
685
|
+
"Turn it off at any time with: localcommander mcp off", ""].join("\n")
|
|
686
|
+
: "Direct local MCP is OFF. AI clients on this computer can no longer use Local Commander directly.\n");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (action === "status") {
|
|
690
|
+
let enabled = false, endpoint = null;
|
|
691
|
+
try { enabled = JSON.parse(await fs.readFile(settings, "utf8"))?.enabled === true; } catch {}
|
|
692
|
+
try { endpoint = JSON.parse(await fs.readFile(path.join(localMcpDirectory(), "endpoint.json"), "utf8")); } catch {}
|
|
693
|
+
const serving = enabled && Number.isSafeInteger(endpoint?.pid) && pidAlive(endpoint.pid);
|
|
694
|
+
process.stdout.write([
|
|
695
|
+
"Direct local MCP: " + (enabled ? "ON" : "OFF") + (enabled ? (serving ? " (agent is serving on 127.0.0.1)" : " (agent not serving: run localcommander restart)") : ""),
|
|
696
|
+
...(enabled ? ["MCP settings for AI clients on this computer:", ...LOCAL_MCP_CLIENTS] : ["Turn it on with: localcommander mcp on"]), ""].join("\n"));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
fail("ARGUMENT_INVALID");
|
|
700
|
+
}
|
|
701
|
+
function help() {
|
|
702
|
+
return [
|
|
703
|
+
"Local Commander agent " + VERSION,
|
|
704
|
+
"",
|
|
705
|
+
"Usage:",
|
|
706
|
+
" npx local-commander-agent@latest install/pair/start",
|
|
707
|
+
" npx local-commander-agent@latest status show local state",
|
|
708
|
+
" localcommander monitor --watch live connections and tasks (also: Start menu > Local Commander Monitor)",
|
|
709
|
+
" localcommander monitor --json monitoring snapshot as JSON",
|
|
710
|
+
" localcommander logs --follow recent production relay events",
|
|
711
|
+
" npx local-commander-agent@latest start start background agent",
|
|
712
|
+
" npx local-commander-agent@latest stop stop background agent",
|
|
713
|
+
" npx local-commander-agent@latest restart restart background agent",
|
|
714
|
+
" npx local-commander-agent@latest update install this package version and restart",
|
|
715
|
+
" npx local-commander-agent@latest configure-access choose all connected drives or selected folders",
|
|
716
|
+
" npx local-commander-agent@latest approve-workspace add another read/write folder",
|
|
717
|
+
" npx local-commander-agent@latest revoke-workspace <workspaceGrantId>",
|
|
718
|
+
" npx local-commander-agent@latest workspaces list approved workspaces and permissions",
|
|
719
|
+
...(AI_CONNECTION_SHIPPED ? [
|
|
720
|
+
" localcommander network [status|auto|on|off|setup|panel] owner-local AI connection controls",
|
|
721
|
+
" localcommander network proxy [on|off|status] [providers] driver-free AI proxy for browser + CLI (no TUN)"
|
|
722
|
+
] : []),
|
|
723
|
+
" localcommander mcp on|off|status direct local MCP for AI clients on this computer (no cloud per call)",
|
|
724
|
+
" localcommander mcp stdio MCP server; start it from an AI client's MCP settings",
|
|
725
|
+
" localcommander audit [status|on|off|copy-key] owner-local encrypted content audit (pilot)",
|
|
726
|
+
" localcommander audit restore <file> <output> decrypt with your key in LCMCP_AUDIT_RECOVERY_KEY",
|
|
727
|
+
" localcommander fabric-owner owner mailbox request through private stdin",
|
|
728
|
+
" localcommander fabric-worker restricted worker request through private stdin",
|
|
729
|
+
" npx local-commander-agent@latest allow-write <workspaceGrantId>",
|
|
730
|
+
" npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
731
|
+
" npx local-commander-agent@latest allow-command --help CMD and PowerShell examples",
|
|
732
|
+
" npx local-commander-agent@latest allow-localhost <workspaceGrantId> <port>",
|
|
733
|
+
" npx local-commander-agent@latest deny-write|deny-command|deny-localhost ...",
|
|
734
|
+
" npx local-commander-agent@latest uninstall [--purge-state]",
|
|
735
|
+
""
|
|
736
|
+
].join("\n");
|
|
737
|
+
}
|
|
738
|
+
async function main() {
|
|
739
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u.test(VERSION)) fail("PACKAGE_VERSION_INVALID");
|
|
740
|
+
// macOS and Linux run the per-platform runtime package through its own lifecycle (#253).
|
|
741
|
+
if (process.platform !== "win32") return (await import("./posix.mjs")).runPosix(process.argv.slice(2), VERSION);
|
|
742
|
+
requireWindows();
|
|
743
|
+
const command = process.argv[2] || "bootstrap";
|
|
744
|
+
const args = process.argv.slice(3);
|
|
745
|
+
if (command === "-h" || command === "--help" || command === "help") {
|
|
746
|
+
process.stdout.write(help());
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (command === "allow-command") {
|
|
750
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
|
|
751
|
+
process.stdout.write(allowCommandHelp());
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// Reject shell fragments before reading installed state or opening owner consent.
|
|
755
|
+
if (args.length !== PERMISSION_COMMANDS[command] || !path.win32.isAbsolute(args[2])) fail("ARGUMENT_INVALID");
|
|
756
|
+
}
|
|
757
|
+
if (command === "bootstrap" || command === "install") {
|
|
758
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
759
|
+
await bootstrap();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (command === "update") {
|
|
763
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
764
|
+
await bootstrap({ chooseWorkspace: false });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (command === "start") {
|
|
768
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
769
|
+
await startAgent();
|
|
770
|
+
process.stdout.write("Local Commander is running.\n");
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (command === "stop") {
|
|
774
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
775
|
+
const stopped = await stopAgent();
|
|
776
|
+
process.stdout.write(stopped ? "Local Commander stopped.\n" : "Local Commander was not running.\n");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (command === "restart") {
|
|
780
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
781
|
+
await stopAgent();
|
|
782
|
+
await startAgent();
|
|
783
|
+
process.stdout.write("Local Commander restarted.\n");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (command === "status") {
|
|
787
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
788
|
+
process.stdout.write(JSON.stringify(await statusObject(), null, 2) + "\n");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (command === "monitor" || command === "logs") {
|
|
792
|
+
await runMonitor(path.join(appDataRoot(), "Monitor"), command, args);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (command === "network") {
|
|
796
|
+
// While the AI connection is not shipped, only checking it and switching it off stay available (for PCs that enabled the pilot).
|
|
797
|
+
if (!AI_CONNECTION_SHIPPED && !(args.length === 0 || (args.length === 1 && ["status", "off"].includes(args[0])) || (args.length === 2 && args[0] === "proxy" && args[1] === "off"))) fail("AI_CONNECTION_UNAVAILABLE");
|
|
798
|
+
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
799
|
+
if (args[0] === "proxy") { await networkProxy(args.slice(1)); return; }
|
|
800
|
+
if (args.length > 1 || !["status", "auto", "on", "off", "setup", "panel"].includes(args[0] || "status")) fail("ARGUMENT_INVALID");
|
|
801
|
+
const action = args[0] || "status";
|
|
802
|
+
const childArgs = ["auto", "on", "off"].includes(action) ? ["network-mode", action] : ["network-" + action];
|
|
803
|
+
const result = execAgent(current.runtimeDir, childArgs, action === "setup" ? 330_000 : 15_000);
|
|
804
|
+
if (result.status !== 0) fail(result.stderr.trim() || "NETWORK_ACTION_FAILED");
|
|
805
|
+
process.stdout.write(result.stdout);
|
|
806
|
+
if (action === "setup") await restartIfRunning();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (command === "screenshot") {
|
|
810
|
+
await screenshotCommand(args);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (command === "audit") {
|
|
814
|
+
const current = await readCurrent(); if (!current) fail("AGENT_NOT_INSTALLED");
|
|
815
|
+
await auditCommand(current.runtimeDir, args);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (command === "mcp") {
|
|
819
|
+
await localMcpCommand(args);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (command === "fabric-owner" || command === "fabric-worker") {
|
|
823
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
824
|
+
const current = await readCurrent();
|
|
825
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
826
|
+
await fabricCommand(current.runtimeDir, command);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (command === "configure-access") {
|
|
830
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
831
|
+
const current = await readCurrent();
|
|
832
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
833
|
+
if (await configureAccess(current.runtimeDir)) {
|
|
834
|
+
await restartIfRunning();
|
|
835
|
+
process.stdout.write("Filesystem access configured.\n");
|
|
836
|
+
} else process.stdout.write("Access selection cancelled; existing permissions were preserved.\n");
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (command === "approve-workspace") {
|
|
840
|
+
if (args.length) fail("ARGUMENT_INVALID");
|
|
841
|
+
const current = await readCurrent();
|
|
842
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
843
|
+
if (await approveWorkspace(current.runtimeDir)) await restartIfRunning();
|
|
844
|
+
else process.stdout.write("Workspace selection cancelled.\n");
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (command === "workspaces" || Object.hasOwn(PERMISSION_COMMANDS, command)) {
|
|
848
|
+
if (command === "workspaces" && args.length) fail("ARGUMENT_INVALID");
|
|
849
|
+
const current = await readCurrent();
|
|
850
|
+
if (!current) fail("AGENT_NOT_INSTALLED");
|
|
851
|
+
if (command === "workspaces") await listWorkspaces(current.runtimeDir);
|
|
852
|
+
else await changePermission(current.runtimeDir, command, args);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (command === "uninstall") {
|
|
856
|
+
if (args.length > 1 || (args.length === 1 && args[0] !== "--purge-state")) fail("ARGUMENT_INVALID");
|
|
857
|
+
await uninstall(args[0] === "--purge-state");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
fail("ARGUMENT_INVALID");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
void main().catch(error => {
|
|
864
|
+
const code = error && typeof error.message === "string" && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : "LOCAL_COMMANDER_FAILED";
|
|
865
|
+
// Unlabelled failures show only the system error class and operation (e.g. EPERM copyfile), never paths or messages.
|
|
866
|
+
const detail = code === "LOCAL_COMMANDER_FAILED" && error && typeof error.code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/u.test(error.code)
|
|
867
|
+
? " (" + error.code + (typeof error.syscall === "string" && /^[a-z_]{1,20}$/u.test(error.syscall) ? " " + error.syscall : "") + ")" : "";
|
|
868
|
+
process.stderr.write(code + detail + "\n");
|
|
869
|
+
if (code === "ARGUMENT_INVALID" && process.argv[2] === "allow-command") process.stderr.write(allowCommandHelp());
|
|
870
|
+
if (code === "AI_CONNECTION_UNAVAILABLE") process.stderr.write("The AI connection is not available in this version of Local Commander.\n");
|
|
871
|
+
process.exitCode = 1;
|
|
872
|
+
});
|