@runuai/host 0.9.98 → 0.9.100

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.
@@ -106,14 +106,14 @@ import {
106
106
  } from "./browser-testing";
107
107
  import { injectCodexIntoContainer } from "./codex-auth";
108
108
  import {
109
+ codexHomeIsolatable,
110
+ codexHomeSeedCommand,
111
+ codexPerAgentHome,
109
112
  cooldownEngineAccount,
110
113
  noteEngineAccountUsed,
111
114
  pickEngineAccount,
112
115
  provisionEngineAccounts,
113
116
  resolveEngineAccounts,
114
- codexHomeIsolatable,
115
- codexPerAgentHome,
116
- codexHomeSeedCommand,
117
117
  type ResolvedEngineAccount,
118
118
  } from "./engine-accounts";
119
119
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
@@ -133,6 +133,7 @@ import type {
133
133
  HostEvent,
134
134
  } from "../src/protocol";
135
135
  import {
136
+ HOST_ACCOUNT_SCOPE,
136
137
  MAX_AGENT_ID_CHARS,
137
138
  MAX_SECRETARY_DISPATCH_ID_CHARS,
138
139
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
@@ -155,6 +156,9 @@ const RESTRICTED_SECRETARY_SESSION_ATTACH_KEY =
155
156
  interface Channel {
156
157
  taskId: string;
157
158
  roster: Roster;
159
+ /** ADR-131 D3: the engine-account scope this task's agents resolve
160
+ * credentials from. `host` unless the cloud scoped the task to a workspace. */
161
+ engineAccountScope: string;
158
162
  /** ADR-083: host-derived role assignment for execution-profile enforcement. */
159
163
  mode: "open" | "secretary";
160
164
  secretaryAgentId?: string;
@@ -527,6 +531,7 @@ export class Orchestrator {
527
531
  channel.browserTesting = spec.browserTesting === true;
528
532
  channel.sharedFiles = spec.sharedFiles ?? "ro";
529
533
  channel.mcpConnections = spec.mcpConnections ?? [];
534
+ if (spec.engineAccountScope) channel.engineAccountScope = spec.engineAccountScope;
530
535
  const nextMcpShape = JSON.stringify({
531
536
  connections: channel.mcpConnections,
532
537
  kinds: [...new Set(channel.roster.map((agent) => agent.kind))].sort(),
@@ -640,6 +645,10 @@ export class Orchestrator {
640
645
  const channel: Channel = {
641
646
  taskId,
642
647
  roster,
648
+ engineAccountScope:
649
+ spec.engineAccountScope ??
650
+ getHostTask(taskId)?.engineAccountScope ??
651
+ HOST_ACCOUNT_SCOPE,
643
652
  mode: spec.mode === "secretary" ? "secretary" : "open",
644
653
  secretaryAgentId: spec.secretaryAgentId,
645
654
  sessions: new Map(),
@@ -797,9 +806,9 @@ export class Orchestrator {
797
806
  * copies use this gate: a false→true toggle queues migration after the older
798
807
  * copy, so that copy cannot land last and erase the managed browser block.
799
808
  */
800
- private codexHomes(hasCodex: boolean): string[] {
809
+ private codexHomes(hasCodex: boolean, scope: string): string[] {
801
810
  if (!hasCodex) return [];
802
- const homes = resolveEngineAccounts("codex")
811
+ const homes = resolveEngineAccounts("codex", {}, scope)
803
812
  .map((account) => account.configDir?.containerDir)
804
813
  .filter((home): home is string => Boolean(home));
805
814
  return [...new Set([DEFAULT_CODEX_HOME, ...homes])];
@@ -825,9 +834,9 @@ export class Orchestrator {
825
834
  if (this.isActiveChannel(channel)) channel.configGeneration += 1;
826
835
  }
827
836
 
828
- private provisionFingerprint(kinds: readonly string[]): string {
837
+ private provisionFingerprint(kinds: readonly string[], scope: string): string {
829
838
  const accounts = kinds.flatMap((kind) =>
830
- resolveEngineAccounts(kind)
839
+ resolveEngineAccounts(kind, {}, scope)
831
840
  .filter((account) => !account.isDefault && account.configDir)
832
841
  .map((account) => ({
833
842
  kind,
@@ -864,15 +873,15 @@ export class Orchestrator {
864
873
  const requestedHasCodex =
865
874
  hasCodex || channel.roster.some((agent) => agent.kind === "codex");
866
875
  const requestedHomes = requestedBrowser
867
- ? this.codexHomes(requestedHasCodex)
876
+ ? this.codexHomes(requestedHasCodex, channel.engineAccountScope)
868
877
  : [];
869
878
  const requestedKinds = [
870
879
  ...new Set(channel.roster.map((agent) => agent.kind)),
871
880
  ];
872
881
  const requestedProvisionFingerprint =
873
- this.provisionFingerprint(requestedKinds);
882
+ this.provisionFingerprint(requestedKinds, channel.engineAccountScope);
874
883
  const mayProvisionConfig = requestedKinds.some((kind) =>
875
- resolveEngineAccounts(kind).some(
884
+ resolveEngineAccounts(kind, {}, channel.engineAccountScope).some(
876
885
  (account) => !account.isDefault && Boolean(account.configDir),
877
886
  ),
878
887
  );
@@ -891,13 +900,13 @@ export class Orchestrator {
891
900
  requestedHasCodex ||
892
901
  channel.roster.some((agent) => agent.kind === "codex");
893
902
  const latestHomes = latestBrowser
894
- ? this.codexHomes(latestHasCodex)
903
+ ? this.codexHomes(latestHasCodex, channel.engineAccountScope)
895
904
  : [];
896
905
  const latestKinds = [
897
906
  ...new Set(channel.roster.map((agent) => agent.kind)),
898
907
  ];
899
908
  const latestProvisionFingerprint =
900
- this.provisionFingerprint(latestKinds);
909
+ this.provisionFingerprint(latestKinds, channel.engineAccountScope);
901
910
  if (
902
911
  (latestBrowser &&
903
912
  coverage?.browserAttempted !== true &&
@@ -949,10 +958,14 @@ export class Orchestrator {
949
958
  requestedKinds,
950
959
  {},
951
960
  channel.agentHandle,
961
+ channel.engineAccountScope,
952
962
  )
953
963
  : await provisionEngineAccounts(
954
964
  channel.containerName,
955
965
  requestedKinds,
966
+ {},
967
+ undefined,
968
+ channel.engineAccountScope,
956
969
  );
957
970
  copiedOrUnknown =
958
971
  provisioned.copied > 0 || provisioned.mayHaveMutated;
@@ -1075,12 +1088,15 @@ export class Orchestrator {
1075
1088
  requestedHasCodex ||
1076
1089
  channel.roster.some((agent) => agent.kind === "codex");
1077
1090
  const latestHomes = latestBrowser
1078
- ? this.codexHomes(latestHasCodex)
1091
+ ? this.codexHomes(latestHasCodex, channel.engineAccountScope)
1079
1092
  : [];
1080
1093
  const latestKinds = [
1081
1094
  ...new Set(channel.roster.map((agent) => agent.kind)),
1082
1095
  ];
1083
- const latestProvisionFingerprint = this.provisionFingerprint(latestKinds);
1096
+ const latestProvisionFingerprint = this.provisionFingerprint(
1097
+ latestKinds,
1098
+ channel.engineAccountScope,
1099
+ );
1084
1100
  if (
1085
1101
  (latestBrowser &&
1086
1102
  !coverage.browserAttempted &&
@@ -1169,7 +1185,7 @@ export class Orchestrator {
1169
1185
  ...new Set(channel.roster.map((agent) => agent.kind)),
1170
1186
  ].sort();
1171
1187
  const codexHomes = engineKinds.includes("codex")
1172
- ? this.codexHomes(true)
1188
+ ? this.codexHomes(true, channel.engineAccountScope)
1173
1189
  : [];
1174
1190
  const fingerprint = JSON.stringify({
1175
1191
  connections,
@@ -1529,7 +1545,9 @@ export class Orchestrator {
1529
1545
  agent: RosterAgent,
1530
1546
  base: Record<string, string>,
1531
1547
  ): Record<string, string> {
1532
- const account = pickEngineAccount(agent.kind);
1548
+ const account = pickEngineAccount(agent.kind, {
1549
+ scope: channel.engineAccountScope,
1550
+ });
1533
1551
  if (!account) {
1534
1552
  channel.accountByAgent.delete(agent.id);
1535
1553
  return base;
@@ -2598,7 +2616,7 @@ export class Orchestrator {
2598
2616
  );
2599
2617
  const boundAccountId = channel.accountByAgent.get(agentId);
2600
2618
  const boundAccount = boundAccountId
2601
- ? resolveEngineAccounts(agent.kind).find(
2619
+ ? resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope).find(
2602
2620
  (account) => account.id === boundAccountId,
2603
2621
  )
2604
2622
  : undefined;
@@ -2667,7 +2685,9 @@ export class Orchestrator {
2667
2685
  // A concurrent terminal event for the same session is already being
2668
2686
  // handled by the path that owns this spawn slot.
2669
2687
  if (channel.spawning.has(agentId)) return true;
2670
- if (resolveEngineAccounts(agent.kind).length < 2) return false;
2688
+ if (resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope).length < 2) {
2689
+ return false;
2690
+ }
2671
2691
 
2672
2692
  const budget = (channel.rotations.get(agentId) ?? 0) + 1;
2673
2693
  channel.rotations.set(agentId, budget);
@@ -2676,6 +2696,7 @@ export class Orchestrator {
2676
2696
  const current = channel.accountByAgent.get(agentId);
2677
2697
  if (current) cooldownEngineAccount(current);
2678
2698
  const next = pickEngineAccount(agent.kind, {
2699
+ scope: channel.engineAccountScope,
2679
2700
  exclude: current ? new Set([current]) : undefined,
2680
2701
  });
2681
2702
  if (!next) return false;
@@ -2918,7 +2939,7 @@ export class Orchestrator {
2918
2939
  return;
2919
2940
  }
2920
2941
 
2921
- const accounts = resolveEngineAccounts(agent.kind);
2942
+ const accounts = resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope);
2922
2943
  const account =
2923
2944
  accounts.find((a) => a.id === channel.accountByAgent.get(agentId)) ??
2924
2945
  accounts[0];
@@ -231,12 +231,14 @@ export function recordTaskOwner(
231
231
  ownerEmail: string | null,
232
232
  ownerName: string | null,
233
233
  projectSlugs: string[],
234
+ engineAccountScope: string | null = null,
234
235
  ): void {
235
236
  upsertHostTask(taskId, {
236
237
  ownerUserId,
237
238
  ownerEmail,
238
239
  ownerName,
239
240
  projectSlugs: JSON.stringify(projectSlugs),
241
+ engineAccountScope,
240
242
  });
241
243
  }
242
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.98",
3
+ "version": "0.9.100",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -35,9 +35,21 @@ import {
35
35
 
36
36
  export const LABEL = "com.runuai.host";
37
37
 
38
- const uid = process.getuid?.() ?? 0;
39
- const domain = `gui/${uid}`;
40
- const target = `${domain}/${LABEL}`;
38
+ /** The per-user launchd domain the LaunchAgent is bootstrapped into. */
39
+ export function launchdDomain(uid: number = process.getuid?.() ?? 0): string {
40
+ return `gui/${uid}`;
41
+ }
42
+
43
+ /** The job as `launchctl` addresses it — `gui/<uid>/com.runuai.host`. The
44
+ * service's own activation respawn (`src/activation-respawn.ts`) targets the
45
+ * same string, so the label and domain cannot drift between the CLI and the
46
+ * running host. */
47
+ export function launchdJobTarget(uid: number = process.getuid?.() ?? 0): string {
48
+ return `${launchdDomain(uid)}/${LABEL}`;
49
+ }
50
+
51
+ const domain = launchdDomain();
52
+ const target = launchdJobTarget();
41
53
  const logDir = resolve(homedir(), "Library", "Logs", "Uai");
42
54
 
43
55
  export function plistPath(): string {
@@ -934,12 +946,82 @@ export async function restartDarwinService(
934
946
  }
935
947
  }
936
948
 
949
+ /** What `launchctl print gui/<uid>/com.runuai.host` says about the job. */
950
+ export interface LaunchdJobState {
951
+ readonly loaded: boolean;
952
+ /** `state = …` verbatim: `running`, `not running`, `waiting`, … */
953
+ readonly state: string | null;
954
+ readonly pid: number | null;
955
+ /** `last exit code = …` verbatim, e.g. `75: EX_TEMPFAIL` or `(never exited)`. */
956
+ readonly lastExitCode: string | null;
957
+ /** `pended nondemand spawn = …` — launchd holds a spawn it has not executed
958
+ * (live 2026-09-06: `inefficient`, with the job `not running` after an
959
+ * exit-75 activation restart that KeepAlive never honoured). */
960
+ readonly pendedNondemandSpawn: string | null;
961
+ /** Why the probe failed, when it did not confirm a loaded job. */
962
+ readonly detail: string | null;
963
+ }
964
+
965
+ function launchdPrintField(stdout: string, name: string): string | null {
966
+ // Top-level keys are indented by exactly one tab; nested dicts (endpoints,
967
+ // event triggers) reuse names like `state = active` two tabs deep, so the
968
+ // job's own key is preferred and the loose match is only a fallback. The
969
+ // whitespace class is horizontal only, so `state` never matches the tail
970
+ // of `spawn type` or another longer key.
971
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
972
+ const value = `(.+?)[ \\t]*(?:\\r?\\n|$)`;
973
+ return (
974
+ new RegExp(`(?:^|\\n)\\t${escaped} = ${value}`).exec(stdout)?.[1] ??
975
+ new RegExp(`(?:^|\\n)[ \\t]*${escaped} = ${value}`).exec(stdout)?.[1] ??
976
+ null
977
+ );
978
+ }
979
+
980
+ export function parseLaunchdJobPrint(stdout: string): LaunchdJobState {
981
+ const pid = launchdPrintField(stdout, "pid");
982
+ return {
983
+ loaded: true,
984
+ state: launchdPrintField(stdout, "state"),
985
+ pid: pid !== null && /^\d+$/.test(pid) ? Number(pid) : null,
986
+ lastExitCode: launchdPrintField(stdout, "last exit code"),
987
+ pendedNondemandSpawn: launchdPrintField(stdout, "pended nondemand spawn"),
988
+ detail: null,
989
+ };
990
+ }
991
+
992
+ /** One `launchctl print` of the host job, parsed. Read-only: safe to call
993
+ * from the running service (a boot after an activation logs it so a pended
994
+ * respawn is visible in the service log rather than only in `launchctl`). */
995
+ export function describeLaunchdJob(execute: StepExecutor = executeStep): LaunchdJobState {
996
+ const result = execute(false, "launchctl", ["print", target]);
997
+ if (result.status !== 0) {
998
+ return {
999
+ loaded: false,
1000
+ state: null,
1001
+ pid: null,
1002
+ lastExitCode: null,
1003
+ pendedNondemandSpawn: null,
1004
+ detail: resultDetail(result) || null,
1005
+ };
1006
+ }
1007
+ return parseLaunchdJobPrint(result.stdout);
1008
+ }
1009
+
1010
+ export function formatLaunchdJobState(job: LaunchdJobState): string {
1011
+ if (!job.loaded) return `not loaded${job.detail ? ` (${job.detail})` : ""}`;
1012
+ const parts = [`state = ${job.state ?? "unknown"}`];
1013
+ if (job.pid !== null) parts.push(`pid = ${job.pid}`);
1014
+ if (job.lastExitCode !== null) parts.push(`last exit code = ${job.lastExitCode}`);
1015
+ if (job.pendedNondemandSpawn !== null) {
1016
+ parts.push(`pended nondemand spawn = ${job.pendedNondemandSpawn}`);
1017
+ }
1018
+ return parts.join(", ");
1019
+ }
1020
+
937
1021
  async function status(): Promise<string> {
938
- const out = step(false, "launchctl", ["print", target], { ignoreError: true });
939
- if (!out) return "not installed / not loaded";
940
- const state = /state = (\S+)/.exec(out)?.[1] ?? "unknown";
941
- const pid = /pid = (\d+)/.exec(out)?.[1];
942
- return `${state}${pid ? ` (pid ${pid})` : ""}`;
1022
+ const job = describeLaunchdJob();
1023
+ if (!job.loaded) return "not installed / not loaded";
1024
+ return `${job.state ?? "unknown"}${job.pid !== null ? ` (pid ${job.pid})` : ""}`;
943
1025
  }
944
1026
 
945
1027
  export const installer: Installer = {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The macOS activation restart must not depend on launchd KeepAlive.
3
+ *
4
+ * A staged runtime activation (the in-app Update, `uai-host update`, the
5
+ * remote hostRestart) drains in-flight work and exits with
6
+ * MANAGED_UPDATE_RESTART_EXIT_CODE (75) so the service supervisor relaunches
7
+ * `runtime/current`. On Linux `Restart=on-failure` does exactly that. On
8
+ * macOS the LaunchAgent is `KeepAlive: true` — and twice on 2026-09-06 launchd
9
+ * did NOT respawn the job: `launchctl print gui/501/com.runuai.host` sat at
10
+ * `state = not running`, `last exit code = 75: EX_TEMPFAIL`,
11
+ * `pended nondemand spawn = inefficient` until the operator ran
12
+ * `uai-host start` (a plain `launchctl kickstart` of the job), which recovered
13
+ * it instantly.
14
+ *
15
+ * So the exiting process schedules that kickstart itself, from a child that
16
+ * survives it: a detached (`setsid`, so it leaves the job's process group and
17
+ * launchd's exit-time group cleanup cannot reach it), unref'd, stdio-less
18
+ * shell that sleeps a few seconds and then kickstarts the job. Plain
19
+ * `kickstart` first — a no-op when KeepAlive did respawn, so a fresh instance
20
+ * is never killed mid-boot — and `kickstart -k` as the fallback should launchd
21
+ * refuse the plain one (kill whatever is there and start again; by then
22
+ * nothing of ours should be running).
23
+ *
24
+ * It is scheduled only where it can matter: darwin, not under the desktop
25
+ * app's supervisor (which relaunches the agent on exit itself), and only when
26
+ * launchd is this process's parent — a `uai-host run` in a terminal, or a test
27
+ * spawning the CLI, has nothing to kick and must never touch a live service.
28
+ */
29
+
30
+ import { spawn as nodeSpawn } from "node:child_process";
31
+
32
+ import { launchdJobTarget } from "../scripts/install/darwin";
33
+
34
+ /** Long enough for a KeepAlive respawn, if launchd honours it, to have
35
+ * happened first (so the plain kickstart is a no-op); short enough that the
36
+ * host is back within seconds when it does not. */
37
+ export const ACTIVATION_RESPAWN_DELAY_SECONDS = 5;
38
+
39
+ export const ACTIVATION_RESPAWN_SHELL = "/bin/sh";
40
+ /** Absolute: the detached shell inherits the service environment, and a
41
+ * launchd-spawned job's PATH is whatever the plist captured at install. */
42
+ export const ACTIVATION_RESPAWN_LAUNCHCTL = "/bin/launchctl";
43
+ /** `$1` = delay in seconds, `$2` = the launchd job target. Positional, never
44
+ * interpolated, so the command line stays inert whatever the inputs. */
45
+ export const ACTIVATION_RESPAWN_SCRIPT = `sleep "$1"; ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart "$2" || exec ${ACTIVATION_RESPAWN_LAUNCHCTL} kickstart -k "$2"`;
46
+
47
+ export interface ActivationRespawnChild {
48
+ on(event: "error", listener: (error: Error) => void): unknown;
49
+ unref(): void;
50
+ }
51
+
52
+ export interface ActivationRespawnSpawnOptions {
53
+ readonly detached: true;
54
+ readonly stdio: "ignore";
55
+ }
56
+
57
+ export type ActivationRespawnSpawn = (
58
+ command: string,
59
+ args: readonly string[],
60
+ options: ActivationRespawnSpawnOptions,
61
+ ) => ActivationRespawnChild;
62
+
63
+ export interface ActivationRespawnSeams {
64
+ platform?: NodeJS.Platform;
65
+ /** `process.ppid`; launchd is pid 1 and the parent of every job it runs. */
66
+ parentPid?: number;
67
+ /** `process.getuid()`; `undefined` models a platform without uids. */
68
+ uid?: number | undefined;
69
+ /** `process.versions.electron` — the desktop app runs the agent as a
70
+ * supervised child and restarts it on exit itself. */
71
+ underElectron?: boolean;
72
+ delaySeconds?: number;
73
+ spawn?: ActivationRespawnSpawn;
74
+ }
75
+
76
+ export type ActivationRespawnPlan =
77
+ | {
78
+ readonly scheduled: true;
79
+ readonly target: string;
80
+ readonly command: string;
81
+ readonly args: readonly string[];
82
+ readonly delaySeconds: number;
83
+ }
84
+ | { readonly scheduled: false; readonly reason: string };
85
+
86
+ /**
87
+ * Call immediately before `process.exit(MANAGED_UPDATE_RESTART_EXIT_CODE)`:
88
+ * the drain is complete, so the delay counts from the moment the old process
89
+ * is gone. `spawn` forks synchronously, so the child exists when this returns
90
+ * and the caller may exit at once.
91
+ */
92
+ export function scheduleActivationRespawn(
93
+ seams: ActivationRespawnSeams = {},
94
+ ): ActivationRespawnPlan {
95
+ const platform = seams.platform ?? process.platform;
96
+ if (platform !== "darwin") {
97
+ return {
98
+ scheduled: false,
99
+ reason: `${platform} relies on its service supervisor's restart policy`,
100
+ };
101
+ }
102
+ if (seams.underElectron ?? Boolean(process.versions.electron)) {
103
+ return {
104
+ scheduled: false,
105
+ reason: "the desktop app's supervisor relaunches the agent",
106
+ };
107
+ }
108
+ const parentPid = seams.parentPid ?? process.ppid;
109
+ if (parentPid !== 1) {
110
+ return {
111
+ scheduled: false,
112
+ reason: `not launchd-spawned (parent pid ${parentPid}); nothing to kick`,
113
+ };
114
+ }
115
+ const uid = "uid" in seams ? seams.uid : process.getuid?.();
116
+ if (uid === undefined) {
117
+ return { scheduled: false, reason: "no uid to address the launchd domain" };
118
+ }
119
+ const delaySeconds = seams.delaySeconds ?? ACTIVATION_RESPAWN_DELAY_SECONDS;
120
+ const target = launchdJobTarget(uid);
121
+ const args = [
122
+ "-c",
123
+ ACTIVATION_RESPAWN_SCRIPT,
124
+ "uai-host-activation-respawn",
125
+ String(delaySeconds),
126
+ target,
127
+ ];
128
+ const spawn = seams.spawn ?? (nodeSpawn as unknown as ActivationRespawnSpawn);
129
+ let child: ActivationRespawnChild;
130
+ try {
131
+ child = spawn(ACTIVATION_RESPAWN_SHELL, args, {
132
+ detached: true,
133
+ stdio: "ignore",
134
+ });
135
+ } catch (error) {
136
+ return {
137
+ scheduled: false,
138
+ reason: `could not spawn the respawn shell: ${
139
+ error instanceof Error ? error.message : String(error)
140
+ }`,
141
+ };
142
+ }
143
+ // A spawn that fails after the fork leaves KeepAlive as the only respawn —
144
+ // the pre-0.9.99 behaviour — and must not become an uncaught 'error' in a
145
+ // process that is about to exit anyway.
146
+ child.on("error", () => {});
147
+ child.unref();
148
+ return {
149
+ scheduled: true,
150
+ target,
151
+ command: ACTIVATION_RESPAWN_SHELL,
152
+ args,
153
+ delaySeconds,
154
+ };
155
+ }
package/src/cli.ts CHANGED
@@ -250,6 +250,7 @@ async function cmdRun(): Promise<void> {
250
250
  installState !== "live" &&
251
251
  installState !== "unknown"
252
252
  ) {
253
+ const activationWasPending = existsSync(managedPaths.activationPending);
253
254
  try {
254
255
  if (!(await acknowledgeManagedRuntimeActivation(runningVersion))) {
255
256
  // Selection changed while the service module was loading. Its task
@@ -258,6 +259,12 @@ async function cmdRun(): Promise<void> {
258
259
  host.requestManagedHostRestart();
259
260
  return;
260
261
  }
262
+ if (activationWasPending) {
263
+ console.log(
264
+ `[host-agent] managed runtime ${runningVersion} activated (pending activation acknowledged)`,
265
+ );
266
+ await logLaunchdJobAfterActivation();
267
+ }
261
268
  } catch (error) {
262
269
  // A malformed or concurrently changed marker must not leave an imported
263
270
  // service running indefinitely. Admission is still closed, so request a
@@ -305,6 +312,32 @@ async function cmdRun(): Promise<void> {
305
312
  }
306
313
  }
307
314
 
315
+ /**
316
+ * The boot after an activation is the moment a launchd respawn problem is
317
+ * visible — `last exit code = 75: EX_TEMPFAIL` with a `pended nondemand
318
+ * spawn` is what the 2026-09-06 stalls looked like in `launchctl print`, and
319
+ * nothing in the service log said so. Read-only, macOS, and only when launchd
320
+ * actually spawned this process (pid 1 is its parent): a terminal `uai-host
321
+ * run`, the desktop supervisor, or a test spawning the CLI never runs
322
+ * `launchctl` here.
323
+ */
324
+ async function logLaunchdJobAfterActivation(): Promise<void> {
325
+ if (process.platform !== "darwin" || process.ppid !== 1) return;
326
+ try {
327
+ const { describeLaunchdJob, formatLaunchdJobState, launchdJobTarget } =
328
+ await import("../scripts/install/darwin");
329
+ console.log(
330
+ `[host-agent] launchd job ${launchdJobTarget()} after activation: ${formatLaunchdJobState(describeLaunchdJob())}`,
331
+ );
332
+ } catch (error) {
333
+ console.warn(
334
+ `[host-agent] could not read the launchd job state after activation: ${
335
+ error instanceof Error ? error.message : String(error)
336
+ }`,
337
+ );
338
+ }
339
+ }
340
+
308
341
  async function waitForManagedInstaller(): Promise<"live" | "restart" | "stopped"> {
309
342
  console.error(
310
343
  red(
package/src/index.ts CHANGED
@@ -768,6 +768,8 @@ export const hostCommands: HostCommands = {
768
768
  input.ownerEmail ?? null,
769
769
  input.ownerName ?? null,
770
770
  input.projects.map((p) => p.slug),
771
+ // ADR-131 D3: persisted so recovery re-provisions the same scope.
772
+ input.task.engineAccountScope ?? null,
771
773
  );
772
774
  // ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
773
775
  // `uai` CLI's per-agent tokens can be minted now + after a host restart.
@@ -1227,7 +1229,7 @@ export const hostCommands: HostCommands = {
1227
1229
  async engineAccountRemove(ctx, input) {
1228
1230
  logCommand(ctx, "engineAccountRemove", input.id);
1229
1231
  try {
1230
- const result = removeEngineAccount(input.id);
1232
+ const result = removeEngineAccount(input.id, input.scope);
1231
1233
  if (!result.ok) {
1232
1234
  return ok({ removed: false, message: result.message });
1233
1235
  }
@@ -19,10 +19,14 @@
19
19
  * (the pane's first burst is half a dozen parallel requests), and a machine
20
20
  * seen not running is not asked again for a few seconds (noVNC retries every
21
21
  * couple of seconds against a stopped machine; without that, each retry was
22
- * a CLI spawn). A refused dial invalidates at once; an address that
23
- * black-holes (an instance stopped while memoised) hangs on the OS connect
24
- * timeout instead — machine dials carry no connect timeout of their own,
25
- * which predates this module — so the TTL is the real bound there.
22
+ * a CLI spawn). A refused dial invalidates at once. An address that
23
+ * black-holes (an instance stopped while memoised) answers nothing, so the
24
+ * route carries its own connect bound, MACHINE_DIAL_CONNECT_TIMEOUT_MS: both
25
+ * dial paths in main.ts arm it (`tunnel-dial.ts`) and the timeout surfaces as
26
+ * an ordinary dial error, which invalidates the entry exactly like a refusal.
27
+ * Before host 0.9.99 machine dials had no timeout of their own and a
28
+ * black-holed address hung on the kernel's SYN budget (~75 s macOS, ~127 s
29
+ * Linux) — longer than the memo TTL, so the TTL was never the real bound.
26
30
  */
27
31
 
28
32
  export type MachineTunnelRoute =
@@ -52,6 +56,20 @@ export const MACHINE_ROUTE_TTL_MS = 45_000;
52
56
  * again — long enough to absorb a reconnecting pane, short enough that a wake
53
57
  * is seen within seconds. */
54
58
  export const MACHINE_ROUTE_NEGATIVE_TTL_MS = 5_000;
59
+ /** Connect-phase bound for a dial to a memoised machine address. An
60
+ * in-region machine answers a SYN in milliseconds; the cloud waits 30 s for
61
+ * the tunnel ack and the forward hop 45 s for the head, so 10 s is generous
62
+ * to a live machine and still leaves the caller a real answer (a 502 and a
63
+ * dropped memo) instead of the kernel's SYN retry budget. */
64
+ export const MACHINE_DIAL_CONNECT_TIMEOUT_MS = 10_000;
65
+
66
+ /** A resolved machine route: where to dial, and the connect bound the dial
67
+ * must carry because the address is memoised. */
68
+ export interface MachineTunnelRouteTarget {
69
+ host: string;
70
+ port: number;
71
+ connectTimeoutMs: number;
72
+ }
55
73
 
56
74
  const memo = new Map<string, { ipv4Address: string; expiresAt: number }>();
57
75
  const negative = new Map<string, number>();
@@ -66,7 +84,7 @@ export async function resolveMachineTunnelRoute<
66
84
  request: MachineTunnelRouteRequest,
67
85
  task: Task,
68
86
  deps: MachineTunnelRouteDeps<Task>,
69
- ): Promise<{ host: string; port: number } | null> {
87
+ ): Promise<MachineTunnelRouteTarget | null> {
70
88
  // The cheap refusal first: an unroutable request never costs an API call.
71
89
  const port =
72
90
  request.target === "editor"
@@ -74,7 +92,9 @@ export async function resolveMachineTunnelRoute<
74
92
  : (request.containerPort ?? null);
75
93
  if (!port) return null;
76
94
  const host = await machineAddress(request.taskId, task, deps);
77
- return host === null ? null : { host, port };
95
+ return host === null
96
+ ? null
97
+ : { host, port, connectTimeoutMs: MACHINE_DIAL_CONNECT_TIMEOUT_MS };
78
98
  }
79
99
 
80
100
  async function machineAddress<Task>(