device-center 0.2.0-beta.4 → 0.2.0-beta.5

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.
@@ -3,25 +3,29 @@
3
3
  // Default command is a plan; importing this module has no side effects.
4
4
  import { mkdir, writeFile, unlink, lstat } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
- import { dirname, join, resolve } from 'node:path';
6
+ import { dirname, join, resolve, win32 } from 'node:path';
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
8
  import { spawnSync } from 'node:child_process';
9
+ import { defaultDataDirectory, ensurePrivateDirectory, writePrivateJson, readPrivateJson } from '../server/drivers/privateFiles.mjs';
10
+ import { windowsServiceDefinition, windowsTask } from '../server/drivers/windowsService.mjs';
9
11
 
10
12
  const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
11
13
  const LABEL = 'local.device-center.control-plane';
12
14
  const xml = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c]);
13
15
  const unitQuote = (v) => `"${String(v).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%')}"`;
14
16
 
15
- export function servicePlan({ platform = process.platform, home = homedir(), root = ROOT, node = process.execPath, port = 5173 } = {}) {
17
+ export function servicePlan({ platform = process.platform, home = homedir(), root = ROOT, node = process.execPath, port = 5173, environment = process.env } = {}) {
16
18
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Choose a port from 1024 to 65535.');
17
19
  if (![home, root, node].every((s) => typeof s === 'string' && !/[\r\n\0]/.test(s))) throw new Error('Invalid path.');
18
- const supported = ['darwin', 'linux'].includes(platform);
19
- const dataDirectory = platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : join(home, '.local', 'share', 'device-center');
20
- const dbPath = join(dataDirectory, 'device-center.sqlite');
21
- const script = join(root, 'server/index.mjs');
22
- const manager = platform === 'darwin' ? 'launchd' : platform === 'linux' ? 'systemd' : 'unsupported';
23
- const configPath = platform === 'darwin' ? join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`) : join(home, '.config', 'systemd', 'user', 'device-center.service');
20
+ const supported = ['darwin', 'linux', 'win32'].includes(platform);
21
+ const dataDirectory = defaultDataDirectory(home, platform);
22
+ const pathJoin = platform === 'win32' ? win32.join : join;
23
+ const dbPath = pathJoin(dataDirectory, 'device-center.sqlite');
24
+ const script = pathJoin(root, 'server', 'index.mjs');
25
+ const manager = platform === 'darwin' ? 'launchd' : platform === 'linux' ? 'systemd' : platform === 'win32' ? 'windows-task' : 'unsupported';
26
+ const configPath = platform === 'darwin' ? join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`) : platform === 'win32' ? pathJoin(dataDirectory, 'service.json') : join(home, '.config', 'systemd', 'user', 'device-center.service');
24
27
  let configuration = null;
28
+ let task = null;
25
29
  if (platform === 'darwin') {
26
30
  configuration = `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>
27
31
  <key>Label</key><string>${LABEL}</string>
@@ -34,8 +38,11 @@ export function servicePlan({ platform = process.platform, home = homedir(), roo
34
38
  </dict></plist>\n`;
35
39
  } else if (platform === 'linux') {
36
40
  configuration = `[Unit]\nDescription=Device Center local read-only connector\n[Service]\nType=simple\nWorkingDirectory=${unitQuote(root)}\nExecStart=${unitQuote(node)} ${unitQuote(script)}\nEnvironment="PORT=${port}"\nEnvironment=${unitQuote(`DEVICE_CENTER_DB_PATH=${dbPath}`)}\nEnvironment="DEVICE_CENTER_SERVICE_MANAGER=systemd"\nRestart=on-failure\nRestartSec=10\nUMask=0077\nNoNewPrivileges=true\n[Install]\nWantedBy=default.target\n`;
41
+ } else if (platform === 'win32') {
42
+ task = windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath: pathJoin(dataDirectory, 'service.log'), errorLogPath: pathJoin(dataDirectory, 'service-error.log'), environment });
43
+ configuration = JSON.stringify(task);
37
44
  }
38
- return { supported, manager, label: LABEL, configPath: supported ? configPath : null, dataDirectory, dbPath, url: `http://127.0.0.1:${port}`, node, root, port, configuration, scope: 'current user; starts at login; not a resource Agent', effects: ['Register a user-level background service', 'Create a private local data directory and logs', 'Read-only local MCP; no keys, host access, remote tasks, or Codex config changes'] };
45
+ return { supported, manager, label: LABEL, configPath: supported ? configPath : null, dataDirectory, dbPath, url: `http://127.0.0.1:${port}`, node, root, port, configuration, ...(task ? { task } : {}), scope: 'current user; starts at login; no reports while logged out, sleeping or offline', effects: ['Register a user-level background service', 'Create a private local data directory and logs', 'Read-only local MCP; no new keys, host access, remote tasks, or AI client config changes'] };
39
46
  }
40
47
  function run(command, args) {
41
48
  const child = spawnSync(command, args, { encoding: 'utf8', timeout: 10_000 });
@@ -44,21 +51,28 @@ function run(command, args) {
44
51
  }
45
52
  async function exists(path) { try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
46
53
  async function install(plan) {
47
- if (!plan.supported) throw new Error('Windows user service installer is not implemented. Use foreground mode or the optional local Docker recipe.');
54
+ if (!plan.supported) throw new Error('Unsupported user service platform.');
48
55
  if (await exists(plan.configPath)) throw new Error('The service config already exists. Inspect it first; this installer will not overwrite it.');
56
+ if (plan.task && windowsTask('status', plan.task).exists) throw new Error('An existing Windows task was preserved. Inspect it before installing.');
49
57
  // Refuse to replace an existing foreground listener or another application.
50
58
  try {
51
59
  await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(1500) });
52
60
  throw new Error('The chosen port is already responding. Stop the foreground service yourself or choose another --port.');
53
61
  } catch (e) { if (e.message.startsWith('The chosen port')) throw e; }
54
- await mkdir(plan.dataDirectory, { recursive: true, mode: 0o700 });
62
+ ensurePrivateDirectory(plan.dataDirectory);
55
63
  await mkdir(dirname(plan.configPath), { recursive: true });
56
- await writeFile(plan.configPath, plan.configuration, { flag: 'wx', mode: 0o600 });
64
+ if (plan.task) writePrivateJson(plan.configPath, plan.task, { create: true });
65
+ else await writeFile(plan.configPath, plan.configuration, { flag: 'wx', mode: 0o600 });
57
66
  if (plan.manager === 'launchd') run('launchctl', ['bootstrap', `gui/${process.getuid()}`, plan.configPath]);
67
+ else if (plan.task) windowsTask('install', plan.task);
58
68
  else { run('systemctl', ['--user', 'daemon-reload']); run('systemctl', ['--user', 'enable', '--now', 'device-center.service']); }
59
69
  let healthy = false;
60
- for (let attempt = 0; attempt < 12; attempt++) {
61
- try { healthy = (await (await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(1000) })).json()).ok === true; } catch { /* wait for manager startup */ }
70
+ for (let attempt = 0; attempt < (plan.task ? 40 : 12); attempt++) {
71
+ try {
72
+ const health = await (await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(1000) })).json();
73
+ const status = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(1000) })).json();
74
+ healthy = health.ok === true && health.mode === 'local' && status.service?.persistence === plan.manager;
75
+ } catch { /* wait for manager startup */ }
62
76
  if (healthy) break;
63
77
  await new Promise((done) => setTimeout(done, 250));
64
78
  }
@@ -69,8 +83,20 @@ async function uninstall(plan) {
69
83
  if (!plan.supported || !await exists(plan.configPath)) throw new Error('No supported user service config exists.');
70
84
  // Only remove the exact config generated for this stable repository/runtime.
71
85
  const { readFile } = await import('node:fs/promises');
72
- if (await readFile(plan.configPath, 'utf8') !== plan.configuration) throw new Error('Service config differs from this plan. Inspect it manually; no overwrite or removal.');
86
+ const configuration = plan.task ? JSON.stringify(readPrivateJson(plan.configPath, null)) : await readFile(plan.configPath, 'utf8');
87
+ if (configuration !== plan.configuration) throw new Error('Service config differs from this plan. Inspect it manually; no overwrite or removal.');
73
88
  if (plan.manager === 'launchd') run('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`]);
89
+ else if (plan.task) {
90
+ windowsTask('stop', plan.task);
91
+ let stopped = false;
92
+ for (let attempt = 0; attempt < 20; attempt++) {
93
+ try { await fetch(`${plan.url}/api/health`, { signal: AbortSignal.timeout(500) }); }
94
+ catch { stopped = true; break; }
95
+ await new Promise(done => setTimeout(done, 250));
96
+ }
97
+ if (!stopped) throw new Error('Task stop did not stop the listener. Config and task retained; inspect Task Scheduler. No unrelated process was killed.');
98
+ windowsTask('remove', plan.task);
99
+ }
74
100
  else { run('systemctl', ['--user', 'disable', '--now', 'device-center.service']); }
75
101
  await unlink(plan.configPath);
76
102
  if (plan.manager === 'systemd') run('systemctl', ['--user', 'daemon-reload']);
@@ -92,7 +118,7 @@ export async function main(argv = process.argv.slice(2)) {
92
118
  if (command === 'status') {
93
119
  let health = null;
94
120
  try { health = await (await fetch(`${plan.url}/api/connector/status`, { signal: AbortSignal.timeout(1500) })).json(); } catch { /* stopped */ }
95
- return { configExists: plan.supported && await exists(plan.configPath), status: health, url: plan.url, note: 'A present config does not prove a running service. The HTTP response identifies foreground vs managed runtime.' };
121
+ return { configExists: plan.supported && await exists(plan.configPath), ...(plan.task ? { task: windowsTask('status', plan.task) } : {}), status: health, url: plan.url, note: 'A present config does not prove a running service. The HTTP response identifies foreground vs managed runtime.' };
96
122
  }
97
123
  if (!confirmed) throw new Error('No system change made. Inspect the plan, then explicitly pass --confirm to install or uninstall the user service.');
98
124
  return command === 'install' ? install(plan) : uninstall(plan);
@@ -1,21 +1,15 @@
1
- import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, readSync, writeSync } from 'node:fs';
1
+ import { constants, openSync, closeSync, fstatSync, lstatSync, realpathSync, readSync, writeSync } from 'node:fs';
2
2
  import { dirname, isAbsolute, resolve, join } from 'node:path';
3
3
  import { isIP } from 'node:net';
4
4
  import { publicIdentity, object, fail } from './driverProtocol.mjs';
5
5
  import { validateCertificate } from './certificates.mjs';
6
- import { ensurePrivateDirectory, writeLink } from './cloudLink.mjs';
6
+ import { ensurePrivateDirectory, readPrivateJson, writePrivateJson, assertPrivateFile, secureCreatedPrivateFile } from './privateFiles.mjs';
7
7
 
8
8
  export const accessFile = linkFile => join(dirname(linkFile), 'access.json');
9
9
  export const validFingerprint = fp => typeof fp === 'string' && /^[a-f0-9]{64}$/.test(fp);
10
10
  export function privateJson(file, fallback) {
11
- let fd;
12
- try {
13
- fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
14
- const s = fstatSync(fd);
15
- if (!s.isFile() || s.nlink !== 1 || s.size > 65536 || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid())) fail('access_private_file_required');
16
- return JSON.parse(readFileSync(fd, 'utf8'));
17
- } catch(e) { if(e.code === 'ENOENT') return fallback; throw e; }
18
- finally { if(fd !== undefined) closeSync(fd); }
11
+ try { return readPrivateJson(file, fallback); }
12
+ catch (error) { if (error.message === 'driver_private_file_required') fail('access_private_file_required'); throw error; }
19
13
  }
20
14
  export function readAccess(file) {
21
15
  const value = privateJson(file, { trusts: [], grants: [], listen: null });
@@ -36,6 +30,7 @@ export function lanEndpoint(value) {
36
30
  }
37
31
  export function safeLogPath(path) {
38
32
  if (typeof path !== 'string' || !isAbsolute(path) || path.length > 4096) fail('access_absolute_log_required');
33
+ if (process.platform === 'win32' && (!/^[A-Za-z]:\\/.test(path) || /[<>:"|?*]/.test(path.slice(2)))) fail('access_local_drive_log_required');
39
34
  const canonical = realpathSync(path);
40
35
  checkLog(canonical); return canonical;
41
36
  }
@@ -52,10 +47,16 @@ export function peerFingerprint(socket) {
52
47
  }
53
48
  export function audit(file, entry) {
54
49
  ensurePrivateDirectory(dirname(file));
55
- const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
50
+ let fd;
51
+ try { fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0)); }
52
+ catch (error) {
53
+ if (error.code !== 'ENOENT') throw error;
54
+ fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
55
+ try { secureCreatedPrivateFile(file); } catch (error) { closeSync(fd); throw error; }
56
+ }
56
57
  try {
57
58
  const s = fstatSync(fd);
58
- if (!s.isFile() || s.nlink !== 1 || s.mode & 0o077 || s.size > 1024 ** 2 || process.getuid && s.uid !== process.getuid()) fail('access_audit_unavailable');
59
+ try { assertPrivateFile(file, s, 1024 ** 2); } catch { fail('access_audit_unavailable'); }
59
60
  writeSync(fd, JSON.stringify({ ...entry, at: Date.now() }) + '\n');
60
61
  } finally { closeSync(fd); }
61
62
  }
@@ -69,7 +70,7 @@ export function readGrantedLog({ file, fingerprint, request, route, now = Date.n
69
70
  let fd;
70
71
  try {
71
72
  checkLog(grant.path);
72
- fd = openSync(grant.path, constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ fd = openSync(grant.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
73
74
  const s = fstatSync(fd), current = lstatSync(grant.path);
74
75
  if (!s.isFile() || s.nlink !== 1 || s.ino !== current.ino || s.dev !== current.dev || realpathSync(grant.path) !== grant.path) fail('access_log_changed');
75
76
  const data = Buffer.alloc(Math.min(request.bytes, s.size));
@@ -81,4 +82,4 @@ export function readGrantedLog({ file, fingerprint, request, route, now = Date.n
81
82
  } catch(error) { audit(auditFile, { fingerprint, label: request.label, route, outcome: 'denied' }); throw error; }
82
83
  finally { if(fd !== undefined) closeSync(fd); }
83
84
  }
84
- export const writeAccess = (file,value) => writeLink(file,value);
85
+ export const writeAccess = (file,value) => writePrivateJson(file,value);
@@ -1,5 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { cloudOrigin } from './driverProtocol.mjs';
3
+ import { powershellArguments, powershellPath } from './windowsSecurity.mjs';
4
+
5
+ export const windowsBrowserSource = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)\n$ErrorActionPreference = 'Stop'\n$url = [Console]::In.ReadToEnd()\nStart-Process -FilePath $url | Out-Null\n`;
3
6
 
4
7
  export function pairingBrowserUrl(origin, pending) {
5
8
  cloudOrigin(origin);
@@ -10,15 +13,17 @@ export function pairingBrowserUrl(origin, pending) {
10
13
  return url.href;
11
14
  }
12
15
 
13
- export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
16
+ export async function openPairingBrowser(url, { platform = process.platform, spawnImpl = spawn, environment = process.env } = {}) {
14
17
  const target = new URL(url);
15
18
  cloudOrigin(target.origin);
16
19
  if (target.pathname !== '/pair' || target.search || !/^#ticket=[a-f0-9]{64}&expires=\d+$/.test(target.hash)) throw new Error('无效的浏览器交接。');
17
- const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : null;
20
+ const windows = platform === 'win32';
21
+ const command = platform === 'darwin' ? '/usr/bin/open' : platform === 'linux' ? 'xdg-open' : windows ? powershellPath(environment) : null;
18
22
  if (!command) throw new Error('此系统尚不支持浏览器接入。');
19
23
  // No shell, URL echo or opener output in the Agent transcript.
20
24
  await new Promise((resolve, reject) => {
21
- const child = spawnImpl(command, [target.href], { stdio: 'ignore', shell: false });
25
+ const child = spawnImpl(command, windows ? powershellArguments(windowsBrowserSource) : [target.href], windows ? { stdio: ['pipe', 'ignore', 'ignore'], shell: false, windowsHide: true } : { stdio: 'ignore', shell: false });
26
+ if (windows) { child.stdin.on('error', () => {}); child.stdin.end(target.href); }
22
27
  const timer = setTimeout(() => { child.kill(); reject(new Error('无法打开本机浏览器。请在本机终端执行不带 --browser 的 connect 手动配对;不会自动输出凭据。')); }, 10_000);
23
28
  child.once('error', () => { clearTimeout(timer); reject(new Error('无法打开本机浏览器,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
24
29
  child.once('close', code => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error('浏览器打开失败,请在本机终端使用不带 --browser 的 connect 手动配对。')); });
@@ -1,38 +1,37 @@
1
- import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
2
1
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
- import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto';
2
+ import { join } from 'node:path';
3
+ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto';
5
4
  import { cloudOrigin, signedRequest, publicIdentity } from './driverProtocol.mjs';
6
5
  import { observeLocalDriver } from './local.mjs';
7
6
  import { validateCertificate } from './certificates.mjs';
7
+ import { readPrivateJson, writePrivateJson, defaultDataDirectory } from './privateFiles.mjs';
8
+ import { protectWindowsIdentity, unprotectWindowsIdentity } from './windowsSecurity.mjs';
9
+ export { ensurePrivateDirectory, defaultDataDirectory } from './privateFiles.mjs';
8
10
 
9
- export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : join(home, '.local', 'share', 'device-center'), 'driver-link.json');
11
+ export const defaultLinkFile = (home = homedir(), platform = process.platform) => join(defaultDataDirectory(home, platform), 'driver-link.json');
12
+ const decrypted = new Map();
10
13
  export function readLink(file) {
11
- let fd;
12
14
  try {
13
- fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
14
- const info = fstatSync(fd);
15
- if (!info.isFile() || info.nlink !== 1 || info.size > 8192 || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_file_required');
16
- const state = JSON.parse(readFileSync(fd, 'utf8'));
15
+ const stored = readPrivateJson(file, null, process.platform === 'win32' ? 32768 : 8192);
16
+ if (stored === null) return null;
17
+ let state = stored;
18
+ if (process.platform === 'win32') {
19
+ const cached = decrypted.get(file);
20
+ state = cached?.data === stored.data ? structuredClone(cached.state) : unprotectWindowsIdentity(stored);
21
+ if (stored.format !== 'device-center-dpapi-v1') throw new Error('windows_encrypted_identity_required');
22
+ if (decrypted.size > 8) decrypted.clear();
23
+ decrypted.set(file, { data: stored.data, state: structuredClone(state) });
24
+ }
17
25
  cloudOrigin(state.origin);
18
26
  const key = createPrivateKey(state.privateKey), identity = publicIdentity(state.publicKey);
19
27
  if (key.asymmetricKeyType !== 'ed25519' || createPublicKey(key).export({ type: 'spki', format: 'pem' }).toString() !== identity.publicKey || identity.fingerprint !== state.fingerprint || typeof state.shareSsh !== 'boolean' || typeof state.disabled !== 'boolean' || (state.driverId != null && !/^[a-f0-9-]{36}$/.test(state.driverId))) throw new Error('driver_private_file_invalid');
20
28
  if (state.certificate) validateCertificate(state.certificate, state.fingerprint, Date.now(), true);
21
29
  return state;
22
30
  } catch (error) { if (error.code === 'ENOENT') return null; throw new Error('driver_private_file_invalid'); }
23
- finally { if (fd !== undefined) closeSync(fd); }
24
- }
25
- export function ensurePrivateDirectory(directory) {
26
- mkdirSync(directory, { recursive: true, mode: 0o700 });
27
- const info = lstatSync(directory);
28
- if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
29
31
  }
30
32
  export function writeLink(file, state, { create = false } = {}) {
31
- ensurePrivateDirectory(dirname(file));
32
- if (create) { writeFileSync(file, JSON.stringify(state), { mode: 0o600, flag: 'wx' }); return; }
33
- const temp = `${file}.${randomBytes(6).toString('hex')}.tmp`;
34
- writeFileSync(temp, JSON.stringify(state), { mode: 0o600, flag: 'wx' });
35
- renameSync(temp, file);
33
+ writePrivateJson(file, process.platform === 'win32' ? protectWindowsIdentity(state) : state, { create });
34
+ decrypted.delete(file);
36
35
  }
37
36
  export function linkSummary(state) {
38
37
  return { protocol: 'device-center-driver-v1', configured: Boolean(state?.driverId && !state.disabled), origin: state?.origin ?? null, driverId: state?.driverId ?? null, fingerprint: state?.fingerprint ?? null, secureTransportReady: Boolean(state?.certificate), shareSsh: Boolean(state?.shareSsh), disabled: Boolean(state?.disabled) };
@@ -0,0 +1,57 @@
1
+ import { constants, openSync, closeSync, fstatSync, readFileSync, mkdirSync, lstatSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { dirname, join, win32 } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { windowsStorage } from './windowsSecurity.mjs';
6
+
7
+ export const defaultDataDirectory = (home = homedir(), platform = process.platform) => platform === 'darwin' ? join(home, 'Library', 'Application Support', 'Device Center') : platform === 'win32' ? win32.join(home, 'AppData', 'Local', 'Device Center') : join(home, '.local', 'share', 'device-center');
8
+
9
+ // Recheck Windows ACLs at least every 30s, or immediately when file metadata changes.
10
+ // This avoids launching PowerShell on every 5s control poll. No private data is cached here.
11
+ const checked = new Map();
12
+ export function secureCreatedPrivateFile(file) {
13
+ if (process.platform === 'win32') windowsStorage('secure-new-file', { path: file });
14
+ }
15
+ export function ensurePrivateDirectory(directory) {
16
+ if (process.platform === 'win32') { windowsStorage('ensure-directory', { path: directory }); return; }
17
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
18
+ const info = lstatSync(directory);
19
+ if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_directory_required');
20
+ }
21
+ export function assertPrivateFile(file, info, limit = 65536) {
22
+ const pathInfo = lstatSync(file);
23
+ if (!info.isFile() || info.nlink !== 1 || info.size > limit || pathInfo.isSymbolicLink() || info.dev !== pathInfo.dev || info.ino !== pathInfo.ino) throw new Error('driver_private_file_required');
24
+ if (process.platform !== 'win32') {
25
+ if ((info.mode & 0o077) || (process.getuid && info.uid !== process.getuid())) throw new Error('driver_private_file_required');
26
+ return;
27
+ }
28
+ const signature = `${info.dev}:${info.ino}:${info.ctimeMs}:${info.mtimeMs}:${info.size}`;
29
+ const previous = checked.get(file);
30
+ if (!previous || previous.signature !== signature || Date.now() - previous.at >= 30_000) {
31
+ windowsStorage('assert-file', { path: file });
32
+ if (checked.size > 32) checked.clear();
33
+ checked.set(file, { signature, at: Date.now() });
34
+ }
35
+ }
36
+ export function readPrivateJson(file, fallback, limit = 65536) {
37
+ let fd;
38
+ try {
39
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
40
+ assertPrivateFile(file, fstatSync(fd), limit);
41
+ return JSON.parse(readFileSync(fd, 'utf8'));
42
+ } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
43
+ finally { if (fd !== undefined) closeSync(fd); }
44
+ }
45
+ export function writePrivateJson(file, value, { create = false } = {}) {
46
+ ensurePrivateDirectory(dirname(file));
47
+ const target = create ? file : `${file}.${randomBytes(6).toString('hex')}.tmp`;
48
+ writeFileSync(target, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
49
+ // Windows files inherit the protected directory ACL. Validate before activation.
50
+ if (process.platform === 'win32') {
51
+ secureCreatedPrivateFile(target);
52
+ const fd = openSync(target, constants.O_RDONLY);
53
+ try { assertPrivateFile(target, fstatSync(fd)); } finally { closeSync(fd); }
54
+ }
55
+ if (!create) renameSync(target, file);
56
+ checked.delete(file);
57
+ }
@@ -0,0 +1,119 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { win32 } from 'node:path';
3
+
4
+ // Fixed PowerShell code; paths and secrets travel only on stdin, never in arguments.
5
+ // No execution-policy change, elevation, password, external download or shell expansion.
6
+ export const powershellPath = (environment = process.env) => {
7
+ const systemRoot = environment.SystemRoot;
8
+ if (!systemRoot || !win32.isAbsolute(systemRoot) || /[\r\n\0]/.test(systemRoot)) throw new Error('windows_system_root_required');
9
+ return win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
10
+ };
11
+ export const powershellArguments = source => ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')];
12
+ export function runPowerShell(source, input, { spawnImpl = spawnSync, environment = process.env } = {}) {
13
+ const result = spawnImpl(powershellPath(environment), powershellArguments(source), {
14
+ input: JSON.stringify(input), encoding: 'utf8', windowsHide: true, shell: false, timeout: 15_000, maxBuffer: 512 * 1024,
15
+ });
16
+ // PowerShell diagnostics can contain input values. Never relay them to the caller.
17
+ if (result.error || result.status !== 0) throw new Error('windows_security_operation_failed');
18
+ try { return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()); }
19
+ catch { throw new Error('windows_security_response_invalid'); }
20
+ }
21
+ export const powershellPreamble = `
22
+ $ErrorActionPreference = 'Stop'
23
+ # A Node child launched from PowerShell 7 inherits its module search path.
24
+ # Windows PowerShell 5.1 must load its own built-in modules (including Set-Acl),
25
+ # not incompatible PS7 or user-installed modules. This affects only this child.
26
+ $env:PSModulePath = [IO.Path]::Combine($PSHOME, 'Modules')
27
+ [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
28
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
29
+ $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
30
+ $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
31
+ `;
32
+ export const windowsStorageSource = powershellPreamble + `
33
+ function Assert-PlainPath([string] $path) {
34
+ $cursor = [IO.Path]::GetFullPath($path)
35
+ while ($cursor) {
36
+ if ([IO.File]::Exists($cursor) -or [IO.Directory]::Exists($cursor)) {
37
+ $item = Get-Item -LiteralPath $cursor -Force
38
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse_point' }
39
+ }
40
+ $parent = [IO.Path]::GetDirectoryName($cursor)
41
+ if ($parent -eq $cursor) { break }
42
+ $cursor = $parent
43
+ }
44
+ }
45
+ function Assert-Private([string] $path) {
46
+ Assert-PlainPath $path
47
+ $acl = Get-Acl -LiteralPath $path
48
+ if ($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner_required' }
49
+ $currentAllowed = $false
50
+ foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
51
+ if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { throw 'deny_rule' }
52
+ if ($rule.IdentityReference.Value -notin @($sid.Value, 'S-1-5-18')) { throw 'private_acl_required' }
53
+ if ($rule.IdentityReference.Value -eq $sid.Value -and ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl) { $currentAllowed = $true }
54
+ }
55
+ if (-not $currentAllowed) { throw 'current_user_access_required' }
56
+ }
57
+ switch ($request.operation) {
58
+ 'ensure-directory' {
59
+ Assert-PlainPath $request.path
60
+ if (-not [IO.Directory]::Exists($request.path)) {
61
+ $directory = [IO.Directory]::CreateDirectory($request.path)
62
+ $acl = New-Object System.Security.AccessControl.DirectorySecurity
63
+ $acl.SetOwner($sid)
64
+ $acl.SetAccessRuleProtection($true, $false)
65
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
66
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
67
+ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')
68
+ $acl.AddAccessRule($rule)
69
+ }
70
+ Set-Acl -LiteralPath $directory.FullName -AclObject $acl
71
+ }
72
+ Assert-Private $request.path
73
+ @{ ok = $true } | ConvertTo-Json -Compress
74
+ }
75
+ 'assert-file' {
76
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
77
+ Assert-Private $request.path
78
+ @{ ok = $true } | ConvertTo-Json -Compress
79
+ }
80
+ 'secure-new-file' {
81
+ # Called only after our exclusive wx creation in an already protected directory.
82
+ Assert-Private ([IO.Path]::GetDirectoryName($request.path))
83
+ Assert-PlainPath $request.path
84
+ $acl = [System.Security.AccessControl.FileSecurity]::new()
85
+ $acl.SetOwner($sid)
86
+ $acl.SetAccessRuleProtection($true, $false)
87
+ foreach ($principal in @($sid.Value, 'S-1-5-18')) {
88
+ $identity = [System.Security.Principal.SecurityIdentifier]::new($principal)
89
+ $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($identity, 'FullControl', 'Allow'))
90
+ }
91
+ Set-Acl -LiteralPath $request.path -AclObject $acl
92
+ Assert-Private $request.path
93
+ @{ ok = $true } | ConvertTo-Json -Compress
94
+ }
95
+ 'protect' {
96
+ Add-Type -AssemblyName System.Security
97
+ $data = [Convert]::FromBase64String($request.data)
98
+ $encrypted = [System.Security.Cryptography.ProtectedData]::Protect($data, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
99
+ @{ data = [Convert]::ToBase64String($encrypted) } | ConvertTo-Json -Compress
100
+ [Array]::Clear($data, 0, $data.Length)
101
+ }
102
+ 'unprotect' {
103
+ Add-Type -AssemblyName System.Security
104
+ $data = [System.Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($request.data), $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)
105
+ @{ data = [Convert]::ToBase64String($data) } | ConvertTo-Json -Compress
106
+ [Array]::Clear($data, 0, $data.Length)
107
+ }
108
+ default { throw 'unknown_storage_operation' }
109
+ }
110
+ `;
111
+ export function windowsStorage(operation, values) {
112
+ if (process.platform !== 'win32') throw new Error('windows_only');
113
+ return runPowerShell(windowsStorageSource, { operation, ...values });
114
+ }
115
+ export const protectWindowsIdentity = value => ({ format: 'device-center-dpapi-v1', data: windowsStorage('protect', { data: Buffer.from(JSON.stringify(value)).toString('base64') }).data });
116
+ export function unprotectWindowsIdentity(value) {
117
+ if (value?.format !== 'device-center-dpapi-v1' || typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('windows_encrypted_identity_required');
118
+ return JSON.parse(Buffer.from(windowsStorage('unprotect', { data: value.data }).data, 'base64').toString('utf8'));
119
+ }
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { powershellArguments, powershellPath, powershellPreamble, runPowerShell } from './windowsSecurity.mjs';
3
+
4
+ const literal = value => `'${String(value).replaceAll("'", "''")}'`;
5
+ export function windowsServiceDefinition({ home, root, node, script, port, dbPath, logPath, errorLogPath, environment = process.env }) {
6
+ const launcher = `$ErrorActionPreference = 'Stop'\n$env:PORT = '${port}'\n$env:DEVICE_CENTER_DB_PATH = ${literal(dbPath)}\n$env:DEVICE_CENTER_SERVICE_MANAGER = 'windows-task'\n$env:DEVICE_CENTER_RUNTIME = 'native'\n$env:DEVICE_CENTER_PUBLIC_ORIGIN = $null\n$env:DEVICE_CENTER_AUTH_FILE = $null\nSet-Location -LiteralPath ${literal(root)}\n# Windows PowerShell 5.1 treats redirected native stderr as ErrorRecord.\n# Keep Node warnings visible in the log; use its exit code for task failure.\n$ErrorActionPreference = 'Continue'\n$LASTEXITCODE = 1\n& ${literal(node)} ${literal(script)} >> ${literal(logPath)} 2>> ${literal(errorLogPath)}\n$exitCode = $LASTEXITCODE\n$ErrorActionPreference = 'Stop'\nexit $exitCode\n`;
7
+ return {
8
+ taskName: `DeviceCenter-${createHash('sha256').update(home.toLowerCase()).digest('hex').slice(0, 16)}`,
9
+ executable: powershellPath(environment),
10
+ arguments: ['-WindowStyle', 'Hidden', ...powershellArguments(launcher)].join(' '),
11
+ workingDirectory: root, logonType: 'InteractiveToken', runLevel: 'Limited', trigger: 'CurrentUserLogon',
12
+ };
13
+ }
14
+ // COM Task Scheduler is built into Windows. Only this user's exact task is touched.
15
+ export const windowsTaskSource = powershellPreamble + `
16
+ $scheduler = New-Object -ComObject 'Schedule.Service'
17
+ $scheduler.Connect()
18
+ $folder = $scheduler.GetFolder('\\')
19
+ $task = $null
20
+ try { $task = $folder.GetTask($request.task.taskName) } catch {
21
+ $exception = $_.Exception
22
+ while ($exception.InnerException) { $exception = $exception.InnerException }
23
+ if ($exception.HResult -ne -2147024894) { throw }
24
+ }
25
+ function Assert-OurTask {
26
+ if (-not $task) { throw 'task_missing' }
27
+ $definition = $task.Definition
28
+ $principal = $definition.Principal
29
+ $principalSid = $principal.UserId
30
+ if ($principal.UserId -ne $sid.Value) { $principalSid = ([System.Security.Principal.NTAccount]::new($principal.UserId)).Translate([System.Security.Principal.SecurityIdentifier]).Value }
31
+ if ($principalSid -ne $sid.Value -or $principal.LogonType -ne 3 -or $principal.RunLevel -ne 0) { throw 'task_principal_changed' }
32
+ if ($definition.Actions.Count -ne 1 -or $definition.Triggers.Count -ne 1) { throw 'task_changed' }
33
+ $action = $definition.Actions.Item(1)
34
+ $trigger = $definition.Triggers.Item(1)
35
+ if ($action.Type -ne 0 -or $action.Path -ne $request.task.executable -or $action.Arguments -cne $request.task.arguments -or $action.WorkingDirectory -cne $request.task.workingDirectory -or $trigger.Type -ne 9 -or $trigger.UserId -ne $sid.Value) { throw 'task_action_changed' }
36
+ }
37
+ switch ($request.operation) {
38
+ 'install' {
39
+ if ($task) { throw 'task_already_exists' }
40
+ $definition = $scheduler.NewTask(0)
41
+ $definition.RegistrationInfo.Description = 'Device Center: current user local Driver; login startup only.'
42
+ $definition.Principal.UserId = $sid.Value
43
+ $definition.Principal.LogonType = 3
44
+ $definition.Principal.RunLevel = 0
45
+ $trigger = $definition.Triggers.Create(9)
46
+ $trigger.UserId = $sid.Value
47
+ $action = $definition.Actions.Create(0)
48
+ $action.Path = $request.task.executable
49
+ $action.Arguments = $request.task.arguments
50
+ $action.WorkingDirectory = $request.task.workingDirectory
51
+ $definition.Settings.Enabled = $true
52
+ $definition.Settings.AllowDemandStart = $true
53
+ $definition.Settings.StartWhenAvailable = $true
54
+ $definition.Settings.DisallowStartIfOnBatteries = $false
55
+ $definition.Settings.StopIfGoingOnBatteries = $false
56
+ $definition.Settings.ExecutionTimeLimit = 'PT0S'
57
+ $definition.Settings.MultipleInstances = 2
58
+ $definition.Settings.RestartInterval = 'PT1M'
59
+ $definition.Settings.RestartCount = 3
60
+ $task = $folder.RegisterTaskDefinition($request.task.taskName, $definition, 2, $sid.Value, $null, 3)
61
+ $null = $task.Run($null)
62
+ @{ registered = $true } | ConvertTo-Json -Compress
63
+ }
64
+ 'status' {
65
+ if ($task) { Assert-OurTask }
66
+ @{ exists = [bool]$task; state = $(if ($task) { [int]$task.State } else { $null }) } | ConvertTo-Json -Compress
67
+ }
68
+ 'stop' { Assert-OurTask; $task.Stop(0); @{ stopped = $true } | ConvertTo-Json -Compress }
69
+ 'remove' { Assert-OurTask; $folder.DeleteTask($request.task.taskName, 0); @{ removed = $true } | ConvertTo-Json -Compress }
70
+ default { throw 'unknown_task_operation' }
71
+ }
72
+ `;
73
+ export function windowsTask(operation, task) {
74
+ if (process.platform !== 'win32') throw new Error('windows_only');
75
+ try { return runPowerShell(windowsTaskSource, { operation, task }); }
76
+ catch { throw new Error('Windows 当前用户任务操作失败。请检查任务计划程序、组策略或既有任务;未提权、改策略或覆盖任务。可改用 start 前台模式。'); }
77
+ }