@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.
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.js +4 -1
- package/dist/command-runner.js +120 -0
- package/dist/desktop/creds.d.ts +7 -0
- package/dist/desktop/creds.js +134 -0
- package/dist/desktop/docker.d.ts +19 -0
- package/dist/desktop/docker.js +99 -0
- package/dist/desktop/engine.d.ts +3 -0
- package/dist/desktop/engine.js +44 -0
- package/dist/desktop/launcher.d.ts +30 -0
- package/dist/desktop/launcher.js +35 -0
- package/dist/desktop/manager.d.ts +32 -0
- package/dist/desktop/manager.js +215 -0
- package/dist/desktop/provider.d.ts +35 -0
- package/dist/desktop/provider.js +53 -0
- package/dist/desktop/relay-client.d.ts +2 -0
- package/dist/desktop/relay-client.js +215 -0
- package/dist/desktop/spec.d.ts +37 -0
- package/dist/desktop/spec.js +62 -0
- package/dist/index.js +8 -0
- package/package.json +2 -2
package/dist/capabilities.d.ts
CHANGED
|
@@ -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;
|
package/dist/capabilities.js
CHANGED
|
@@ -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),
|
package/dist/command-runner.js
CHANGED
|
@@ -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,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,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.
|
|
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.
|
|
53
|
+
"ws": "^8.21.3"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "^20.0.0",
|