@addai/node 0.12.0 → 0.14.0

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.
@@ -1,3 +1,4 @@
1
+ import type { EngineInfo } from './desktop/provider';
1
2
  import { type AutostartState } from './autostart';
2
3
  import { type AutoUpdateState } from './auto-update';
3
4
  import { type MachineSample } from './machine-metrics';
@@ -44,6 +45,10 @@ interface CapabilitiesShape {
44
45
  * rather than a column of its own — same channel Studio already reads the
45
46
  * harness grid from. */
46
47
  autostart?: AutostartState;
48
+ /** Container engine backing Entity Desktops, or null when none is
49
+ * installed. Studio renders an install card in place of the create
50
+ * button when this is null. */
51
+ container_engine?: EngineInfo | null;
47
52
  /** CPU / memory / disk / runs at the moment of this probe. The AiNode page
48
53
  * has read this key since it was written; nothing produced it until now. */
49
54
  machine?: MachineSample;
@@ -41,6 +41,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
41
41
  exports.codexHome = codexHome;
42
42
  exports.codexAuthFromStore = codexAuthFromStore;
43
43
  exports.probeCapabilities = probeCapabilities;
44
+ const engine_1 = require("./desktop/engine");
44
45
  const child_process_1 = require("child_process");
45
46
  const fs = __importStar(require("fs"));
46
47
  const path = __importStar(require("path"));
@@ -377,13 +378,14 @@ async function probeCapabilities() {
377
378
  // heartbeat — that would report the runtime permanently offline.
378
379
  const shield = (p, fallback) => p.catch(() => fallback);
379
380
  const unavailable = { available: false, authed: false };
380
- const [claude, codex, kimi, gemini, grok, git] = await Promise.all([
381
+ const [claude, codex, kimi, gemini, grok, git, containerEngine] = await Promise.all([
381
382
  shield(probeClaude(), unavailable),
382
383
  shield(probeCodex(), unavailable),
383
384
  shield(probeKimi(), unavailable),
384
385
  shield(probeGemini(), unavailable),
385
386
  shield(probeGrok(), unavailable),
386
387
  shield(probeGit(), unavailable),
388
+ shield((0, engine_1.detectEngine)(), null),
387
389
  ]);
388
390
  // Decorate each harness with its registry facts (efforts, installability,
389
391
  // login strategy) so Studio and the TUI render from one source of truth.
@@ -400,6 +402,7 @@ async function probeCapabilities() {
400
402
  return {
401
403
  daemon_version: readDaemonVersion(),
402
404
  autostart: (0, autostart_1.status)(),
405
+ container_engine: containerEngine,
403
406
  auto_update: (0, auto_update_1.autoUpdateState)(),
404
407
  machine: (0, machine_metrics_1.sampleMachine)(),
405
408
  claude: deco('claude', claude),
@@ -45,6 +45,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.setRestartHook = setRestartHook;
46
46
  exports.wake = wake;
47
47
  const child_process_1 = require("child_process");
48
+ const crypto = __importStar(require("crypto"));
48
49
  const fs = __importStar(require("fs"));
49
50
  const os = __importStar(require("os"));
50
51
  const path = __importStar(require("path"));
@@ -59,6 +60,9 @@ const self_update_1 = require("./self-update");
59
60
  const autostart_1 = require("./autostart");
60
61
  const paths_1 = require("./paths");
61
62
  const pty_helper_1 = require("./pty-helper");
63
+ const docker_1 = require("./desktop/docker");
64
+ const manager_1 = require("./desktop/manager");
65
+ const creds_1 = require("./desktop/creds");
62
66
  const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
63
67
  /** Own version, read the same way index.ts does. Resolved here rather than
64
68
  * imported from index.ts, which already imports this module. */
@@ -557,6 +561,118 @@ async function runSetAutostart(cmd) {
557
561
  }
558
562
  await update(cmd.id, 'completed', { autostart: state });
559
563
  }
564
+ /* ── desktops ────────────────────────────────────────────────────────────
565
+ * All six desktop_* kinds. Progress streams into the command payload the
566
+ * same way install_harness does, so Studio's existing live-log panel renders
567
+ * the image pull with no new component. */
568
+ const DESKTOP_KINDS = [
569
+ 'desktop_create', 'desktop_start', 'desktop_stop',
570
+ 'desktop_delete', 'desktop_rebuild', 'desktop_sync_logins',
571
+ ];
572
+ /** Setup scripts run as a FILE, never as a `sh -c` string: a script with
573
+ * quotes or newlines must not need escaping to survive. */
574
+ async function runSetupScript(provider, row, onLog) {
575
+ const { confDir } = (0, manager_1.ensureDirs)(row.id);
576
+ fs.writeFileSync(path.join(confDir, 'setup.sh'), row.setup_script ?? '', { mode: 0o700 });
577
+ await (0, creds_1.execInDesktop)(provider, row, ['bash', '/conf/setup.sh'], onLog);
578
+ }
579
+ async function runDesktopCommand(cmd) {
580
+ if (!cmd.desktop_id) {
581
+ await update(cmd.id, 'failed', undefined, 'no desktop on command');
582
+ return;
583
+ }
584
+ // force: a user who just installed Docker should not have to restart the
585
+ // daemon for their first desktop to build.
586
+ const provider = await (0, docker_1.getProvider)(true);
587
+ if (!provider) {
588
+ const why = 'No container engine on this machine. Install Docker Desktop or Podman, then try again.';
589
+ await update(cmd.id, 'failed', undefined, why);
590
+ await (0, manager_1.setStatus)(cmd.desktop_id, { status: 'failed', status_message: why });
591
+ return;
592
+ }
593
+ const rows = await (0, manager_1.listDesktops)();
594
+ const row = rows.find(r => r.id === cmd.desktop_id);
595
+ if (!row) {
596
+ await update(cmd.id, 'failed', undefined, 'desktop row not found');
597
+ return;
598
+ }
599
+ let log = '';
600
+ const onLog = (chunk) => {
601
+ log = (log + chunk).slice(-LOG_TAIL_CHARS);
602
+ void update(cmd.id, null, { log });
603
+ };
604
+ try {
605
+ await update(cmd.id, 'running', { log });
606
+ switch (cmd.kind) {
607
+ case 'desktop_create':
608
+ case 'desktop_rebuild': {
609
+ if (cmd.kind === 'desktop_rebuild') {
610
+ onLog('removing existing container\n');
611
+ await provider.remove(row);
612
+ }
613
+ await (0, manager_1.setStatus)(row.id, { status: 'creating', status_message: null });
614
+ const { confDir, workDir } = (0, manager_1.ensureDirs)(row.id);
615
+ const vncPort = await (0, manager_1.allocateVncPort)();
616
+ // A fresh password on every build; it only ever travels between the
617
+ // daemon and the container on the same machine.
618
+ const vncPassword = crypto.randomBytes(12).toString('base64url');
619
+ const built = { ...row, vnc_password: vncPassword };
620
+ const containerId = await provider.create(built, { confDir, workDir, vncPort }, onLog);
621
+ await (0, manager_1.setStatus)(row.id, {
622
+ status: 'running', container_id: containerId,
623
+ engine: provider.id, vnc_port: vncPort, status_message: null,
624
+ // Record the password we actually gave the container, or the viewer
625
+ // will keep presenting the previous one.
626
+ vnc_password: vncPassword,
627
+ });
628
+ await (0, creds_1.syncCredentials)(provider, built, onLog);
629
+ if (row.setup_script) {
630
+ onLog('running setup script\n');
631
+ try {
632
+ await runSetupScript(provider, built, onLog);
633
+ }
634
+ catch (err) {
635
+ // The desktop exists and works; the script did not. Record it and
636
+ // do not destroy the box the user just waited minutes for.
637
+ await (0, manager_1.setStatus)(row.id, {
638
+ status_message: `Setup script failed: ${err.message}`.slice(0, 400),
639
+ });
640
+ }
641
+ }
642
+ break;
643
+ }
644
+ case 'desktop_start':
645
+ await (0, manager_1.setStatus)(row.id, { status: 'starting' });
646
+ await provider.start(row);
647
+ await (0, manager_1.setStatus)(row.id, { status: 'running', status_message: null });
648
+ break;
649
+ case 'desktop_stop':
650
+ await provider.stop(row);
651
+ await (0, manager_1.setStatus)(row.id, { status: 'stopped', status_message: null });
652
+ break;
653
+ case 'desktop_delete': {
654
+ await provider.remove(row);
655
+ // The server keeps the row until the machine confirms, so a delete
656
+ // issued to an offline node is not lost.
657
+ const t = token();
658
+ if (t)
659
+ await (0, supabase_client_1.rpc)('runtime_desktop_deleted', { p_token: t, p_desktop_id: row.id });
660
+ break;
661
+ }
662
+ case 'desktop_sync_logins':
663
+ await (0, creds_1.syncCredentials)(provider, row, onLog);
664
+ break;
665
+ }
666
+ await update(cmd.id, 'completed', { log });
667
+ }
668
+ catch (err) {
669
+ const message = err.message.slice(0, 500);
670
+ await update(cmd.id, 'failed', { log }, message);
671
+ if (cmd.kind !== 'desktop_delete') {
672
+ await (0, manager_1.setStatus)(row.id, { status: 'failed', status_message: message });
673
+ }
674
+ }
675
+ }
560
676
  /* ── dispatcher ──────────────────────────────────────────────────────── */
561
677
  async function execute(cmd) {
562
678
  // Not a harness command — dispatch before the harness lookup, which would
@@ -569,6 +685,10 @@ async function execute(cmd) {
569
685
  await runSetAutostart(cmd);
570
686
  return;
571
687
  }
688
+ if (DESKTOP_KINDS.includes(cmd.kind)) {
689
+ await runDesktopCommand(cmd);
690
+ return;
691
+ }
572
692
  const spec = (0, harness_registry_1.harness)(cmd.harness ?? '');
573
693
  if (!spec) {
574
694
  await update(cmd.id, 'failed', {}, `unknown harness: ${cmd.harness}`);
@@ -0,0 +1,7 @@
1
+ import { DesktopRow } from './spec';
2
+ import { DesktopProvider } from './provider';
3
+ /** Keep the auth fields, drop host MCP servers and project history. */
4
+ export declare function stripMcpServers(raw: string): string;
5
+ /** Run a command inside a desktop, streaming output to onLog. */
6
+ export declare function execInDesktop(provider: DesktopProvider, row: DesktopRow, cmd: string[], onLog: (c: string) => void): Promise<void>;
7
+ export declare function syncCredentials(provider: DesktopProvider, row: DesktopRow, onLog: (c: string) => void): Promise<void>;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.stripMcpServers = stripMcpServers;
37
+ exports.execInDesktop = execInDesktop;
38
+ exports.syncCredentials = syncCredentials;
39
+ // Harness logins are COPIED into a desktop, never bind-mounted.
40
+ //
41
+ // Bind-mounting ~/.claude would hand the box write access to every transcript
42
+ // on this machine - which defeats the isolation the desktop exists for. The
43
+ // entity already effectively holds these tokens, so copying the auth blob
44
+ // costs nothing; the host's project history and MCP servers are what must not
45
+ // travel, so they are stripped here.
46
+ const child_process_1 = require("child_process");
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const win_1 = require("../win");
51
+ const provider_1 = require("./provider");
52
+ /** Keep the auth fields, drop host MCP servers and project history. */
53
+ function stripMcpServers(raw) {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(raw);
57
+ if (parsed === null || typeof parsed !== 'object')
58
+ throw new Error('not an object');
59
+ }
60
+ catch {
61
+ return '{"mcpServers":{}}';
62
+ }
63
+ delete parsed.projects;
64
+ parsed.mcpServers = {};
65
+ return JSON.stringify(parsed);
66
+ }
67
+ /** Run a command inside a desktop, streaming output to onLog. */
68
+ function execInDesktop(provider, row, cmd, onLog) {
69
+ const args = (0, provider_1.buildExecArgs)(row, cmd, { cwd: '/work', env: {}, tty: false });
70
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
71
+ return new Promise((resolve, reject) => {
72
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
73
+ env: process.env, windowsHide: true, timeout: 10 * 60_000,
74
+ });
75
+ let err = '';
76
+ child.stdout?.on('data', b => onLog(b.toString('utf8')));
77
+ child.stderr?.on('data', b => { const s = b.toString('utf8'); err += s; onLog(s); });
78
+ child.on('error', reject);
79
+ child.on('exit', code => code === 0
80
+ ? resolve()
81
+ : reject(new Error(err.trim().slice(0, 400) || `exit ${code}`)));
82
+ });
83
+ }
84
+ /** Write a file into the container through stdin, so contents with quotes,
85
+ * newlines or ESC bytes survive verbatim - nothing is ever interpolated
86
+ * into a shell string. */
87
+ function writeInto(provider, row, dest, contents) {
88
+ const args = (0, provider_1.buildExecArgs)(row, ['sh', '-c', 'mkdir -p "$(dirname "$1")" && cat > "$1"', '_', dest], { cwd: '/work', env: {}, tty: false });
89
+ const inv = (0, win_1.resolveCliInvocation)(provider.id, args);
90
+ return new Promise((resolve, reject) => {
91
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, { env: process.env, windowsHide: true });
92
+ child.on('error', reject);
93
+ child.on('exit', code => code === 0
94
+ ? resolve()
95
+ : reject(new Error(`write ${dest} failed (exit ${code})`)));
96
+ child.stdin?.end(contents);
97
+ });
98
+ }
99
+ function credFiles() {
100
+ const home = os.homedir();
101
+ return [
102
+ {
103
+ hostPath: path.join(home, '.claude.json'),
104
+ destPath: '/home/entity/.claude.json',
105
+ transform: stripMcpServers,
106
+ },
107
+ { hostPath: path.join(home, '.claude', '.credentials.json'),
108
+ destPath: '/home/entity/.claude/.credentials.json' },
109
+ { hostPath: path.join(home, '.codex', 'auth.json'),
110
+ destPath: '/home/entity/.codex/auth.json' },
111
+ { hostPath: path.join(home, '.gemini', 'oauth_creds.json'),
112
+ destPath: '/home/entity/.gemini/oauth_creds.json' },
113
+ ];
114
+ }
115
+ async function syncCredentials(provider, row, onLog) {
116
+ for (const f of credFiles()) {
117
+ let raw;
118
+ try {
119
+ raw = fs.readFileSync(f.hostPath, 'utf8');
120
+ }
121
+ catch {
122
+ continue;
123
+ } // not logged in here: nothing to copy
124
+ const body = f.transform ? f.transform(raw) : raw;
125
+ try {
126
+ await writeInto(provider, row, f.destPath, body);
127
+ onLog(`copied ${path.basename(f.destPath)}\n`);
128
+ }
129
+ catch (err) {
130
+ // One missing login must not abort the others.
131
+ onLog(`could not copy ${path.basename(f.destPath)}: ${err.message}\n`);
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,19 @@
1
+ import { DesktopRow } from './spec';
2
+ import { DesktopProvider, EngineInfo, CreateOpts } from './provider';
3
+ export declare class CliProvider implements DesktopProvider {
4
+ readonly id: 'docker' | 'podman';
5
+ private version;
6
+ constructor(id: 'docker' | 'podman', version: string);
7
+ private run;
8
+ probe(): Promise<EngineInfo | null>;
9
+ create(row: DesktopRow, opts: CreateOpts, onLog: (c: string) => void): Promise<string>;
10
+ start(row: DesktopRow): Promise<void>;
11
+ stop(row: DesktopRow): Promise<void>;
12
+ remove(row: DesktopRow): Promise<void>;
13
+ inspect(row: DesktopRow): Promise<{
14
+ running: boolean;
15
+ containerId: string;
16
+ } | null>;
17
+ }
18
+ export declare function getProvider(force?: boolean): Promise<DesktopProvider | null>;
19
+ export declare function resetProviderCache(): void;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CliProvider = void 0;
4
+ exports.getProvider = getProvider;
5
+ exports.resetProviderCache = resetProviderCache;
6
+ // Docker/Podman backend. One class serves both: the CLI surface we use
7
+ // (run/start/stop/rm/inspect/exec) is compatible between them.
8
+ const child_process_1 = require("child_process");
9
+ const win_1 = require("../win");
10
+ const provider_1 = require("./provider");
11
+ const engine_1 = require("./engine");
12
+ class CliProvider {
13
+ id;
14
+ version;
15
+ constructor(id, version) {
16
+ this.id = id;
17
+ this.version = version;
18
+ }
19
+ run(args, onLog, timeoutMs = 15 * 60_000) {
20
+ return new Promise(resolve => {
21
+ let stdout = '', stderr = '';
22
+ try {
23
+ const inv = (0, win_1.resolveCliInvocation)(this.id, args);
24
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
25
+ env: process.env, windowsHide: true, timeout: timeoutMs,
26
+ });
27
+ child.stdout?.on('data', b => { const s = b.toString('utf8'); stdout += s; onLog?.(s); });
28
+ child.stderr?.on('data', b => { const s = b.toString('utf8'); stderr += s; onLog?.(s); });
29
+ child.on('error', err => resolve({ ok: false, stdout, stderr: String(err) }));
30
+ child.on('exit', code => resolve({ ok: code === 0, stdout, stderr }));
31
+ }
32
+ catch (err) {
33
+ resolve({ ok: false, stdout, stderr: err.message });
34
+ }
35
+ });
36
+ }
37
+ async probe() {
38
+ return { id: this.id, version: this.version };
39
+ }
40
+ async create(row, opts, onLog) {
41
+ // Pull first so the slow, chatty image download streams into the command
42
+ // payload as its own phase rather than hiding inside `run`.
43
+ onLog(`pulling ${row.image}\n`);
44
+ const pull = await this.run(['pull', row.image], onLog);
45
+ if (!pull.ok) {
46
+ // A pull failure is only fatal if we have no copy. A locally built or
47
+ // side-loaded image has no registry to pull FROM, and a node that is
48
+ // briefly offline should still be able to start a desktop it already
49
+ // has. Only give up when neither the registry nor the disk has it.
50
+ const local = await this.run(['image', 'inspect', row.image], undefined, 30_000);
51
+ if (!local.ok)
52
+ throw new Error(`image pull failed: ${pull.stderr.trim().slice(0, 500)}`);
53
+ onLog(`pull failed; using the copy already on this machine\n`);
54
+ }
55
+ onLog(`creating ${(0, provider_1.containerName)(row)}\n`);
56
+ const res = await this.run((0, provider_1.buildCreateArgs)(row, opts), onLog);
57
+ if (!res.ok)
58
+ throw new Error(`create failed: ${res.stderr.trim().slice(0, 500)}`);
59
+ return res.stdout.trim().split('\n').pop() || '';
60
+ }
61
+ async start(row) {
62
+ const res = await this.run(['start', (0, provider_1.containerName)(row)], undefined, 60_000);
63
+ if (!res.ok)
64
+ throw new Error(`start failed: ${res.stderr.trim().slice(0, 300)}`);
65
+ }
66
+ async stop(row) {
67
+ const res = await this.run(['stop', '-t', '10', (0, provider_1.containerName)(row)], undefined, 60_000);
68
+ if (!res.ok)
69
+ throw new Error(`stop failed: ${res.stderr.trim().slice(0, 300)}`);
70
+ }
71
+ async remove(row) {
72
+ // -f so a running desktop deletes without a separate stop; -v drops its
73
+ // anonymous volumes, otherwise deleting leaks disk forever.
74
+ const res = await this.run(['rm', '-f', '-v', (0, provider_1.containerName)(row)], undefined, 120_000);
75
+ // "No such container" IS success for a delete.
76
+ if (!res.ok && !/no such container/i.test(res.stderr)) {
77
+ throw new Error(`remove failed: ${res.stderr.trim().slice(0, 300)}`);
78
+ }
79
+ }
80
+ async inspect(row) {
81
+ const res = await this.run(['inspect', '-f', '{{.Id}} {{.State.Running}}', (0, provider_1.containerName)(row)], undefined, 30_000);
82
+ if (!res.ok)
83
+ return null;
84
+ const [id, running] = res.stdout.trim().split(/\s+/);
85
+ if (!id)
86
+ return null;
87
+ return { running: running === 'true', containerId: id };
88
+ }
89
+ }
90
+ exports.CliProvider = CliProvider;
91
+ let cached;
92
+ async function getProvider(force = false) {
93
+ if (!force && cached !== undefined)
94
+ return cached;
95
+ const info = await (0, engine_1.detectEngine)();
96
+ cached = info ? new CliProvider(info.id, info.version) : null;
97
+ return cached;
98
+ }
99
+ function resetProviderCache() { cached = undefined; }
@@ -0,0 +1,3 @@
1
+ import { EngineInfo } from './provider';
2
+ export declare function parseEngineVersion(stdout: string): string | null;
3
+ export declare function detectEngine(): Promise<EngineInfo | null>;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseEngineVersion = parseEngineVersion;
4
+ exports.detectEngine = detectEngine;
5
+ // Which container engine is on this machine? Docker first (the verified
6
+ // path), Podman second. Never installs anything: installing Docker Desktop
7
+ // needs admin rights and, on Windows, a reboot for the WSL2 kernel, so the
8
+ // daemon reports the absence and Studio shows guidance instead.
9
+ const child_process_1 = require("child_process");
10
+ const win_1 = require("../win");
11
+ function parseEngineVersion(stdout) {
12
+ const m = /version\s+v?(\d+\.\d+\.\d+)/i.exec(stdout);
13
+ return m ? m[1] : null;
14
+ }
15
+ function runVersion(binary) {
16
+ return new Promise(resolve => {
17
+ try {
18
+ const inv = (0, win_1.resolveCliInvocation)(binary, ['--version']);
19
+ const child = (0, child_process_1.spawn)(inv.file, inv.args, {
20
+ env: process.env, windowsHide: true, timeout: 8_000,
21
+ });
22
+ let out = '';
23
+ child.stdout?.on('data', b => { out += b.toString('utf8'); });
24
+ child.on('error', () => resolve(null));
25
+ child.on('exit', code => resolve(code === 0 ? out : null));
26
+ }
27
+ catch {
28
+ // resolveCliInvocation throws when a Windows shim can't be resolved -
29
+ // that is "not installed" as far as this probe is concerned.
30
+ resolve(null);
31
+ }
32
+ });
33
+ }
34
+ async function detectEngine() {
35
+ for (const id of ['docker', 'podman']) {
36
+ const out = await runVersion(id);
37
+ if (!out)
38
+ continue;
39
+ const version = parseEngineVersion(out);
40
+ if (version)
41
+ return { id, version };
42
+ }
43
+ return null;
44
+ }
@@ -0,0 +1,30 @@
1
+ import { DesktopRow } from './spec';
2
+ export interface Assignment {
3
+ desktop_id: string;
4
+ runtime_id: string;
5
+ is_default: boolean;
6
+ }
7
+ export interface Invocation {
8
+ file: string;
9
+ args: string[];
10
+ cwd: string;
11
+ env: Record<string, string>;
12
+ }
13
+ /** Resolution order from the spec: the chat/schedule picker, then the
14
+ * entity's default on THIS node, then any assignment on this node, then the
15
+ * host. Returns the desktop id, or null for "run on the host".
16
+ *
17
+ * A desktop on another node is deliberately ignored rather than treated as
18
+ * an error: a run that failed over to a second machine must still run. */
19
+ export declare function resolveDesktopForRun(req: {
20
+ desktop_id: string | null;
21
+ runtime_id: string;
22
+ }, assignments: Assignment[], desktops: {
23
+ id: string;
24
+ runtime_id: string;
25
+ }[]): string | null;
26
+ /** Turn a host invocation into a container one. */
27
+ export declare function wrapInvocation(engine: 'docker' | 'podman', row: Pick<DesktopRow, 'id' | 'name'>, inv: Invocation, tty?: boolean): {
28
+ file: string;
29
+ args: string[];
30
+ };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveDesktopForRun = resolveDesktopForRun;
4
+ exports.wrapInvocation = wrapInvocation;
5
+ // Decides whether a run happens on the host or inside a desktop, and wraps
6
+ // the spawn when it is the latter. Everything about HOW a session runs is
7
+ // unchanged - only the argv it is launched with.
8
+ const spec_1 = require("./spec");
9
+ const provider_1 = require("./provider");
10
+ /** Resolution order from the spec: the chat/schedule picker, then the
11
+ * entity's default on THIS node, then any assignment on this node, then the
12
+ * host. Returns the desktop id, or null for "run on the host".
13
+ *
14
+ * A desktop on another node is deliberately ignored rather than treated as
15
+ * an error: a run that failed over to a second machine must still run. */
16
+ function resolveDesktopForRun(req, assignments, desktops) {
17
+ const onThisNode = (id) => desktops.some(d => d.id === id && d.runtime_id === req.runtime_id);
18
+ if (req.desktop_id && onThisNode(req.desktop_id))
19
+ return req.desktop_id;
20
+ const here = assignments.filter(a => a.runtime_id === req.runtime_id
21
+ && onThisNode(a.desktop_id));
22
+ const preferred = here.find(a => a.is_default) ?? here[0];
23
+ return preferred ? preferred.desktop_id : null;
24
+ }
25
+ /** Turn a host invocation into a container one. */
26
+ function wrapInvocation(engine, row, inv, tty = false) {
27
+ // A cwd that isn't a posix absolute path is a HOST path (a Windows drive
28
+ // path, or a relative one) and means nothing inside the container. Falling
29
+ // back to the workspace root beats handing docker a path it will reject.
30
+ const cwd = inv.cwd.startsWith('/') ? inv.cwd : spec_1.WORK_ROOT;
31
+ const args = (0, provider_1.buildExecArgs)(row, [inv.file, ...inv.args], {
32
+ cwd, env: inv.env, tty,
33
+ });
34
+ return { file: engine, args };
35
+ }
@@ -0,0 +1,32 @@
1
+ import { DesktopRow } from './spec';
2
+ export type Action = 'none' | 'start' | 'mark_failed' | 'mark_stopped' | 'mark_running';
3
+ /** Pure: given what the row claims and what the engine reports, what now?
4
+ * `actual` is null when no such container exists. */
5
+ export declare function reconcileAction(row: Pick<DesktopRow, 'status' | 'autostart'>, actual: {
6
+ running: boolean;
7
+ } | null): Action;
8
+ export declare function listDesktops(): Promise<DesktopRow[]>;
9
+ export declare function setStatus(id: string, fields: {
10
+ status?: DesktopRow['status'];
11
+ status_message?: string | null;
12
+ container_id?: string | null;
13
+ engine?: string | null;
14
+ vnc_port?: number | null;
15
+ /** Written only on create/rebuild, when the daemon mints a fresh one. The
16
+ * viewer authenticates with whatever is recorded here, so a password used
17
+ * but not recorded means "password check failed" at the RFB handshake. */
18
+ vnc_password?: string | null;
19
+ }): Promise<void>;
20
+ /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
21
+ * reading back what it bound is the only race-free way to pick one. */
22
+ export declare function allocateVncPort(): Promise<number>;
23
+ export declare function ensureDirs(desktopId: string): {
24
+ confDir: string;
25
+ workDir: string;
26
+ };
27
+ export declare function startDesktopManager(): void;
28
+ export declare function stopDesktopManager(): void;
29
+ /** Bring a desktop up and wait for it, for the run path. Returns the row when
30
+ * running, null when it could not be started - the caller then falls back to
31
+ * the host rather than failing the run. */
32
+ export declare function ensureRunning(desktopId: string, timeoutMs?: number): Promise<DesktopRow | null>;
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.reconcileAction = reconcileAction;
37
+ exports.listDesktops = listDesktops;
38
+ exports.setStatus = setStatus;
39
+ exports.allocateVncPort = allocateVncPort;
40
+ exports.ensureDirs = ensureDirs;
41
+ exports.startDesktopManager = startDesktopManager;
42
+ exports.stopDesktopManager = stopDesktopManager;
43
+ exports.ensureRunning = ensureRunning;
44
+ // Keeps the desktops the server believes in and the containers that actually
45
+ // exist in agreement. Modelled on projects.ts: a slow poll, no realtime, and
46
+ // every transition reported back so a Studio row reflects the machine.
47
+ const fs = __importStar(require("fs"));
48
+ const net = __importStar(require("net"));
49
+ const supabase_client_1 = require("../supabase-client");
50
+ const store_1 = require("../store");
51
+ const spec_1 = require("./spec");
52
+ const docker_1 = require("./docker");
53
+ // Desktops are not latency-sensitive: a container that died is a rare event
54
+ // and 15s to notice it is fine. Slower interval = less pooler pressure.
55
+ const POLL_INTERVAL_MS = 15_000;
56
+ /** Pure: given what the row claims and what the engine reports, what now?
57
+ * `actual` is null when no such container exists. */
58
+ function reconcileAction(row, actual) {
59
+ // In-flight and terminal states belong to the command runner, not here -
60
+ // reconciling mid-create would fight the thing doing the creating.
61
+ if (row.status === 'creating' || row.status === 'starting'
62
+ || row.status === 'deleting' || row.status === 'pending'
63
+ || row.status === 'failed')
64
+ return 'none';
65
+ if (actual === null) {
66
+ // The row says this exists and it does not. Someone pruned it by hand.
67
+ return row.status === 'running' || row.status === 'stopped' ? 'mark_failed' : 'none';
68
+ }
69
+ if (row.status === 'running' && !actual.running) {
70
+ return row.autostart ? 'start' : 'mark_stopped';
71
+ }
72
+ if (row.status === 'stopped' && actual.running)
73
+ return 'mark_running';
74
+ return 'none';
75
+ }
76
+ function token() { return (0, store_1.readPairing)()?.daemonToken ?? null; }
77
+ async function listDesktops() {
78
+ const t = token();
79
+ if (!t)
80
+ return [];
81
+ return (await (0, supabase_client_1.rpc)('runtime_desktops_list', { p_token: t })) ?? [];
82
+ }
83
+ async function setStatus(id, fields) {
84
+ const t = token();
85
+ if (!t)
86
+ return;
87
+ try {
88
+ await (0, supabase_client_1.rpc)('runtime_desktop_status_update', {
89
+ p_token: t, p_desktop_id: id,
90
+ p_status: fields.status ?? null,
91
+ p_status_message: fields.status_message ?? null,
92
+ p_container_id: fields.container_id ?? null,
93
+ p_engine: fields.engine ?? null,
94
+ p_vnc_port: fields.vnc_port ?? null,
95
+ p_vnc_password: fields.vnc_password ?? null,
96
+ });
97
+ }
98
+ catch (err) {
99
+ console.error('[desktops] status update failed:', err.message);
100
+ }
101
+ }
102
+ /** A free loopback port for this desktop's VNC. Asking the OS for port 0 and
103
+ * reading back what it bound is the only race-free way to pick one. */
104
+ function allocateVncPort() {
105
+ return new Promise((resolve, reject) => {
106
+ const srv = net.createServer();
107
+ srv.once('error', reject);
108
+ srv.listen(0, '127.0.0.1', () => {
109
+ const port = srv.address().port;
110
+ srv.close(() => resolve(port));
111
+ });
112
+ });
113
+ }
114
+ function ensureDirs(desktopId) {
115
+ const confDir = (0, spec_1.confDirFor)(desktopId);
116
+ const workDir = (0, spec_1.workDirFor)(desktopId);
117
+ fs.mkdirSync(confDir, { recursive: true, mode: 0o700 });
118
+ fs.mkdirSync(workDir, { recursive: true, mode: 0o700 });
119
+ return { confDir, workDir };
120
+ }
121
+ let timer = null;
122
+ let inflight = false;
123
+ async function tick() {
124
+ if (inflight)
125
+ return;
126
+ inflight = true;
127
+ try {
128
+ const provider = await (0, docker_1.getProvider)();
129
+ if (!provider)
130
+ return; // no engine: nothing to reconcile
131
+ const rows = await listDesktops();
132
+ for (const row of rows) {
133
+ const actual = await provider.inspect(row);
134
+ const action = reconcileAction(row, actual);
135
+ if (action === 'none')
136
+ continue;
137
+ try {
138
+ if (action === 'start') {
139
+ await provider.start(row);
140
+ await setStatus(row.id, { status: 'running', status_message: null });
141
+ }
142
+ else if (action === 'mark_failed') {
143
+ await setStatus(row.id, {
144
+ status: 'failed',
145
+ status_message: 'Container no longer exists on this machine. Rebuild to recreate it.',
146
+ });
147
+ }
148
+ else if (action === 'mark_stopped') {
149
+ await setStatus(row.id, { status: 'stopped' });
150
+ }
151
+ else if (action === 'mark_running') {
152
+ await setStatus(row.id, {
153
+ status: 'running', container_id: actual?.containerId ?? null, status_message: null,
154
+ });
155
+ }
156
+ }
157
+ catch (err) {
158
+ await setStatus(row.id, {
159
+ status: 'failed', status_message: err.message.slice(0, 400),
160
+ });
161
+ }
162
+ }
163
+ }
164
+ catch (err) {
165
+ console.error('[desktops] reconcile failed:', err.message);
166
+ }
167
+ finally {
168
+ inflight = false;
169
+ }
170
+ }
171
+ function startDesktopManager() {
172
+ if (timer)
173
+ return;
174
+ void tick();
175
+ timer = setInterval(() => { void tick(); }, POLL_INTERVAL_MS);
176
+ }
177
+ function stopDesktopManager() {
178
+ if (timer) {
179
+ clearInterval(timer);
180
+ timer = null;
181
+ }
182
+ }
183
+ /** Bring a desktop up and wait for it, for the run path. Returns the row when
184
+ * running, null when it could not be started - the caller then falls back to
185
+ * the host rather than failing the run. */
186
+ async function ensureRunning(desktopId, timeoutMs = 60_000) {
187
+ const provider = await (0, docker_1.getProvider)();
188
+ if (!provider)
189
+ return null;
190
+ const rows = await listDesktops();
191
+ const row = rows.find(r => r.id === desktopId);
192
+ if (!row)
193
+ return null;
194
+ const actual = await provider.inspect(row);
195
+ if (actual?.running)
196
+ return row;
197
+ if (!actual)
198
+ return null; // never created / pruned: cannot start
199
+ const deadline = Date.now() + timeoutMs;
200
+ try {
201
+ await provider.start(row);
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ while (Date.now() < deadline) {
207
+ const now = await provider.inspect(row);
208
+ if (now?.running) {
209
+ await setStatus(row.id, { status: 'running', status_message: null });
210
+ return row;
211
+ }
212
+ await new Promise(r => setTimeout(r, 1_000));
213
+ }
214
+ return null;
215
+ }
@@ -0,0 +1,35 @@
1
+ import { DesktopRow } from './spec';
2
+ export interface EngineInfo {
3
+ id: 'docker' | 'podman';
4
+ version: string;
5
+ }
6
+ export interface ExecOpts {
7
+ cwd: string;
8
+ env: Record<string, string>;
9
+ /** PTY-driven harnesses need a tty allocated inside the container. */
10
+ tty: boolean;
11
+ }
12
+ export interface CreateOpts {
13
+ confDir: string;
14
+ workDir?: string;
15
+ vncPort: number;
16
+ }
17
+ export interface DesktopProvider {
18
+ readonly id: 'docker' | 'podman';
19
+ probe(): Promise<EngineInfo | null>;
20
+ create(row: DesktopRow, opts: CreateOpts, onLog: (chunk: string) => void): Promise<string>;
21
+ start(row: DesktopRow): Promise<void>;
22
+ stop(row: DesktopRow): Promise<void>;
23
+ remove(row: DesktopRow): Promise<void>;
24
+ /** null when the container does not exist. */
25
+ inspect(row: DesktopRow): Promise<{
26
+ running: boolean;
27
+ containerId: string;
28
+ } | null>;
29
+ }
30
+ /** Docker/Podman object names allow [a-zA-Z0-9][a-zA-Z0-9_.-]*. The id suffix
31
+ * keeps a renamed desktop from colliding with a leftover container of the
32
+ * same name. */
33
+ export declare function containerName(row: Pick<DesktopRow, 'id' | 'name'>): string;
34
+ export declare function buildCreateArgs(row: DesktopRow, opts: CreateOpts): string[];
35
+ export declare function buildExecArgs(row: DesktopRow, cmd: string[], opts: ExecOpts): string[];
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.containerName = containerName;
4
+ exports.buildCreateArgs = buildCreateArgs;
5
+ exports.buildExecArgs = buildExecArgs;
6
+ // The interface every desktop backend implements, plus the pure command
7
+ // builders. Kept free of I/O so the argv construction - the part most likely
8
+ // to be subtly wrong, and the part that behaves differently on Windows - is
9
+ // unit-testable with no container engine installed.
10
+ const spec_1 = require("./spec");
11
+ /** Docker/Podman object names allow [a-zA-Z0-9][a-zA-Z0-9_.-]*. The id suffix
12
+ * keeps a renamed desktop from colliding with a leftover container of the
13
+ * same name. */
14
+ function containerName(row) {
15
+ const slug = row.name.toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, '-')
17
+ .replace(/^-+|-+$/g, '')
18
+ .slice(0, 32) || 'desktop';
19
+ return `ainode-desktop-${slug}-${row.id.slice(0, 8)}`;
20
+ }
21
+ function buildCreateArgs(row, opts) {
22
+ const args = [
23
+ 'run', '-d',
24
+ '--name', containerName(row),
25
+ '--cpus', String(row.cpus),
26
+ '--memory', `${row.memory_mb}m`,
27
+ '--shm-size', '512m', // Chromium crashes on the 64m default
28
+ '--restart', 'unless-stopped',
29
+ // Loopback ONLY. Binding 0.0.0.0 would expose a logged-in desktop to the
30
+ // whole LAN; the relay reaches it from the daemon on the same host.
31
+ '-p', `127.0.0.1:${opts.vncPort}:${spec_1.VNC_PORT_IN_CONTAINER}`,
32
+ '-v', `${opts.confDir}:${spec_1.CONF_ROOT}`,
33
+ ];
34
+ if (opts.workDir)
35
+ args.push('-v', `${opts.workDir}:${spec_1.WORK_ROOT}`);
36
+ if (row.vnc_password)
37
+ args.push('-e', `VNC_PASSWORD=${row.vnc_password}`);
38
+ args.push(row.image);
39
+ return args;
40
+ }
41
+ function buildExecArgs(row, cmd, opts) {
42
+ const args = ['exec', '-i'];
43
+ if (opts.tty)
44
+ args.push('-t');
45
+ args.push('-w', opts.cwd);
46
+ for (const [k, v] of Object.entries(opts.env))
47
+ args.push('-e', `${k}=${v}`);
48
+ args.push(containerName(row));
49
+ // cmd goes in as separate argv entries - never joined into a shell string.
50
+ // A prompt containing quotes, newlines or ESC bytes must survive verbatim.
51
+ args.push(...cmd);
52
+ return args;
53
+ }
@@ -0,0 +1,2 @@
1
+ export declare function startRelayClient(): void;
2
+ export declare function stopRelayClient(): void;
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.startRelayClient = startRelayClient;
40
+ exports.stopRelayClient = stopRelayClient;
41
+ // Dial-out socket to the desktop relay.
42
+ //
43
+ // The daemon connects OUT whenever it has a running desktop, so there is no
44
+ // inbound port and no NAT problem - and because the socket is already open,
45
+ // attaching a viewer is instant rather than waiting on the 30s heartbeat.
46
+ //
47
+ // It carries RAW RFB bytes. The container serves RFB directly (no websockify),
48
+ // so noVNC in the browser speaks RFB across this pipe unchanged.
49
+ const ws_1 = __importDefault(require("ws"));
50
+ const net = __importStar(require("net"));
51
+ const store_1 = require("../store");
52
+ const manager_1 = require("./manager");
53
+ // The deployed relay. Overridable so a node can be pointed at a local one.
54
+ const RELAY_URL = process.env.AINODE_RELAY_URL
55
+ ?? 'wss://desktop-relay-29522465016.europe-west2.run.app';
56
+ const RECONNECT_MIN_MS = 2_000;
57
+ const RECONNECT_MAX_MS = 60_000;
58
+ /** Re-check whether any desktop is running this often while idle. */
59
+ const IDLE_CHECK_MS = 60_000;
60
+ /** How often to prove the socket is still alive. A machine that suspends
61
+ * leaves a half-open socket that never fires 'close', so without this the
62
+ * client sits holding a dead handle and never reconnects. */
63
+ const PING_INTERVAL_MS = 30_000;
64
+ let ws = null;
65
+ let stopped = true;
66
+ let backoff = RECONNECT_MIN_MS;
67
+ let idleTimer = null;
68
+ let liveTimer = null;
69
+ /** Set on every pong; cleared when a ping goes out. Two misses = dead. */
70
+ let missedPongs = 0;
71
+ /** desktopId -> the local TCP socket to that desktop's published RFB port. */
72
+ const bridges = new Map();
73
+ function dropBridges() {
74
+ for (const s of bridges.values()) {
75
+ try {
76
+ s.destroy();
77
+ }
78
+ catch { /* gone */ }
79
+ }
80
+ bridges.clear();
81
+ }
82
+ async function anyDesktopRunning() {
83
+ try {
84
+ return (await (0, manager_1.listDesktops)()).some(d => d.status === 'running');
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ function scheduleReconnect() {
91
+ if (stopped)
92
+ return;
93
+ setTimeout(() => { void connect(); }, backoff);
94
+ backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
95
+ }
96
+ async function connect() {
97
+ if (stopped || ws)
98
+ return;
99
+ const token = (0, store_1.readPairing)()?.daemonToken;
100
+ if (!token) {
101
+ scheduleReconnect();
102
+ return;
103
+ }
104
+ // No point holding a socket open for a machine with nothing to show.
105
+ if (!(await anyDesktopRunning())) {
106
+ idleTimer = setTimeout(() => { void connect(); }, IDLE_CHECK_MS);
107
+ return;
108
+ }
109
+ const sock = new ws_1.default(`${RELAY_URL}/node?token=${encodeURIComponent(token)}`);
110
+ ws = sock;
111
+ sock.on('open', () => {
112
+ backoff = RECONNECT_MIN_MS;
113
+ missedPongs = 0;
114
+ if (liveTimer)
115
+ clearInterval(liveTimer);
116
+ // A suspended Mac comes back with a socket that looks open and is not.
117
+ // Ping until two go unanswered, then tear it down so the normal backoff
118
+ // path reconnects - the daemon's own sleep detector reports the same
119
+ // suspends this is guarding against.
120
+ liveTimer = setInterval(() => {
121
+ if (sock.readyState !== ws_1.default.OPEN) {
122
+ sock.terminate();
123
+ return;
124
+ }
125
+ if (missedPongs >= 2) {
126
+ console.warn('[relay] no pong in 60s — assuming the link died, reconnecting');
127
+ sock.terminate();
128
+ return;
129
+ }
130
+ missedPongs += 1;
131
+ try {
132
+ sock.ping();
133
+ }
134
+ catch {
135
+ sock.terminate();
136
+ }
137
+ }, PING_INTERVAL_MS);
138
+ });
139
+ sock.on('pong', () => { missedPongs = 0; });
140
+ sock.on('message', async (raw) => {
141
+ let msg;
142
+ try {
143
+ msg = JSON.parse(raw.toString());
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ if (msg.type === 'attach') {
149
+ const row = (await (0, manager_1.listDesktops)()).find(d => d.id === msg.desktopId);
150
+ if (!row?.vnc_port)
151
+ return;
152
+ // Loopback only - the container published its RFB on 127.0.0.1 and this
153
+ // process is the only thing on the machine that reaches it.
154
+ const tcp = net.connect(row.vnc_port, '127.0.0.1');
155
+ tcp.on('data', chunk => {
156
+ if (sock.readyState === ws_1.default.OPEN) {
157
+ sock.send(JSON.stringify({
158
+ type: 'data', desktopId: msg.desktopId, b64: chunk.toString('base64'),
159
+ }));
160
+ }
161
+ });
162
+ const forget = () => { bridges.delete(msg.desktopId); };
163
+ tcp.on('close', forget);
164
+ tcp.on('error', forget);
165
+ bridges.get(msg.desktopId)?.destroy();
166
+ bridges.set(msg.desktopId, tcp);
167
+ return;
168
+ }
169
+ if (msg.type === 'data' && msg.b64) {
170
+ bridges.get(msg.desktopId)?.write(Buffer.from(msg.b64, 'base64'));
171
+ return;
172
+ }
173
+ if (msg.type === 'detach') {
174
+ bridges.get(msg.desktopId)?.destroy();
175
+ bridges.delete(msg.desktopId);
176
+ }
177
+ });
178
+ const retry = () => {
179
+ if (liveTimer) {
180
+ clearInterval(liveTimer);
181
+ liveTimer = null;
182
+ }
183
+ if (ws !== sock)
184
+ return; // superseded by a newer socket
185
+ dropBridges();
186
+ ws = null;
187
+ scheduleReconnect();
188
+ };
189
+ sock.on('close', retry);
190
+ sock.on('error', retry);
191
+ }
192
+ function startRelayClient() {
193
+ if (!stopped)
194
+ return;
195
+ stopped = false;
196
+ backoff = RECONNECT_MIN_MS;
197
+ void connect();
198
+ }
199
+ function stopRelayClient() {
200
+ stopped = true;
201
+ if (idleTimer) {
202
+ clearTimeout(idleTimer);
203
+ idleTimer = null;
204
+ }
205
+ if (liveTimer) {
206
+ clearInterval(liveTimer);
207
+ liveTimer = null;
208
+ }
209
+ dropBridges();
210
+ try {
211
+ ws?.close();
212
+ }
213
+ catch { /* already closing */ }
214
+ ws = null;
215
+ }
@@ -0,0 +1,37 @@
1
+ export interface DesktopRow {
2
+ id: string;
3
+ runtime_id: string;
4
+ name: string;
5
+ image: string;
6
+ engine: 'docker' | 'podman' | null;
7
+ container_id: string | null;
8
+ cpus: number;
9
+ memory_mb: number;
10
+ disk_gb: number;
11
+ setup_script: string | null;
12
+ enabled_mcps: string[];
13
+ enabled_skills: string[];
14
+ enabled_packages: string[];
15
+ autostart: boolean;
16
+ status: 'pending' | 'creating' | 'starting' | 'running' | 'stopped' | 'failed' | 'deleting';
17
+ status_message: string | null;
18
+ vnc_port: number | null;
19
+ vnc_password: string | null;
20
+ }
21
+ /** Workspace root inside the container. Projects clone to /work/<name>,
22
+ * session cwds live at /work/.sessions/<requestId>. */
23
+ export declare const WORK_ROOT = "/work";
24
+ /** Where the daemon's generated files (MCP config, memory pack, attachments)
25
+ * appear inside the container. Bind-mounted from the host. */
26
+ export declare const CONF_ROOT = "/conf";
27
+ /** The container's RAW RFB port, fixed by the image.
28
+ *
29
+ * Raw, not websockified: the relay carries these bytes over its own
30
+ * WebSocket and noVNC in the browser speaks RFB across it. Putting
31
+ * websockify in the container too would mean a WebSocket handshake tunnelled
32
+ * inside a WebSocket, which no client can read. */
33
+ export declare const VNC_PORT_IN_CONTAINER = 5900;
34
+ /** Host directory bind-mounted at CONF_ROOT for one desktop. */
35
+ export declare function confDirFor(desktopId: string): string;
36
+ /** Host directory holding the desktop's persistent /work volume. */
37
+ export declare function workDirFor(desktopId: string): string;
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.VNC_PORT_IN_CONTAINER = exports.CONF_ROOT = exports.WORK_ROOT = void 0;
37
+ exports.confDirFor = confDirFor;
38
+ exports.workDirFor = workDirFor;
39
+ // Shapes and paths shared by every desktop module. No I/O here.
40
+ const path = __importStar(require("path"));
41
+ const paths_1 = require("../paths");
42
+ /** Workspace root inside the container. Projects clone to /work/<name>,
43
+ * session cwds live at /work/.sessions/<requestId>. */
44
+ exports.WORK_ROOT = '/work';
45
+ /** Where the daemon's generated files (MCP config, memory pack, attachments)
46
+ * appear inside the container. Bind-mounted from the host. */
47
+ exports.CONF_ROOT = '/conf';
48
+ /** The container's RAW RFB port, fixed by the image.
49
+ *
50
+ * Raw, not websockified: the relay carries these bytes over its own
51
+ * WebSocket and noVNC in the browser speaks RFB across it. Putting
52
+ * websockify in the container too would mean a WebSocket handshake tunnelled
53
+ * inside a WebSocket, which no client can read. */
54
+ exports.VNC_PORT_IN_CONTAINER = 5900;
55
+ /** Host directory bind-mounted at CONF_ROOT for one desktop. */
56
+ function confDirFor(desktopId) {
57
+ return path.join(paths_1.RUNTIME_HOME, 'desktops', desktopId, 'conf');
58
+ }
59
+ /** Host directory holding the desktop's persistent /work volume. */
60
+ function workDirFor(desktopId) {
61
+ return path.join(paths_1.RUNTIME_HOME, 'desktops', desktopId, 'work');
62
+ }
package/dist/index.js CHANGED
@@ -57,6 +57,8 @@ const sleep_detector_1 = require("./sleep-detector");
57
57
  const claude_config_1 = require("./claude-config");
58
58
  const request_pump_1 = require("./request-pump");
59
59
  const projects_1 = require("./projects");
60
+ const manager_1 = require("./desktop/manager");
61
+ const relay_client_1 = require("./desktop/relay-client");
60
62
  const session_runner_1 = require("./session-runner");
61
63
  const diskguard_1 = require("./diskguard");
62
64
  const pty_helper_1 = require("./pty-helper");
@@ -206,6 +208,10 @@ async function start(argv = []) {
206
208
  (0, request_pump_1.start)();
207
209
  // …and for pending projects (git clones, future MCP/skill installs).
208
210
  (0, projects_1.start)();
211
+ // …and to keep desktops and the containers behind them in agreement.
212
+ (0, manager_1.startDesktopManager)();
213
+ // …and dial out to the relay so a running desktop is watchable from Studio.
214
+ (0, relay_client_1.startRelayClient)();
209
215
  let stopped = false;
210
216
  // Outcome of the last drain, so a remote roll can report whether it left
211
217
  // work unfinished instead of claiming a clean restart either way.
@@ -217,6 +223,8 @@ async function start(argv = []) {
217
223
  // Stop accepting new work first — otherwise the pump would queue
218
224
  // another request between drain start and process exit.
219
225
  (0, projects_1.stop)();
226
+ (0, manager_1.stopDesktopManager)();
227
+ (0, relay_client_1.stopRelayClient)();
220
228
  (0, request_pump_1.stop)();
221
229
  (0, heartbeat_1.stop)();
222
230
  (0, auto_update_1.stop)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "node-pty": "^1.1.0",
53
- "ws": "^8.21.1"
53
+ "ws": "^8.21.3"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "^20.0.0",