@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.
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +7 -0
- package/lib/engine-accounts.ts +120 -11
- package/lib/engine-connect.ts +24 -2
- package/lib/engine-login.ts +46 -8
- package/lib/orchestrator.ts +40 -19
- package/lib/runtime-state.ts +2 -0
- package/package.json +1 -1
- package/scripts/install/darwin.ts +90 -8
- package/src/activation-respawn.ts +155 -0
- package/src/cli.ts +33 -0
- package/src/index.ts +3 -1
- package/src/machine-tunnel-route.ts +26 -6
- package/src/main.ts +65 -7
- package/src/protocol.ts +46 -5
- package/src/tunnel-dial.ts +102 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -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(
|
|
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)
|
|
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];
|
package/lib/runtime-state.ts
CHANGED
|
@@ -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
|
@@ -35,9 +35,21 @@ import {
|
|
|
35
35
|
|
|
36
36
|
export const LABEL = "com.runuai.host";
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
939
|
-
if (!
|
|
940
|
-
|
|
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
|
|
23
|
-
* black-holes (an instance stopped while memoised)
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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<
|
|
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
|
|
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>(
|