@addai/node 0.16.0 → 0.19.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.
@@ -64,6 +64,7 @@ const docker_1 = require("./desktop/docker");
64
64
  const manager_1 = require("./desktop/manager");
65
65
  const creds_1 = require("./desktop/creds");
66
66
  const install_engine_1 = require("./desktop/install-engine");
67
+ const engine_1 = require("./desktop/engine");
67
68
  const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
68
69
  /** Own version, read the same way index.ts does. Resolved here rather than
69
70
  * imported from index.ts, which already imports this module. */
@@ -692,8 +693,51 @@ async function runInstallEngine(cmd) {
692
693
  };
693
694
  await update(cmd.id, 'running', { log });
694
695
  try {
695
- await (0, install_engine_1.installEngine)(onLog);
696
+ // Installed but unreachable is a permissions problem, not a missing one -
697
+ // the same button must repair rather than reinstall.
698
+ const before = await (0, engine_1.detectEngine)();
699
+ const blocked = !!before && !before.running;
700
+ const outcome = await (0, install_engine_1.installEngine)(onLog, async () => {
701
+ // Park the command and let Studio collect it, exactly as a harness
702
+ // login collects a paste-back code.
703
+ await update(cmd.id, 'awaiting_input', { needs: 'sudo_password', log });
704
+ const deadline = Date.now() + INPUT_WAIT_MS;
705
+ while (Date.now() < deadline) {
706
+ const row = await pollRow(cmd.id);
707
+ if (!row)
708
+ break;
709
+ if (row.status === 'canceled')
710
+ return null;
711
+ const pw = row.input?.sudo_password;
712
+ if (pw) {
713
+ // Wipe it from the row the moment we hold it. It exists in one
714
+ // process's memory from here, and nowhere else.
715
+ const t = token();
716
+ if (t) {
717
+ try {
718
+ await (0, supabase_client_1.rpc)('runtime_command_clear_input', { p_token: t, p_id: cmd.id });
719
+ }
720
+ catch { /* best effort — the install still must not print it */ }
721
+ }
722
+ await update(cmd.id, 'running', { needs: null, log });
723
+ return pw;
724
+ }
725
+ await new Promise(r => setTimeout(r, INPUT_POLL_MS));
726
+ }
727
+ return null;
728
+ }, blocked);
696
729
  (0, docker_1.resetProviderCache)();
730
+ if (outcome.needsRestart) {
731
+ // Group membership only applies to new processes, so this daemon still
732
+ // cannot reach the socket. Say so plainly and bounce it.
733
+ onLog('\nPermissions fixed. Restarting this machine\'s node so it picks up the new group…\n');
734
+ await update(cmd.id, 'completed', { log, restarting: true });
735
+ await (0, heartbeat_1.beat)();
736
+ if (restartHook) {
737
+ await restartHook({ commandId: cmd.id, version: VERSION, restartOnly: true });
738
+ }
739
+ return;
740
+ }
697
741
  const engine = await (0, docker_1.getProvider)(true);
698
742
  if (!engine) {
699
743
  await update(cmd.id, 'failed', { log }, 'Installed, but no engine is answering yet. On macOS, Docker Desktop has to be opened once before its daemon runs.');
@@ -35,7 +35,7 @@ class CliProvider {
35
35
  });
36
36
  }
37
37
  async probe() {
38
- return { id: this.id, version: this.version };
38
+ return { id: this.id, version: this.version, running: true };
39
39
  }
40
40
  async create(row, opts, onLog) {
41
41
  // Pull first so the slow, chatty image download streams into the command
@@ -93,7 +93,10 @@ async function getProvider(force = false) {
93
93
  if (!force && cached !== undefined)
94
94
  return cached;
95
95
  const info = await (0, engine_1.detectEngine)();
96
- cached = info ? new CliProvider(info.id, info.version) : null;
96
+ // An engine whose daemon is not answering is not a provider: every call
97
+ // through it would fail at the socket. Studio reports the reason from the
98
+ // capability probe instead.
99
+ cached = info?.running ? new CliProvider(info.id, info.version) : null;
97
100
  return cached;
98
101
  }
99
102
  function resetProviderCache() { cached = undefined; }
@@ -1,3 +1,15 @@
1
1
  import { EngineInfo } from './provider';
2
2
  export declare function parseEngineVersion(stdout: string): string | null;
3
+ /** Why an installed engine still cannot run anything. The three causes are
4
+ * worth telling apart, because the fix for each is different and none of
5
+ * them is "install Docker". */
6
+ export declare function explainUnreachable(output: string, platform: NodeJS.Platform): string;
7
+ /**
8
+ * Is there an engine here that can actually RUN something?
9
+ *
10
+ * `--version` is not enough: the CLI answers it with no daemon at all, so a
11
+ * machine where Docker Desktop is installed-but-closed, or where the user is
12
+ * not in the docker group, would report itself ready and then fail on every
13
+ * create. `info` is the question that needs the daemon, so it is the one asked.
14
+ */
3
15
  export declare function detectEngine(): Promise<EngineInfo | null>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseEngineVersion = parseEngineVersion;
4
+ exports.explainUnreachable = explainUnreachable;
4
5
  exports.detectEngine = detectEngine;
5
6
  // Which container engine is on this machine? Docker first (the verified
6
7
  // path), Podman second. Never installs anything: installing Docker Desktop
@@ -12,33 +13,61 @@ function parseEngineVersion(stdout) {
12
13
  const m = /version\s+v?(\d+\.\d+\.\d+)/i.exec(stdout);
13
14
  return m ? m[1] : null;
14
15
  }
15
- function runVersion(binary) {
16
+ /** Run a command and hand back exit code + output. */
17
+ function run(binary, args) {
16
18
  return new Promise(resolve => {
17
19
  try {
18
- const inv = (0, win_1.resolveCliInvocation)(binary, ['--version']);
20
+ const inv = (0, win_1.resolveCliInvocation)(binary, args);
19
21
  const child = (0, child_process_1.spawn)(inv.file, inv.args, {
20
22
  env: process.env, windowsHide: true, timeout: 8_000,
21
23
  });
22
24
  let out = '';
23
25
  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
+ child.stderr?.on('data', b => { out += b.toString('utf8'); });
27
+ child.on('error', () => resolve({ ok: false, out: '' }));
28
+ child.on('exit', code => resolve({ ok: code === 0, out }));
26
29
  }
27
30
  catch {
28
31
  // resolveCliInvocation throws when a Windows shim can't be resolved -
29
32
  // that is "not installed" as far as this probe is concerned.
30
- resolve(null);
33
+ resolve({ ok: false, out: '' });
31
34
  }
32
35
  });
33
36
  }
37
+ /** Why an installed engine still cannot run anything. The three causes are
38
+ * worth telling apart, because the fix for each is different and none of
39
+ * them is "install Docker". */
40
+ function explainUnreachable(output, platform) {
41
+ const t = output.toLowerCase();
42
+ if (t.includes('permission denied') || t.includes('dial unix')) {
43
+ return 'Docker is installed but this user cannot reach it. Add the user to the docker group:\n sudo usermod -aG docker $USER (then log out and back in)';
44
+ }
45
+ if (platform === 'darwin')
46
+ return 'Docker is installed but not running. Open Docker Desktop once and leave it running.';
47
+ if (platform === 'win32')
48
+ return 'Docker is installed but not running. Start Docker Desktop and let it finish starting.';
49
+ return 'Docker is installed but its daemon is not answering. Start it:\n sudo systemctl start docker';
50
+ }
51
+ /**
52
+ * Is there an engine here that can actually RUN something?
53
+ *
54
+ * `--version` is not enough: the CLI answers it with no daemon at all, so a
55
+ * machine where Docker Desktop is installed-but-closed, or where the user is
56
+ * not in the docker group, would report itself ready and then fail on every
57
+ * create. `info` is the question that needs the daemon, so it is the one asked.
58
+ */
34
59
  async function detectEngine() {
35
60
  for (const id of ['docker', 'podman']) {
36
- const out = await runVersion(id);
37
- if (!out)
61
+ const v = await run(id, ['--version']);
62
+ if (!v.ok)
63
+ continue;
64
+ const version = parseEngineVersion(v.out);
65
+ if (!version)
38
66
  continue;
39
- const version = parseEngineVersion(out);
40
- if (version)
41
- return { id, version };
67
+ const info = await run(id, ['info', '--format', '{{.ServerVersion}}']);
68
+ if (info.ok)
69
+ return { id, version, running: true };
70
+ return { id, version, running: false, reason: explainUnreachable(info.out, process.platform) };
42
71
  }
43
72
  return null;
44
73
  }
@@ -8,5 +8,28 @@ export interface InstallPlan {
8
8
  }
9
9
  /** Pure, so the choice is testable on any machine. */
10
10
  export declare function installPlan(platform: NodeJS.Platform, isRoot: boolean): InstallPlan;
11
+ /** Docker is there but this user cannot reach its socket. The fix is group
12
+ * membership, not another install — and it needs root, so it takes the same
13
+ * password round trip. */
14
+ export declare function repairPlan(user: string): {
15
+ inner: string;
16
+ manual: string;
17
+ };
18
+ /** Group membership is fixed for FUTURE processes only, so the daemon that
19
+ * asked for the fix still cannot reach the socket until it restarts. */
20
+ export declare const REPAIR_NEEDS_RESTART = true;
11
21
  export declare function runningAsRoot(): boolean;
12
- export declare function installEngine(onLog: (s: string) => void): Promise<void>;
22
+ /** Build the argv for a privileged install. The password NEVER goes in argv —
23
+ * argv is world-readable through /proc on Linux, so anyone with a shell on
24
+ * the box could read it. `-S` makes sudo take it on stdin instead, and
25
+ * `-p ''` stops it printing a prompt into the log we stream back. */
26
+ export declare function sudoArgv(inner: string): {
27
+ file: string;
28
+ args: string[];
29
+ };
30
+ /** Install if it is missing; fix permissions if it is merely unreachable.
31
+ * One button, because from the outside they are the same complaint: this
32
+ * machine cannot host desktops. Returns whether a restart is now needed. */
33
+ export declare function installEngine(onLog: (s: string) => void, askPassword?: () => Promise<string | null>, alreadyInstalledButBlocked?: boolean): Promise<{
34
+ needsRestart: boolean;
35
+ }>;
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REPAIR_NEEDS_RESTART = void 0;
3
4
  exports.installPlan = installPlan;
5
+ exports.repairPlan = repairPlan;
4
6
  exports.runningAsRoot = runningAsRoot;
7
+ exports.sudoArgv = sudoArgv;
5
8
  exports.installEngine = installEngine;
6
9
  // Install a container engine on this machine, on request.
7
10
  //
@@ -36,27 +39,95 @@ function installPlan(platform, isRoot) {
36
39
  manual: 'curl -fsSL https://get.docker.com | sudo sh', needsRoot: true,
37
40
  };
38
41
  }
42
+ /** Docker is there but this user cannot reach its socket. The fix is group
43
+ * membership, not another install — and it needs root, so it takes the same
44
+ * password round trip. */
45
+ function repairPlan(user) {
46
+ const u = user.replace(/[^A-Za-z0-9._-]/g, '');
47
+ return {
48
+ inner: `usermod -aG docker ${u}`,
49
+ manual: `sudo usermod -aG docker ${u}`,
50
+ };
51
+ }
52
+ /** Group membership is fixed for FUTURE processes only, so the daemon that
53
+ * asked for the fix still cannot reach the socket until it restarts. */
54
+ exports.REPAIR_NEEDS_RESTART = true;
39
55
  function runningAsRoot() {
40
56
  return typeof process.getuid === 'function' && process.getuid() === 0;
41
57
  }
42
- async function installEngine(onLog) {
58
+ /** Build the argv for a privileged install. The password NEVER goes in argv —
59
+ * argv is world-readable through /proc on Linux, so anyone with a shell on
60
+ * the box could read it. `-S` makes sudo take it on stdin instead, and
61
+ * `-p ''` stops it printing a prompt into the log we stream back. */
62
+ function sudoArgv(inner) {
63
+ return { file: 'sudo', args: ['-S', '-p', '', 'sh', '-c', inner] };
64
+ }
65
+ /** Install if it is missing; fix permissions if it is merely unreachable.
66
+ * One button, because from the outside they are the same complaint: this
67
+ * machine cannot host desktops. Returns whether a restart is now needed. */
68
+ async function installEngine(onLog, askPassword, alreadyInstalledButBlocked) {
43
69
  const plan = installPlan(process.platform, runningAsRoot());
44
- if (!plan.file) {
70
+ let file = plan.file;
71
+ let args = plan.args;
72
+ let password = null;
73
+ // Docker present but the socket is refused: repair, do not reinstall.
74
+ const repair = alreadyInstalledButBlocked
75
+ ? repairPlan(process.env.USER || process.env.LOGNAME || 'root')
76
+ : null;
77
+ if (repair) {
78
+ if (runningAsRoot()) {
79
+ onLog(`fixing permissions\n ${repair.inner}\n\n`);
80
+ file = 'sh';
81
+ args = ['-c', repair.inner];
82
+ }
83
+ else if (askPassword) {
84
+ onLog(`This machine needs a sudo password to fix Docker permissions.\n ${repair.manual}\n\n`);
85
+ password = await askPassword();
86
+ if (!password)
87
+ throw new Error(`No password given. Run it on the machine instead:\n ${repair.manual}`);
88
+ const sudo = sudoArgv(repair.inner);
89
+ file = sudo.file;
90
+ args = sudo.args;
91
+ }
92
+ else {
93
+ throw new Error(`Needs root. Run this on the machine:\n ${repair.manual}`);
94
+ }
95
+ }
96
+ if (!file && plan.needsRoot && askPassword) {
97
+ onLog('This machine needs a sudo password to install Docker.\n');
98
+ password = await askPassword();
99
+ if (!password) {
100
+ throw new Error(`No password given. Run it on the machine instead:\n ${plan.manual}`);
101
+ }
102
+ const sudo = sudoArgv(plan.args[1] ?? '');
103
+ file = sudo.file;
104
+ args = sudo.args;
105
+ }
106
+ if (!file) {
45
107
  throw new Error(`This needs root, and the node is not running as root. Run this on ${require('os').hostname()}:\n ${plan.manual}`);
46
108
  }
47
109
  onLog(`installing a container engine\n ${plan.manual}\n\n`);
48
- const inv = (0, win_1.resolveCliInvocation)(plan.file, plan.args);
110
+ const inv = (0, win_1.resolveCliInvocation)(file, args);
49
111
  await new Promise((resolve, reject) => {
50
112
  const child = (0, child_process_1.spawn)(inv.file, inv.args, {
51
113
  env: process.env, windowsHide: true, timeout: 25 * 60_000,
114
+ stdio: password ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
52
115
  });
116
+ if (password) {
117
+ // Straight down stdin and gone. It is never echoed, never logged, and
118
+ // the caller wipes it from the row as soon as it reaches here.
119
+ child.stdin?.write(`${password}\n`);
120
+ child.stdin?.end();
121
+ password = null;
122
+ }
53
123
  let tail = '';
54
124
  const cap = (b) => { const s = b.toString('utf8'); tail = (tail + s).slice(-2000); onLog(s); };
55
125
  child.stdout?.on('data', cap);
56
126
  child.stderr?.on('data', cap);
57
- child.on('error', err => reject(new Error(`${plan.file} is not available here. Install it by hand:\n ${plan.manual}\n(${err.message})`)));
127
+ child.on('error', err => reject(new Error(`${file} is not available here. Install it by hand:\n ${plan.manual}\n(${err.message})`)));
58
128
  child.on('exit', code => code === 0
59
129
  ? resolve()
60
- : reject(new Error(`install failed (exit ${code}). Try by hand:\n ${plan.manual}\n${tail.slice(-400)}`)));
130
+ : reject(new Error(`${repair ? 'permission fix' : 'install'} failed (exit ${code}). Try by hand:\n ${repair ? repair.manual : plan.manual}\n${tail.slice(-400)}`)));
61
131
  });
132
+ return { needsRestart: !!repair && exports.REPAIR_NEEDS_RESTART };
62
133
  }
@@ -2,6 +2,11 @@ import { DesktopRow } from './spec';
2
2
  export interface EngineInfo {
3
3
  id: 'docker' | 'podman';
4
4
  version: string;
5
+ /** The CLI exists AND its daemon answers. False means installed but
6
+ * unusable — a different problem, with a different fix, than missing. */
7
+ running: boolean;
8
+ /** Set when running is false: what to do about it, in words. */
9
+ reason?: string;
5
10
  }
6
11
  export interface ExecOpts {
7
12
  cwd: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@addai/node",
3
- "version": "0.16.0",
3
+ "version": "0.19.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": [