@addai/node 0.11.3 → 0.13.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.
@@ -0,0 +1,139 @@
1
+ import { type HarnessId } from './harness-registry';
2
+ import { type LaunchMode } from './self-update';
3
+ /**
4
+ * A stable per-node offset into the hour, derived from the daemon token.
5
+ *
6
+ * The fleet has to be spread across the hour rather than converging on :00 —
7
+ * with no soak window, the stagger is the ONLY thing limiting how fast a bad
8
+ * publish reaches everyone. Deriving it from the token (rather than random)
9
+ * means a node keeps its slot across restarts, including the restart an
10
+ * auto-roll itself causes, which would otherwise re-converge every rolled
11
+ * node onto the same minute.
12
+ */
13
+ export declare function tickOffsetMs(token: string): number;
14
+ /**
15
+ * Milliseconds until this node's next slot, measured against the wall clock
16
+ * rather than against boot time — so a daemon that restarts twice an hour
17
+ * keeps firing on its own slot instead of drifting to a new one each time.
18
+ */
19
+ export declare function msUntilNextTick(nowMs: number, offsetMs: number): number;
20
+ export type SkipReason = 'disabled' | 'busy' | 'breaker';
21
+ /**
22
+ * The guards, in the order they are cheapest to check.
23
+ *
24
+ * `busy` is a skip, NOT a drain: an auto-update is elective and there is
25
+ * always another hour, whereas draining would take the node out of rotation
26
+ * for up to ten minutes every time the tick happened to land mid-turn. A node
27
+ * that is never idle never auto-updates, and gets rolled by hand.
28
+ */
29
+ export declare function shouldRun(s: {
30
+ enabled: boolean;
31
+ busy: boolean;
32
+ breakerOpen: boolean;
33
+ }): SkipReason | null;
34
+ export interface HarnessUpgrade {
35
+ id: HarnessId;
36
+ npmPackage: string;
37
+ }
38
+ /**
39
+ * Which harnesses this tick will `npm i -g …@latest`.
40
+ *
41
+ * Two rules, both deliberate:
42
+ * - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
43
+ * so it is never touched here — a truthful skip beats a fake upgrade.
44
+ * - installed only. The tick upgrades; it does not provision. Installing a
45
+ * harness nobody asked for is a different feature, and one with a cost
46
+ * (disk, a login prompt that never comes) the user never opted into.
47
+ */
48
+ export declare function planHarnessUpgrades(installed: HarnessId[]): HarnessUpgrade[];
49
+ export interface RollDecision {
50
+ roll: boolean;
51
+ /** Why not, when roll is false. Logged, and surfaced in capabilities. */
52
+ reason?: string;
53
+ }
54
+ /**
55
+ * Whether to roll the daemon itself.
56
+ *
57
+ * A source checkout is refused for the same reason a remote version bump is
58
+ * (see planRoll): its version comes from the working tree, so installing and
59
+ * restarting would come back up on exactly the same bits — a no-op wearing a
60
+ * success. Harnesses on such a node are still upgraded; only this half is off.
61
+ */
62
+ export declare function planAutoRoll(current: string, latest: string | null, mode: LaunchMode): RollDecision;
63
+ export interface HarnessOutcome {
64
+ from?: string;
65
+ to?: string;
66
+ /** Where the binary resolves NOW. You chose npm-for-every-npm-able-harness,
67
+ * so on a machine whose Claude Code came from Anthropic's native installer
68
+ * npm has just put a second copy somewhere else — possibly shadowing the
69
+ * first. The path is what makes that visible instead of silent. */
70
+ path?: string;
71
+ error?: string;
72
+ }
73
+ export interface AutoUpdateState {
74
+ enabled: boolean;
75
+ last_checked_at?: string;
76
+ last_result?: 'ok' | 'skipped' | 'failed';
77
+ skipped_reason?: SkipReason;
78
+ /** Set when a roll actually happened — written by the daemon that came up
79
+ * after it, from the restart handoff. */
80
+ runtime?: {
81
+ from: string;
82
+ to: string;
83
+ wanted?: string;
84
+ } | null;
85
+ harnesses?: Record<string, HarnessOutcome>;
86
+ error?: string;
87
+ }
88
+ /** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
89
+ * page already reads — no new RPC, no new column, no polling. */
90
+ export declare function autoUpdateState(): AutoUpdateState;
91
+ /** Called by index.ts on boot when the handoff it consumed had no command id,
92
+ * i.e. the roll was ours rather than a button press. */
93
+ export declare function recordAutoRoll(r: {
94
+ from: string;
95
+ to: string;
96
+ wanted?: string;
97
+ }): void;
98
+ /** Only the part of a capabilities probe this module reads. Kept structural
99
+ * rather than importing CapabilitiesShape, which would put capabilities.ts on
100
+ * both ends of an import cycle (it reads autoUpdateState from here). */
101
+ type ProbeShape = Partial<Record<HarnessId, {
102
+ version?: string;
103
+ available?: boolean;
104
+ }>>;
105
+ export interface AutoUpdateDeps {
106
+ /** The daemon token — only used to derive this node's slot. */
107
+ token: () => string | null;
108
+ /** True while this node holds a child process for a live run. */
109
+ isBusy: () => boolean;
110
+ /** The daemon-wide Supabase circuit breaker. */
111
+ breakerOpen: () => boolean;
112
+ /** Whether the server says auto-update is on for this node. */
113
+ enabled: () => boolean;
114
+ /** Full capabilities probe — used for before/after harness versions. */
115
+ probe: () => Promise<ProbeShape>;
116
+ /** Push a heartbeat so Studio sees the new versions without waiting. */
117
+ beat: () => Promise<void>;
118
+ /** The same closure `update_runtime` uses. No command id: nobody asked. */
119
+ roll: (opts: {
120
+ version: string;
121
+ }) => Promise<void>;
122
+ /** This daemon's own version. */
123
+ version: string;
124
+ /** `npm i -g <spec>`; null on success. Injectable so the orchestration below
125
+ * — the before/after diff, the busy re-check — can be tested without
126
+ * actually installing anything. */
127
+ install?: (spec: string) => Promise<string | null>;
128
+ /** What npm calls latest for a package, or null if we couldn't ask. */
129
+ latest?: (pkg: string) => Promise<string | null>;
130
+ /** How this daemon was launched. Injectable because under a test runner
131
+ * argv[1] is the runner, which would read as `source` and mask the roll. */
132
+ mode?: () => LaunchMode;
133
+ }
134
+ /** One pass. Exported so `ainode` can be made to do it on demand later, and
135
+ * so a test can drive it without waiting an hour. */
136
+ export declare function runOnce(d: AutoUpdateDeps): Promise<void>;
137
+ export declare function start(d: AutoUpdateDeps): void;
138
+ export declare function stop(): void;
139
+ export {};
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ // Hourly auto-update — this node keeps itself, and its harnesses, current.
3
+ //
4
+ // Until now a node only moved when somebody clicked. `update_runtime` issued
5
+ // by hand from Entity Studio was the ONLY thing that ever rolled a daemon,
6
+ // which is how the fleet came to sit on 0.2.51/0.2.52/0.2.53 for days after
7
+ // 0.2.55 shipped. Harnesses were worse: Studio offers Install only while a
8
+ // harness reads `missing`, so once Claude Code was on a node it could never
9
+ // be upgraded from anywhere.
10
+ //
11
+ // The schedule lives here rather than in a server-side cron because a node
12
+ // asleep, behind NAT, or between pairings then needs no special case — the
13
+ // daemon already wakes on its own for reclaim, diskguard and sleep detection.
14
+ //
15
+ // Everything the tick needs from the rest of the daemon is INJECTED by
16
+ // index.ts (roll, probe, beat, isBusy, isEnabled). That keeps this module's
17
+ // imports down to the registry and the npm helpers, so the decisions below
18
+ // are testable without booting a daemon — and avoids the
19
+ // auto-update → heartbeat → capabilities → auto-update cycle that importing
20
+ // them directly would create.
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.tickOffsetMs = tickOffsetMs;
23
+ exports.msUntilNextTick = msUntilNextTick;
24
+ exports.shouldRun = shouldRun;
25
+ exports.planHarnessUpgrades = planHarnessUpgrades;
26
+ exports.planAutoRoll = planAutoRoll;
27
+ exports.autoUpdateState = autoUpdateState;
28
+ exports.recordAutoRoll = recordAutoRoll;
29
+ exports.runOnce = runOnce;
30
+ exports.start = start;
31
+ exports.stop = stop;
32
+ const harness_registry_1 = require("./harness-registry");
33
+ const self_update_1 = require("./self-update");
34
+ const HOUR_MS = 60 * 60 * 1000;
35
+ /** Nothing auto-rolls in the first few minutes of a boot. A node that just
36
+ * came up may BE the result of a roll (or of one that went badly), and
37
+ * rolling again immediately would turn a bad publish into a restart loop. */
38
+ const BOOT_GRACE_MS = 5 * 60 * 1000;
39
+ /* ── when ─────────────────────────────────────────────────────────────── */
40
+ /**
41
+ * A stable per-node offset into the hour, derived from the daemon token.
42
+ *
43
+ * The fleet has to be spread across the hour rather than converging on :00 —
44
+ * with no soak window, the stagger is the ONLY thing limiting how fast a bad
45
+ * publish reaches everyone. Deriving it from the token (rather than random)
46
+ * means a node keeps its slot across restarts, including the restart an
47
+ * auto-roll itself causes, which would otherwise re-converge every rolled
48
+ * node onto the same minute.
49
+ */
50
+ function tickOffsetMs(token) {
51
+ // FNV-1a. Not cryptographic — it only has to spread evenly and be stable.
52
+ let h = 0x811c9dc5;
53
+ for (let i = 0; i < token.length; i++) {
54
+ h ^= token.charCodeAt(i);
55
+ h = Math.imul(h, 0x01000193) >>> 0;
56
+ }
57
+ return h % HOUR_MS;
58
+ }
59
+ /**
60
+ * Milliseconds until this node's next slot, measured against the wall clock
61
+ * rather than against boot time — so a daemon that restarts twice an hour
62
+ * keeps firing on its own slot instead of drifting to a new one each time.
63
+ */
64
+ function msUntilNextTick(nowMs, offsetMs) {
65
+ const since = (((nowMs - offsetMs) % HOUR_MS) + HOUR_MS) % HOUR_MS;
66
+ const wait = HOUR_MS - since;
67
+ // since === 0 lands exactly on the slot; wait a full hour rather than
68
+ // firing twice for the same slot.
69
+ return wait === 0 ? HOUR_MS : wait;
70
+ }
71
+ /**
72
+ * The guards, in the order they are cheapest to check.
73
+ *
74
+ * `busy` is a skip, NOT a drain: an auto-update is elective and there is
75
+ * always another hour, whereas draining would take the node out of rotation
76
+ * for up to ten minutes every time the tick happened to land mid-turn. A node
77
+ * that is never idle never auto-updates, and gets rolled by hand.
78
+ */
79
+ function shouldRun(s) {
80
+ if (!s.enabled)
81
+ return 'disabled';
82
+ if (s.breakerOpen)
83
+ return 'breaker';
84
+ if (s.busy)
85
+ return 'busy';
86
+ return null;
87
+ }
88
+ /**
89
+ * Which harnesses this tick will `npm i -g …@latest`.
90
+ *
91
+ * Two rules, both deliberate:
92
+ * - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
93
+ * so it is never touched here — a truthful skip beats a fake upgrade.
94
+ * - installed only. The tick upgrades; it does not provision. Installing a
95
+ * harness nobody asked for is a different feature, and one with a cost
96
+ * (disk, a login prompt that never comes) the user never opted into.
97
+ */
98
+ function planHarnessUpgrades(installed) {
99
+ const present = new Set(installed);
100
+ return harness_registry_1.HARNESS_IDS
101
+ .filter(id => present.has(id) && harness_registry_1.HARNESSES[id].npmPackage !== null)
102
+ .map(id => ({ id, npmPackage: harness_registry_1.HARNESSES[id].npmPackage }));
103
+ }
104
+ /**
105
+ * Whether to roll the daemon itself.
106
+ *
107
+ * A source checkout is refused for the same reason a remote version bump is
108
+ * (see planRoll): its version comes from the working tree, so installing and
109
+ * restarting would come back up on exactly the same bits — a no-op wearing a
110
+ * success. Harnesses on such a node are still upgraded; only this half is off.
111
+ */
112
+ function planAutoRoll(current, latest, mode) {
113
+ if (mode === 'source')
114
+ return { roll: false, reason: 'source checkout — pull and rebuild it there' };
115
+ if (!latest)
116
+ return { roll: false, reason: 'could not read the latest published version' };
117
+ if (latest === current)
118
+ return { roll: false, reason: 'already latest' };
119
+ return { roll: true };
120
+ }
121
+ let state = { enabled: true };
122
+ let isEnabled = () => true;
123
+ /** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
124
+ * page already reads — no new RPC, no new column, no polling. */
125
+ function autoUpdateState() {
126
+ return { ...state, enabled: isEnabled() };
127
+ }
128
+ /** Called by index.ts on boot when the handoff it consumed had no command id,
129
+ * i.e. the roll was ours rather than a button press. */
130
+ function recordAutoRoll(r) {
131
+ state = { ...state, runtime: r, last_result: 'ok', last_checked_at: new Date().toISOString() };
132
+ }
133
+ let timer = null;
134
+ let stopped = false;
135
+ let deps = null;
136
+ function harnessVersion(probe, id) {
137
+ const p = probe[id];
138
+ return p?.available ? p.version : undefined;
139
+ }
140
+ function installedHarnesses(probe) {
141
+ return harness_registry_1.HARNESS_IDS.filter(id => probe[id]?.available === true);
142
+ }
143
+ /** One pass. Exported so `ainode` can be made to do it on demand later, and
144
+ * so a test can drive it without waiting an hour. */
145
+ async function runOnce(d) {
146
+ const skip = shouldRun({ enabled: d.enabled(), busy: d.isBusy(), breakerOpen: d.breakerOpen() });
147
+ if (skip) {
148
+ state = { ...state, last_checked_at: new Date().toISOString(), last_result: 'skipped', skipped_reason: skip };
149
+ if (skip !== 'breaker')
150
+ console.log(`[auto-update] skipped — ${skip === 'busy' ? 'the node is mid-run' : 'turned off for this node'}`);
151
+ return;
152
+ }
153
+ const checkedAt = new Date().toISOString();
154
+ const harnesses = {};
155
+ let failed;
156
+ const install = d.install ?? self_update_1.npmInstallGlobal;
157
+ const latestOf = d.latest ?? self_update_1.npmLatestVersion;
158
+ /* ── harnesses first — nothing restarts, so this half always lands ──── */
159
+ try {
160
+ const before = await d.probe();
161
+ const upgrades = planHarnessUpgrades(installedHarnesses(before));
162
+ for (const u of upgrades) {
163
+ const from = harnessVersion(before, u.id);
164
+ const err = await install(`${u.npmPackage}@latest`);
165
+ harnesses[u.id] = { from, ...(err ? { error: err } : {}) };
166
+ if (err)
167
+ console.error(`[auto-update] ${u.id}: ${err}`);
168
+ }
169
+ if (upgrades.length > 0) {
170
+ // The finders cached their pre-upgrade lookups; a probe through stale
171
+ // caches would report the OLD version and hide the upgrade entirely.
172
+ (0, harness_registry_1.resetBinaryCaches)();
173
+ const after = await d.probe();
174
+ for (const u of upgrades) {
175
+ const to = harnessVersion(after, u.id);
176
+ const path = harness_registry_1.HARNESSES[u.id].findBinary() ?? undefined;
177
+ harnesses[u.id] = { ...harnesses[u.id], to, path };
178
+ if (harnesses[u.id].from !== to) {
179
+ console.log(`[auto-update] ${u.id} ${harnesses[u.id].from ?? '?'} → ${to ?? '?'} (${path ?? 'not resolvable'})`);
180
+ }
181
+ }
182
+ await d.beat();
183
+ }
184
+ }
185
+ catch (err) {
186
+ failed = `harness upgrade: ${err.message}`;
187
+ console.error(`[auto-update] ${failed}`);
188
+ }
189
+ state = {
190
+ ...state,
191
+ last_checked_at: checkedAt,
192
+ last_result: failed ? 'failed' : 'ok',
193
+ skipped_reason: undefined,
194
+ harnesses,
195
+ error: failed,
196
+ };
197
+ /* ── then the daemon itself ─────────────────────────────────────────── */
198
+ const mode = d.mode ? d.mode() : (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
199
+ const latest = mode === 'source' ? null : await latestOf(self_update_1.PACKAGE_NAME);
200
+ const decision = planAutoRoll(d.version, latest, mode);
201
+ if (!decision.roll) {
202
+ if (decision.reason !== 'already latest')
203
+ console.log(`[auto-update] not rolling — ${decision.reason}`);
204
+ return;
205
+ }
206
+ // Re-check busy: the harness upgrades above can take minutes, and a run may
207
+ // well have arrived while npm was working. Rolling now would guillotine it
208
+ // at the drain ceiling for no reason — there is another hour.
209
+ if (d.isBusy()) {
210
+ state = { ...state, last_result: 'skipped', skipped_reason: 'busy' };
211
+ console.log('[auto-update] a run arrived during the harness pass — leaving the roll for next hour');
212
+ return;
213
+ }
214
+ console.log(`[auto-update] rolling ${d.version} → ${latest}`);
215
+ try {
216
+ // This does not return: the roll drains, hands over, and exits. The
217
+ // daemon that comes up completes the story from the handoff file.
218
+ await d.roll({ version: latest });
219
+ }
220
+ catch (err) {
221
+ state = { ...state, last_result: 'failed', error: `roll: ${err.message}` };
222
+ console.error(`[auto-update] roll failed — staying on ${d.version}:`, err.message);
223
+ }
224
+ }
225
+ function schedule(firstRun) {
226
+ if (stopped || !deps)
227
+ return;
228
+ const token = deps.token() ?? '';
229
+ const offset = tickOffsetMs(token);
230
+ let wait = msUntilNextTick(Date.now(), offset);
231
+ if (firstRun)
232
+ wait = Math.max(wait, BOOT_GRACE_MS);
233
+ timer = setTimeout(() => {
234
+ void (async () => {
235
+ try {
236
+ await runOnce(deps);
237
+ }
238
+ catch (err) {
239
+ console.error('[auto-update] tick crashed:', err.message);
240
+ }
241
+ schedule(false);
242
+ })();
243
+ }, wait);
244
+ // Never keep the process alive just to auto-update.
245
+ timer.unref?.();
246
+ }
247
+ function start(d) {
248
+ if (timer || stopped)
249
+ return;
250
+ deps = d;
251
+ isEnabled = d.enabled;
252
+ state = { ...state, enabled: d.enabled() };
253
+ schedule(true);
254
+ }
255
+ function stop() {
256
+ stopped = true;
257
+ if (timer) {
258
+ clearTimeout(timer);
259
+ timer = null;
260
+ }
261
+ }
@@ -1,4 +1,7 @@
1
+ import type { EngineInfo } from './desktop/provider';
1
2
  import { type AutostartState } from './autostart';
3
+ import { type AutoUpdateState } from './auto-update';
4
+ import { type MachineSample } from './machine-metrics';
2
5
  interface AuthShape {
3
6
  authed: boolean;
4
7
  account?: string;
@@ -42,6 +45,17 @@ interface CapabilitiesShape {
42
45
  * rather than a column of its own — same channel Studio already reads the
43
46
  * harness grid from. */
44
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;
52
+ /** CPU / memory / disk / runs at the moment of this probe. The AiNode page
53
+ * has read this key since it was written; nothing produced it until now. */
54
+ machine?: MachineSample;
55
+ /** What the hourly auto-update last did — versions moved, skips, failures.
56
+ * Same channel, same reason: without it the AiNode page is somewhere the
57
+ * version silently changes with no explanation. */
58
+ auto_update?: AutoUpdateState;
45
59
  }
46
60
  /** Codex's credential store: $CODEX_HOME (or ~/.codex) + /auth.json. */
47
61
  export declare function codexHome(): string;
@@ -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"));
@@ -53,6 +54,8 @@ const codex_binary_1 = require("./codex-binary");
53
54
  const win_1 = require("./win");
54
55
  const harness_registry_1 = require("./harness-registry");
55
56
  const autostart_1 = require("./autostart");
57
+ const auto_update_1 = require("./auto-update");
58
+ const machine_metrics_1 = require("./machine-metrics");
56
59
  function readDaemonVersion() {
57
60
  try {
58
61
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -375,13 +378,14 @@ async function probeCapabilities() {
375
378
  // heartbeat — that would report the runtime permanently offline.
376
379
  const shield = (p, fallback) => p.catch(() => fallback);
377
380
  const unavailable = { available: false, authed: false };
378
- const [claude, codex, kimi, gemini, grok, git] = await Promise.all([
381
+ const [claude, codex, kimi, gemini, grok, git, containerEngine] = await Promise.all([
379
382
  shield(probeClaude(), unavailable),
380
383
  shield(probeCodex(), unavailable),
381
384
  shield(probeKimi(), unavailable),
382
385
  shield(probeGemini(), unavailable),
383
386
  shield(probeGrok(), unavailable),
384
387
  shield(probeGit(), unavailable),
388
+ shield((0, engine_1.detectEngine)(), null),
385
389
  ]);
386
390
  // Decorate each harness with its registry facts (efforts, installability,
387
391
  // login strategy) so Studio and the TUI render from one source of truth.
@@ -398,6 +402,9 @@ async function probeCapabilities() {
398
402
  return {
399
403
  daemon_version: readDaemonVersion(),
400
404
  autostart: (0, autostart_1.status)(),
405
+ container_engine: containerEngine,
406
+ auto_update: (0, auto_update_1.autoUpdateState)(),
407
+ machine: (0, machine_metrics_1.sampleMachine)(),
401
408
  claude: deco('claude', claude),
402
409
  codex: deco('codex', codex),
403
410
  kimi: deco('kimi', kimi),
@@ -25,6 +25,14 @@ export interface CodexInput {
25
25
  * Mirrors the claude --strict-mcp-config behavior. */
26
26
  mcpServers?: CodexMcpServer[];
27
27
  }
28
+ /** Build a config.toml that declares only the supplied MCP servers and
29
+ * return the CODEX_HOME directory plus any env vars the child needs
30
+ * (bearer tokens for remote servers). Caller is responsible for cleanup
31
+ * (we drop it under os.tmpdir() so it gets reaped). */
32
+ export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string): {
33
+ home: string;
34
+ extraEnv: Record<string, string>;
35
+ };
28
36
  export interface CodexHandle {
29
37
  pid: number | undefined;
30
38
  threadId: string | null;
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  };
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.writeCodexHome = writeCodexHome;
43
44
  exports.codexEffort = codexEffort;
44
45
  exports.spawnCodex = spawnCodex;
45
46
  const child_process_1 = require("child_process");
@@ -54,15 +55,58 @@ const events_1 = require("./events");
54
55
  function tomlString(s) {
55
56
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
56
57
  }
58
+ /** Env var name carrying an HTTP MCP's bearer token into the codex process.
59
+ * Only the NAME goes in config.toml — the value rides on the child env, so
60
+ * the token never lands on disk. */
61
+ const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
57
62
  /** Build a config.toml that declares only the supplied MCP servers and
58
- * return the directory path to use as CODEX_HOME. Caller is responsible
59
- * for cleanup (we drop it under os.tmpdir() so it gets reaped). */
63
+ * return the CODEX_HOME directory plus any env vars the child needs
64
+ * (bearer tokens for remote servers). Caller is responsible for cleanup
65
+ * (we drop it under os.tmpdir() so it gets reaped). */
60
66
  function writeCodexHome(servers, workingDirectory) {
61
67
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entities-codex-'));
68
+ const extraEnv = {};
62
69
  let toml = '# Generated by ainode — sandboxes codex against host config\n\n';
63
70
  for (const s of servers) {
64
71
  if (!s.slug || !s.command)
65
72
  continue;
73
+ // Remote MCP servers. Registry rows store command='http'|'sse' with
74
+ // args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
75
+ // the row verbatim made codex try to exec a binary called `http`, so the
76
+ // server never started and its tools silently vanished — which is how a
77
+ // provider-ladder hop to codex dropped an entity's GitHub access without
78
+ // any error anywhere. The claude path (install.ts) always handled this;
79
+ // codex and grok did not.
80
+ if (s.command === 'http' || s.command === 'sse') {
81
+ const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
82
+ if (!url) {
83
+ console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
84
+ continue;
85
+ }
86
+ const name = tomlString(s.slug).slice(1, -1);
87
+ toml += `[mcp_servers.${name}]\n`;
88
+ toml += `url = ${tomlString(url)}\n`;
89
+ // codex reads the bearer from its OWN environment (--env is stdio-only),
90
+ // so the value goes on the child env and only the var name in the file.
91
+ const creds = Object.entries(s.env || {})
92
+ .filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
93
+ const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
94
+ if (bearer) {
95
+ const varName = bearerEnvVar(s.slug);
96
+ extraEnv[varName] = String(bearer[1]);
97
+ toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
98
+ if (creds.length > 1) {
99
+ // codex carries one bearer and no arbitrary headers. Anything that
100
+ // authenticates with several custom headers is only partly wired,
101
+ // and saying so beats a server that 401s for no visible reason.
102
+ console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
103
+ `codex cannot send custom headers, so ${creds.length - 1} other credential ` +
104
+ `field(s) were dropped`);
105
+ }
106
+ }
107
+ toml += '\n';
108
+ continue;
109
+ }
66
110
  // On native Windows, registry commands like `npx` are .cmd shims that
67
111
  // codex can't CreateProcess directly — wrap with `cmd /c` (no-op on POSIX).
68
112
  const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
@@ -109,7 +153,7 @@ function writeCodexHome(servers, workingDirectory) {
109
153
  if (fs.existsSync(sessionSkillsDir)) {
110
154
  (0, win_1.linkDir)(sessionSkillsDir, path.join(dir, 'skills'));
111
155
  }
112
- return dir;
156
+ return { home: dir, extraEnv };
113
157
  }
114
158
  function lineToEvents(line) {
115
159
  const type = typeof line.type === 'string' ? line.type : '';
@@ -194,7 +238,7 @@ function spawnCodex(input) {
194
238
  // Sandbox codex against an isolated CODEX_HOME so the host's
195
239
  // ~/.codex/config.toml mcp_servers don't leak in. TERM=dumb keeps
196
240
  // codex's TTY heuristics happy (no fancy terminal output).
197
- const codexHome = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
241
+ const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
198
242
  // Register the scratch dir with DiskGuard: marks it active (so a
199
243
  // concurrent sweep won't evict a live run) and writes a PID lock so a
200
244
  // different runtime process can tell it's still owned. dispose(true)
@@ -205,6 +249,9 @@ function spawnCodex(input) {
205
249
  ...process.env,
206
250
  TERM: 'dumb',
207
251
  CODEX_HOME: codexHome,
252
+ // Bearer tokens for remote (http/sse) MCP servers. config.toml names these
253
+ // vars via bearer_token_env_var; the values only ever live here.
254
+ ...mcpBearerEnv,
208
255
  };
209
256
  // Resolve binary path before spawn. Previously called spawn('codex', ...)
210
257
  // bare, which threw ENOENT under launchd / desktop-app PATH inheritance
@@ -1,5 +1,7 @@
1
1
  export type RestartHook = (opts: {
2
- commandId: string;
2
+ /** Absent when the hourly auto-update is the one rolling: nobody issued a
3
+ * command, so there is nothing to close on the other side. */
4
+ commandId?: string;
3
5
  version: string;
4
6
  /** Bounce this daemon on the version it is already running: no npm
5
7
  * install, no version change. See runUpdateRuntime. */