agent-embassy 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +19 -34
- package/README.md +110 -228
- package/SECURITY.md +55 -89
- package/dist/src/errors.d.ts +10 -1
- package/dist/src/errors.js +3 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
- package/dist/src/gateway/claude-helper-protocol.js +12 -11
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
- package/dist/src/gateway/claude-helper-supervisor.js +10 -9
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
- package/dist/src/gateway/claude-helper.js +6 -6
- package/dist/src/gateway/claude-helper.js.map +1 -1
- package/dist/src/gateway/claude-peer.d.ts +0 -3
- package/dist/src/gateway/claude-peer.js +6 -18
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +17 -6
- package/dist/src/gateway/cli.js +912 -261
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
- package/dist/src/gateway/codex-socket-holder.js +76 -0
- package/dist/src/gateway/codex-socket-holder.js.map +1 -0
- package/dist/src/gateway/codex-stateless-transport.js +1 -1
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +1 -10
- package/dist/src/gateway/config.js +4 -10
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +45 -77
- package/dist/src/gateway/control.js +56 -141
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +29 -2
- package/dist/src/gateway/federation-nodes.js +177 -7
- package/dist/src/gateway/federation-nodes.js.map +1 -1
- package/dist/src/gateway/peer-client.d.ts +4 -3
- package/dist/src/gateway/peer-client.js +22 -13
- package/dist/src/gateway/peer-client.js.map +1 -1
- package/dist/src/gateway/peer-protocol.d.ts +8 -11
- package/dist/src/gateway/peer-protocol.js +5 -12
- package/dist/src/gateway/peer-protocol.js.map +1 -1
- package/dist/src/gateway/provenance-envelope.d.ts +0 -1
- package/dist/src/gateway/provenance-envelope.js +4 -19
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/providers.d.ts +7 -4
- package/dist/src/gateway/providers.js +16 -20
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +3 -12
- package/dist/src/gateway/server.js +31 -50
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/service-agent.d.ts +187 -0
- package/dist/src/gateway/service-agent.js +758 -0
- package/dist/src/gateway/service-agent.js.map +1 -0
- package/dist/src/gateway/service.d.ts +114 -31
- package/dist/src/gateway/service.js +461 -566
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/status-view.d.ts +167 -0
- package/dist/src/gateway/status-view.js +488 -0
- package/dist/src/gateway/status-view.js.map +1 -0
- package/dist/src/gateway/store.d.ts +103 -21
- package/dist/src/gateway/store.js +454 -529
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +48 -99
- package/dist/src/gateway/types.js +15 -52
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +173 -44
- package/docs/DELIVERY.md +11 -11
- package/docs/GATEWAY-ARCHITECTURE.md +277 -375
- package/package.json +4 -12
- package/skills/embassy-peer/SKILL.md +65 -90
- package/skills/embassy-peer/agents/openai.yaml +1 -1
- package/README.zh-CN.md +0 -275
- package/assets/live-dashboard/app.css +0 -1619
- package/assets/vendor/react/LICENSE +0 -21
- package/assets/vendor/react/react-dom.production.min.js +0 -267
- package/assets/vendor/react/react.production.min.js +0 -31
- package/dist/src/gateway/acp-client.d.ts +0 -110
- package/dist/src/gateway/acp-client.js +0 -407
- package/dist/src/gateway/acp-client.js.map +0 -1
- package/dist/src/gateway/acp-provider.d.ts +0 -66
- package/dist/src/gateway/acp-provider.js +0 -275
- package/dist/src/gateway/acp-provider.js.map +0 -1
- package/dist/src/gateway/cli-copy.d.ts +0 -8
- package/dist/src/gateway/cli-copy.en.d.ts +0 -22
- package/dist/src/gateway/cli-copy.en.js +0 -62
- package/dist/src/gateway/cli-copy.en.js.map +0 -1
- package/dist/src/gateway/cli-copy.js +0 -27
- package/dist/src/gateway/cli-copy.js.map +0 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
- package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
- package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/codex-doctor.d.ts +0 -36
- package/dist/src/gateway/codex-doctor.js +0 -127
- package/dist/src/gateway/codex-doctor.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.d.ts +0 -7
- package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.en.js +0 -505
- package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.js +0 -514
- package/dist/src/gateway/dashboard-copy.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/dashboard-model.d.ts +0 -343
- package/dist/src/gateway/dashboard-model.js +0 -1061
- package/dist/src/gateway/dashboard-model.js.map +0 -1
- package/dist/src/gateway/dashboard.d.ts +0 -20
- package/dist/src/gateway/dashboard.js +0 -874
- package/dist/src/gateway/dashboard.js.map +0 -1
- package/dist/src/gateway/deepseek-detect.d.ts +0 -14
- package/dist/src/gateway/deepseek-detect.js +0 -41
- package/dist/src/gateway/deepseek-detect.js.map +0 -1
- package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
- package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
- package/dist/src/gateway/live-dashboard-assets.js +0 -74
- package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
- package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
- package/dist/src/gateway/live-dashboard-command.js +0 -334
- package/dist/src/gateway/live-dashboard-command.js.map +0 -1
- package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
- package/dist/src/gateway/live-dashboard-http.js +0 -383
- package/dist/src/gateway/live-dashboard-http.js.map +0 -1
- package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
- package/dist/src/gateway/live-dashboard-protocol.js +0 -114
- package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
- package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
- package/dist/src/gateway/live-dashboard-server.js +0 -144
- package/dist/src/gateway/live-dashboard-server.js.map +0 -1
- package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
- package/dist/src/gateway/live-dashboard-stream.js +0 -234
- package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
- package/dist/src/gateway/live-dashboard.d.ts +0 -28
- package/dist/src/gateway/live-dashboard.js +0 -154
- package/dist/src/gateway/live-dashboard.js.map +0 -1
- package/dist/src/gateway/locale.d.ts +0 -4
- package/dist/src/gateway/locale.js +0 -10
- package/dist/src/gateway/locale.js.map +0 -1
- package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
- package/dist/src/gateway/progress-watch-machine.js +0 -70
- package/dist/src/gateway/progress-watch-machine.js.map +0 -1
- package/docs/CONFIGURATION.zh-CN.md +0 -97
- package/docs/DASHBOARD.md +0 -98
- package/docs/DASHBOARD.zh-CN.md +0 -49
- package/docs/DELIVERY.zh-CN.md +0 -55
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the broker as a macOS launchd agent. This module renders the plist,
|
|
3
|
+
* drives `launchctl` through an injected runner (real callers spawn the
|
|
4
|
+
* system binary; tests inject a fake), and refuses to install while another
|
|
5
|
+
* Embassy broker holds the host-wide instance lease
|
|
6
|
+
* (src/gateway/instance-lease.ts). It never touches the real ~/Library
|
|
7
|
+
* itself — every path is derived from an injected `homeDir`.
|
|
8
|
+
*
|
|
9
|
+
* Install order is load-bearing. Our own agent is booted out *first* (that
|
|
10
|
+
* is what re-install means), and only then is the host lease probed: probing
|
|
11
|
+
* first made `install` refuse over the very agent it was replacing. Every
|
|
12
|
+
* input is validated before the first side effect, and any failure after
|
|
13
|
+
* `bootstrap` is rolled back, so a rejected install never leaves a
|
|
14
|
+
* half-registered agent behind.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import { constants } from "node:fs";
|
|
19
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { BridgeError } from "../errors.js";
|
|
22
|
+
import { acquireGatewayInstanceLease } from "./instance-lease.js";
|
|
23
|
+
export const SERVICE_AGENT_LABEL = "com.agent-embassy.broker";
|
|
24
|
+
/** Bounded read of our own rendered plist when `status` inspects it or a rollback restores it. */
|
|
25
|
+
const MAX_PLIST_BYTES = 64 * 1024;
|
|
26
|
+
/** Bounded read of the lease lock record, matching instance-lease.ts. */
|
|
27
|
+
const MAX_LEASE_RECORD_BYTES = 4 * 1024;
|
|
28
|
+
/**
|
|
29
|
+
* Every byte this module puts into a message a caller may print passes
|
|
30
|
+
* through boundedServiceDetail or boundedProgramList: launchctl stderr, the
|
|
31
|
+
* instance lease's own message, an errno string, and the program paths in a
|
|
32
|
+
* status note. cli.ts holds the invariant that stderr never carries private
|
|
33
|
+
* detail, and `launchctl print` stdout is private detail — it dumps the
|
|
34
|
+
* agent's whole EnvironmentVariables dict, values included — so its stdout is
|
|
35
|
+
* parsed but never quoted.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_LAUNCHCTL_DETAIL_BYTES = 512;
|
|
38
|
+
/** A status note names at most this many missing programs, each capped. */
|
|
39
|
+
const MAX_PROGRAM_LIST_ENTRIES = 3;
|
|
40
|
+
const MAX_PROGRAM_PATH_BYTES = 256;
|
|
41
|
+
/**
|
|
42
|
+
* launchd tears a job down asynchronously and `bootout` can return 36
|
|
43
|
+
* ("operation in progress") while it is still going; the broker's own close
|
|
44
|
+
* awaits its providers, its store, and the lease helper, up to 5 s. So a
|
|
45
|
+
* bootout is confirmed by polling `print` for not-found, not by one shot.
|
|
46
|
+
*/
|
|
47
|
+
const BOOTOUT_POLL_INTERVAL_MS = 250;
|
|
48
|
+
const BOOTOUT_WAIT_TIMEOUT_MS = 10_000;
|
|
49
|
+
const BOOTOUT_MAX_ATTEMPTS = BOOTOUT_WAIT_TIMEOUT_MS / BOOTOUT_POLL_INTERVAL_MS;
|
|
50
|
+
/**
|
|
51
|
+
* The host-wide advisory lease lives at this fixed path under the login
|
|
52
|
+
* home, independent of EMBASSY_STATE_DIR (see instance-lease.ts). The path
|
|
53
|
+
* components are not exported by that module (they are load-bearing only
|
|
54
|
+
* for its own lock file), so they are named again here — the same
|
|
55
|
+
* duplication test/gateway-instance-lease.test.ts already carries — solely
|
|
56
|
+
* to read the current holder's pid for a friendlier refusal message.
|
|
57
|
+
*/
|
|
58
|
+
const HOST_LEASE_LOCK_RELATIVE_PATH = path.join(".local", "state", "agent-embassy", ".gateway-host.lock");
|
|
59
|
+
/**
|
|
60
|
+
* `launchctl print` on a label that is not loaded. Every other non-zero
|
|
61
|
+
* result is a launchctl problem, not an answer about the agent, and is
|
|
62
|
+
* reported as such rather than rendered as "not loaded".
|
|
63
|
+
*/
|
|
64
|
+
const SERVICE_NOT_FOUND_PATTERN = /could not find (?:the )?(?:specified )?service|no such process/i;
|
|
65
|
+
const isServiceNotFound = (result) => SERVICE_NOT_FOUND_PATTERN.test(`${result.stderr}\n${result.stdout}`);
|
|
66
|
+
function xmlEscape(value) {
|
|
67
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
68
|
+
}
|
|
69
|
+
function xmlUnescape(value) {
|
|
70
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
71
|
+
}
|
|
72
|
+
function stringElement(value) {
|
|
73
|
+
return `<string>${xmlEscape(value)}</string>`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Render the launchd agent plist.
|
|
77
|
+
*
|
|
78
|
+
* `KeepAlive` is `{ Crashed: true }`, never plain `true`. Per launchd.plist(5)
|
|
79
|
+
* `Crashed` relaunches the job only when it died from a signal typically
|
|
80
|
+
* associated with a crash — SIGSEGV, SIGBUS, SIGILL, SIGABRT. Nothing else
|
|
81
|
+
* brings it back: not a clean exit, not a non-zero exit, not a plain `kill`
|
|
82
|
+
* (SIGTERM), and not one of the broker's deliberate boot refusals (an
|
|
83
|
+
* unsupported state schema, another instance already holding the lease).
|
|
84
|
+
* Under plain `KeepAlive` every one of those refusals would relaunch forever,
|
|
85
|
+
* throttled to once every 5 seconds, into one log file that nothing rotates.
|
|
86
|
+
* A refusal now exits once and stays down, where `embassy service status` and
|
|
87
|
+
* the log can explain it. ThrottleInterval still bounds a genuine crash loop.
|
|
88
|
+
*
|
|
89
|
+
* EnvironmentVariables carries exactly the captured configuration keys and
|
|
90
|
+
* nothing else — see captureAgentEnvironment for the rule. PATH is not
|
|
91
|
+
* needed (every child process this broker spawns — the codex standalone
|
|
92
|
+
* binary, /bin/ps, /usr/bin/lockf, /bin/cat — is invoked by an absolute path
|
|
93
|
+
* already; see codex-local-transport.ts, claude-peer.ts, and
|
|
94
|
+
* instance-lease.ts).
|
|
95
|
+
*/
|
|
96
|
+
export function renderLaunchAgentPlist(options) {
|
|
97
|
+
const argumentsXml = options.programArguments
|
|
98
|
+
.map((argument) => ` ${stringElement(argument)}`)
|
|
99
|
+
.join("\n");
|
|
100
|
+
const environmentEntries = Object.entries(options.environment);
|
|
101
|
+
const environmentXml = environmentEntries.length === 0
|
|
102
|
+
? ""
|
|
103
|
+
: [
|
|
104
|
+
" <key>EnvironmentVariables</key>",
|
|
105
|
+
" <dict>",
|
|
106
|
+
...environmentEntries.flatMap(([key, value]) => [
|
|
107
|
+
` <key>${xmlEscape(key)}</key>`,
|
|
108
|
+
` ${stringElement(value)}`,
|
|
109
|
+
]),
|
|
110
|
+
" </dict>",
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
114
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
115
|
+
<plist version="1.0">
|
|
116
|
+
<dict>
|
|
117
|
+
<key>Label</key>
|
|
118
|
+
${stringElement(options.label)}
|
|
119
|
+
<key>ProgramArguments</key>
|
|
120
|
+
<array>
|
|
121
|
+
${argumentsXml}
|
|
122
|
+
</array>
|
|
123
|
+
<key>RunAtLoad</key>
|
|
124
|
+
<true/>
|
|
125
|
+
<key>KeepAlive</key>
|
|
126
|
+
<dict>
|
|
127
|
+
<key>Crashed</key>
|
|
128
|
+
<true/>
|
|
129
|
+
</dict>
|
|
130
|
+
<key>ThrottleInterval</key>
|
|
131
|
+
<integer>5</integer>
|
|
132
|
+
<key>StandardOutPath</key>
|
|
133
|
+
${stringElement(options.logPath)}
|
|
134
|
+
<key>StandardErrorPath</key>
|
|
135
|
+
${stringElement(options.logPath)}
|
|
136
|
+
${environmentXml}</dict>
|
|
137
|
+
</plist>
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
const defaultDelay = async (milliseconds) => await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
141
|
+
export function serviceAgentPaths(homeDir) {
|
|
142
|
+
const launchAgentsDir = path.join(homeDir, "Library", "LaunchAgents");
|
|
143
|
+
const logsDir = path.join(homeDir, "Library", "Logs", "agent-embassy");
|
|
144
|
+
return {
|
|
145
|
+
launchAgentsDir,
|
|
146
|
+
logsDir,
|
|
147
|
+
plistPath: path.join(launchAgentsDir, `${SERVICE_AGENT_LABEL}.plist`),
|
|
148
|
+
logPath: path.join(logsDir, "broker.log"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const launchctlDomain = (uid) => `gui/${uid}`;
|
|
152
|
+
const launchctlTarget = (uid) => `gui/${uid}/${SERVICE_AGENT_LABEL}`;
|
|
153
|
+
/**
|
|
154
|
+
* stderr only, trimmed, and capped — never launchctl's stdout. Everything the
|
|
155
|
+
* service path puts on the CLI's stderr goes through this, including the
|
|
156
|
+
* errno text of a filesystem failure the CLI itself renders.
|
|
157
|
+
*/
|
|
158
|
+
export function boundedServiceDetail(text) {
|
|
159
|
+
const trimmed = text.trim();
|
|
160
|
+
if (trimmed.length === 0)
|
|
161
|
+
return "no stderr output";
|
|
162
|
+
const bytes = Buffer.from(trimmed, "utf8");
|
|
163
|
+
return bytes.length <= MAX_LAUNCHCTL_DETAIL_BYTES
|
|
164
|
+
? trimmed
|
|
165
|
+
: `${bytes.subarray(0, MAX_LAUNCHCTL_DETAIL_BYTES).toString("utf8")}… (truncated)`;
|
|
166
|
+
}
|
|
167
|
+
const launchctlDetail = (result) => boundedServiceDetail(result.stderr);
|
|
168
|
+
const formatSeconds = (milliseconds) => (Math.max(0, milliseconds) / 1000).toFixed(1);
|
|
169
|
+
/** Elapsed time is measured monotonically; a wall-clock step must not move it. */
|
|
170
|
+
const monotonicNow = () => performance.now();
|
|
171
|
+
/** The missing-program list as it appears in a note: bounded in both directions. */
|
|
172
|
+
function boundedProgramList(programs) {
|
|
173
|
+
const shown = programs.slice(0, MAX_PROGRAM_LIST_ENTRIES).map((program) => {
|
|
174
|
+
const bytes = Buffer.from(program, "utf8");
|
|
175
|
+
return bytes.length <= MAX_PROGRAM_PATH_BYTES
|
|
176
|
+
? program
|
|
177
|
+
: `${bytes.subarray(0, MAX_PROGRAM_PATH_BYTES).toString("utf8")}… (truncated)`;
|
|
178
|
+
});
|
|
179
|
+
const remaining = programs.length - shown.length;
|
|
180
|
+
return remaining > 0 ? `${shown.join(", ")}, and ${remaining} more` : shown.join(", ");
|
|
181
|
+
}
|
|
182
|
+
/** An errno-bearing filesystem failure, rendered without a stack. */
|
|
183
|
+
const errnoDetail = (error) => boundedServiceDetail(error instanceof Error ? error.message : "unknown filesystem failure");
|
|
184
|
+
/**
|
|
185
|
+
* launchctl failures are transient by class: the binary was unavailable, the
|
|
186
|
+
* gui domain was not up, another tool held the label. The CLI reports them
|
|
187
|
+
* as unavailable and prints this message, which carries launchctl's own
|
|
188
|
+
* stderr, rather than discarding it behind a generic input rejection.
|
|
189
|
+
*/
|
|
190
|
+
function launchctlFailure(verb, result, suffix = "") {
|
|
191
|
+
return new BridgeError("SERVICE_AGENT_COMMAND_FAILED", `launchctl ${verb} failed (exit ${result.code}): ${launchctlDetail(result)}${suffix}`, true);
|
|
192
|
+
}
|
|
193
|
+
const rejectInput = (message) => {
|
|
194
|
+
throw new BridgeError("INVALID_GATEWAY_CONFIGURATION", message, false);
|
|
195
|
+
};
|
|
196
|
+
function requireAbsolutePath(value, description) {
|
|
197
|
+
if (value.length === 0 || value.includes("\0") || !path.isAbsolute(value) ||
|
|
198
|
+
path.resolve(value) !== value) {
|
|
199
|
+
rejectInput(`${description} must be an absolute, already-resolved path.`);
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
function requireUid(uid) {
|
|
204
|
+
if (!Number.isSafeInteger(uid) || uid < 0) {
|
|
205
|
+
rejectInput("The launchd agent needs this process's numeric uid.");
|
|
206
|
+
}
|
|
207
|
+
return uid;
|
|
208
|
+
}
|
|
209
|
+
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
|
210
|
+
/**
|
|
211
|
+
* The environment the installed agent runs with. launchd agents inherit
|
|
212
|
+
* almost nothing, so Embassy's own configuration has to be copied into the
|
|
213
|
+
* plist: every `EMBASSY_*` variable (EMBASSY_STATE_DIR included) plus
|
|
214
|
+
* XDG_STATE_HOME, which decides the state root when EMBASSY_STATE_DIR is
|
|
215
|
+
* unset. These are configuration, not secrets — but nothing else is copied,
|
|
216
|
+
* so an inherited API key or token cannot reach the plist.
|
|
217
|
+
*
|
|
218
|
+
* Both state roots must be absolute here, before capture: a relative value
|
|
219
|
+
* resolves against the installing shell's working directory, and the agent
|
|
220
|
+
* would silently resolve it somewhere else.
|
|
221
|
+
*/
|
|
222
|
+
export function captureAgentEnvironment(env) {
|
|
223
|
+
const captured = {};
|
|
224
|
+
for (const key of Object.keys(env).sort()) {
|
|
225
|
+
if (key !== "XDG_STATE_HOME" && !key.startsWith("EMBASSY_"))
|
|
226
|
+
continue;
|
|
227
|
+
const value = env[key];
|
|
228
|
+
if (value === undefined || value.length === 0)
|
|
229
|
+
continue;
|
|
230
|
+
if (CONTROL_CHARACTER_PATTERN.test(value)) {
|
|
231
|
+
rejectInput(`${key} contains a control character and cannot be captured into the launchd agent.`);
|
|
232
|
+
}
|
|
233
|
+
if (key === "EMBASSY_STATE_DIR" || key === "XDG_STATE_HOME") {
|
|
234
|
+
requireAbsolutePath(value, key);
|
|
235
|
+
}
|
|
236
|
+
captured[key] = value;
|
|
237
|
+
}
|
|
238
|
+
return captured;
|
|
239
|
+
}
|
|
240
|
+
function isProcessAlive(pid) {
|
|
241
|
+
try {
|
|
242
|
+
process.kill(pid, 0);
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// EPERM means the process exists but belongs to someone else.
|
|
247
|
+
return error.code === "EPERM";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Read the lease holder's pid under the same discipline instance-lease.ts
|
|
252
|
+
* applies in readPrivateFile: lstat first, regular file only, this user's
|
|
253
|
+
* own, size-bounded, opened O_NOFOLLOW and re-verified against the same
|
|
254
|
+
* inode. This value only decorates a refusal message, but it is read out of
|
|
255
|
+
* a file another process writes, so it gets the same care.
|
|
256
|
+
*/
|
|
257
|
+
/**
|
|
258
|
+
* Read a small private record this user owns, or nothing at all. Never
|
|
259
|
+
* throws, and never follows anything: the path is lstat'd, refused unless it
|
|
260
|
+
* is a regular file this uid owns within the byte bound, opened O_NOFOLLOW,
|
|
261
|
+
* and re-verified through the open handle by (dev, ino, uid, size) so the
|
|
262
|
+
* file that was checked is the file that was read. A uid of `undefined` — a
|
|
263
|
+
* platform with no `getuid` — skips only the ownership half.
|
|
264
|
+
*
|
|
265
|
+
* Both readers of a private record go through here: the host lease's pid
|
|
266
|
+
* below, and `readGatewayControllerPid` in cli.ts.
|
|
267
|
+
*/
|
|
268
|
+
export async function readOwnedSmallFile(filePath, uid, maximumBytes) {
|
|
269
|
+
try {
|
|
270
|
+
const info = await lstat(filePath);
|
|
271
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > maximumBytes ||
|
|
272
|
+
(uid !== undefined && info.uid !== uid)) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
const handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
276
|
+
try {
|
|
277
|
+
const opened = await handle.stat();
|
|
278
|
+
if (!opened.isFile() || opened.dev !== info.dev || opened.ino !== info.ino ||
|
|
279
|
+
opened.size > maximumBytes || (uid !== undefined && opened.uid !== uid)) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
const buffer = Buffer.alloc(maximumBytes);
|
|
283
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
284
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
await handle.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** The pid recorded in a JSON record, or nothing when it names none. */
|
|
295
|
+
export function recordedPid(raw) {
|
|
296
|
+
if (raw === undefined)
|
|
297
|
+
return undefined;
|
|
298
|
+
let record;
|
|
299
|
+
try {
|
|
300
|
+
record = JSON.parse(raw.trim());
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const pid = record !== null && typeof record === "object" && "pid" in record
|
|
306
|
+
? record.pid : undefined;
|
|
307
|
+
return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
|
|
308
|
+
}
|
|
309
|
+
async function readHeldLeasePid(homeDir, uid) {
|
|
310
|
+
return recordedPid(await readOwnedSmallFile(path.join(homeDir, HOST_LEASE_LOCK_RELATIVE_PATH), uid, MAX_LEASE_RECORD_BYTES));
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The production probe, and the reason this is an injected dependency at all.
|
|
314
|
+
* It reuses the exact detection the broker relies on for single-instance
|
|
315
|
+
* correctness — a non-blocking acquire of the host-wide advisory lease
|
|
316
|
+
* (acquireGatewayInstanceLease, unmodified), released immediately when nobody
|
|
317
|
+
* holds it. That helper spawns /usr/bin/lockf, which exists only on macOS, so
|
|
318
|
+
* on any other platform it reports contention that is really a missing
|
|
319
|
+
* binary. The product is macOS-only and that is correct in production, but it
|
|
320
|
+
* makes every install path untestable off darwin; tests inject a fake probe
|
|
321
|
+
* and one darwin-only test drives this default.
|
|
322
|
+
*
|
|
323
|
+
* `GATEWAY_INSTANCE_IN_USE` is not only contention: instance-lease.ts throws
|
|
324
|
+
* it for roughly ten conditions that have nothing to do with another broker
|
|
325
|
+
* (a symlinked path component, a non-empty unmarked lease root, a mode or
|
|
326
|
+
* owner drift). So its message is always carried out of here unchanged, and a
|
|
327
|
+
* pid is reported only when the recorded holder is genuinely alive — the lock
|
|
328
|
+
* record keeps the *last* holder, and a successful probe writes its own pid
|
|
329
|
+
* there, so an unchecked pid is routinely stale. Never tell someone to stop
|
|
330
|
+
* a dead process.
|
|
331
|
+
*/
|
|
332
|
+
export async function defaultProbeHostLease(homeDir, uid) {
|
|
333
|
+
let lease;
|
|
334
|
+
try {
|
|
335
|
+
lease = await acquireGatewayInstanceLease(homeDir);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
if (error instanceof BridgeError && error.code === "GATEWAY_INSTANCE_IN_USE") {
|
|
339
|
+
const pid = await readHeldLeasePid(homeDir, uid);
|
|
340
|
+
if (pid !== undefined && isProcessAlive(pid)) {
|
|
341
|
+
return { held: true, pid, message: error.message };
|
|
342
|
+
}
|
|
343
|
+
return { held: true, message: error.message };
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
await lease.close();
|
|
348
|
+
return { held: false };
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Refuse to install while the host lease cannot be taken. The lease's own
|
|
352
|
+
* message is preserved — verbatim up to the same 512-byte bound every other
|
|
353
|
+
* quoted string here carries, which no instance-lease message approaches —
|
|
354
|
+
* and a pid is named only when the probe verified it alive.
|
|
355
|
+
*/
|
|
356
|
+
async function refuseIfAnotherBrokerHoldsLease(deps, homeDir, uid) {
|
|
357
|
+
const probe = await (deps.probeHostLease ?? defaultProbeHostLease)(homeDir, uid);
|
|
358
|
+
if (!probe.held)
|
|
359
|
+
return;
|
|
360
|
+
const reported = boundedServiceDetail(probe.message);
|
|
361
|
+
throw new BridgeError("GATEWAY_INSTANCE_IN_USE", probe.pid === undefined
|
|
362
|
+
? `The Embassy host lease could not be acquired, so nothing was installed. The lease reported: ${reported}`
|
|
363
|
+
: `Another Embassy broker holds the host lease (pid ${probe.pid}, alive) — stop it (\`embassy service uninstall\` if it is the launchd agent, otherwise the \`embassy serve\` terminal), then re-run install. The lease reported: ${reported}`, true);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Create one path component if it is absent, then verify it. Nothing that
|
|
367
|
+
* already exists is ever chmod-ed: a login home may deliberately keep
|
|
368
|
+
* ~/Library or ~/Library/LaunchAgents tighter than the default, and an
|
|
369
|
+
* installer has no business loosening it. This mirrors the discipline
|
|
370
|
+
* prepareHostLeaseDirectory already applies in instance-lease.ts.
|
|
371
|
+
*/
|
|
372
|
+
async function ensureOwnedDirectory(target, mode, uid) {
|
|
373
|
+
let created = false;
|
|
374
|
+
try {
|
|
375
|
+
await mkdir(target, { mode });
|
|
376
|
+
created = true;
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST")
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
const info = await lstat(target);
|
|
383
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
384
|
+
throw new BridgeError("SERVICE_AGENT_PATH_UNSAFE", `${target} is not a real directory; refusing to install the launchd agent through it.`, false);
|
|
385
|
+
}
|
|
386
|
+
if (info.uid !== uid) {
|
|
387
|
+
throw new BridgeError("SERVICE_AGENT_PATH_UNSAFE", `${target} is not owned by this user; refusing to install the launchd agent there.`, false);
|
|
388
|
+
}
|
|
389
|
+
// mkdir's mode is masked by the process umask, so the requested mode is
|
|
390
|
+
// applied explicitly — but only to a directory this call just created.
|
|
391
|
+
if (created)
|
|
392
|
+
await chmod(target, mode);
|
|
393
|
+
}
|
|
394
|
+
async function prepareServiceDirectories(homeDir, uid) {
|
|
395
|
+
await ensureOwnedDirectory(path.join(homeDir, "Library"), 0o755, uid);
|
|
396
|
+
await ensureOwnedDirectory(path.join(homeDir, "Library", "LaunchAgents"), 0o755, uid);
|
|
397
|
+
await ensureOwnedDirectory(path.join(homeDir, "Library", "Logs"), 0o755, uid);
|
|
398
|
+
await ensureOwnedDirectory(path.join(homeDir, "Library", "Logs", "agent-embassy"), 0o700, uid);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Write the plist through a fresh O_EXCL temp file and one rename, so a
|
|
402
|
+
* reader never sees a half-written plist and a pre-planted symlink at the
|
|
403
|
+
* destination is replaced rather than written through.
|
|
404
|
+
*/
|
|
405
|
+
async function writePlistAtomically(plistPath, plist) {
|
|
406
|
+
const existing = await lstat(plistPath).catch(() => undefined);
|
|
407
|
+
if (existing !== undefined && (existing.isSymbolicLink() || !existing.isFile())) {
|
|
408
|
+
throw new BridgeError("SERVICE_AGENT_PATH_UNSAFE", `${plistPath} is not a regular file; refusing to replace it.`, false);
|
|
409
|
+
}
|
|
410
|
+
const temporaryPath = `${plistPath}.tmp-${randomUUID()}`;
|
|
411
|
+
const handle = await open(temporaryPath, "wx", 0o644);
|
|
412
|
+
try {
|
|
413
|
+
await handle.writeFile(plist, "utf8");
|
|
414
|
+
await handle.chmod(0o644);
|
|
415
|
+
await handle.sync();
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
await handle.close();
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
await rename(temporaryPath, plistPath);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
await rm(temporaryPath, { force: true });
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Poll `print` until launchctl says the label is not found, or the bound expires. */
|
|
429
|
+
async function waitForServiceGone(deps, target) {
|
|
430
|
+
const now = deps.now ?? monotonicNow;
|
|
431
|
+
const delay = deps.delay ?? defaultDelay;
|
|
432
|
+
const started = now();
|
|
433
|
+
for (let attempt = 0;; attempt += 1) {
|
|
434
|
+
const printed = await deps.runLaunchctl(["print", target]);
|
|
435
|
+
const elapsedMs = now() - started;
|
|
436
|
+
if (printed.code !== 0) {
|
|
437
|
+
return isServiceNotFound(printed)
|
|
438
|
+
? { kind: "gone", elapsedMs }
|
|
439
|
+
: { kind: "print-failed", result: printed, elapsedMs };
|
|
440
|
+
}
|
|
441
|
+
if (elapsedMs >= BOOTOUT_WAIT_TIMEOUT_MS || attempt >= BOOTOUT_MAX_ATTEMPTS) {
|
|
442
|
+
return { kind: "still-loaded", elapsedMs };
|
|
443
|
+
}
|
|
444
|
+
await delay(BOOTOUT_POLL_INTERVAL_MS);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Boot the label out and confirm it. `bootout`'s own exit code is not the
|
|
449
|
+
* answer — it returns 0 while launchd is still tearing the job down, and it
|
|
450
|
+
* returns non-zero for a job that was never loaded — so the confirmation is
|
|
451
|
+
* always `print` reporting not-found. The reason strings quote launchctl's
|
|
452
|
+
* stderr only; `print` stdout carries the agent's environment values and
|
|
453
|
+
* never leaves this module.
|
|
454
|
+
*/
|
|
455
|
+
async function verifiedBootout(deps, target, subject) {
|
|
456
|
+
const bootout = await deps.runLaunchctl(["bootout", target]);
|
|
457
|
+
const wait = await waitForServiceGone(deps, target);
|
|
458
|
+
if (wait.kind === "gone")
|
|
459
|
+
return { gone: true };
|
|
460
|
+
if (wait.kind === "print-failed") {
|
|
461
|
+
return {
|
|
462
|
+
gone: false,
|
|
463
|
+
reason: `launchctl print could not confirm the unload (exit ${wait.result.code}): ${launchctlDetail(wait.result)}`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (bootout.code !== 0) {
|
|
467
|
+
return {
|
|
468
|
+
gone: false,
|
|
469
|
+
reason: `launchctl bootout failed (exit ${bootout.code}): ${launchctlDetail(bootout)}`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
gone: false,
|
|
474
|
+
reason: `${subject} is still unloading after ${formatSeconds(wait.elapsedMs)} s, although launchctl bootout returned 0`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* The plist currently on disk, as three distinct answers. "Absent" and
|
|
479
|
+
* "unreadable" must not collapse into one: a rollback deletes on absent, and
|
|
480
|
+
* deleting a plist we merely failed to read would be a silent uninstall of an
|
|
481
|
+
* install that already existed.
|
|
482
|
+
*/
|
|
483
|
+
async function readPreviousPlist(plistPath) {
|
|
484
|
+
const info = await lstat(plistPath).catch(() => undefined);
|
|
485
|
+
if (info === undefined)
|
|
486
|
+
return { kind: "absent" };
|
|
487
|
+
if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_PLIST_BYTES) {
|
|
488
|
+
return { kind: "unreadable" };
|
|
489
|
+
}
|
|
490
|
+
const plist = await readFile(plistPath, "utf8").catch(() => undefined);
|
|
491
|
+
return plist === undefined ? { kind: "unreadable" } : { kind: "bytes", plist };
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Confirm a bootstrapped agent is actually running, by `print` rather than by
|
|
495
|
+
* an exit code — the same standard this module applies to a bootout. A job
|
|
496
|
+
* that loaded but is not running gets one plain `kickstart`, never `-k`.
|
|
497
|
+
*/
|
|
498
|
+
async function confirmRunning(deps, target) {
|
|
499
|
+
const printed = await deps.runLaunchctl(["print", target]);
|
|
500
|
+
if (printed.code !== 0)
|
|
501
|
+
return { running: false, verb: "print", result: printed };
|
|
502
|
+
const parsed = parseLaunchctlPrintOutput(printed.stdout);
|
|
503
|
+
if (parsed.pid !== undefined || parsed.launchdState === "running")
|
|
504
|
+
return { running: true };
|
|
505
|
+
const kickstart = await deps.runLaunchctl(["kickstart", target]);
|
|
506
|
+
return kickstart.code === 0
|
|
507
|
+
? { running: true }
|
|
508
|
+
: { running: false, verb: "kickstart", result: kickstart };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Register the broker as this user's launchd agent. Re-running it over a
|
|
512
|
+
* prior install — including one that is loaded and running right now — is
|
|
513
|
+
* the supported way to change what the agent runs with.
|
|
514
|
+
*/
|
|
515
|
+
export async function installServiceAgent(deps) {
|
|
516
|
+
// 1. Everything that can be rejected, rejected before the first side effect.
|
|
517
|
+
const uid = requireUid(deps.uid);
|
|
518
|
+
const homeDir = requireAbsolutePath(deps.homeDir, "The login home");
|
|
519
|
+
requireAbsolutePath(deps.execPath, "The Node executable path");
|
|
520
|
+
requireAbsolutePath(deps.cliPath, "The Embassy CLI path");
|
|
521
|
+
const environment = captureAgentEnvironment(deps.env);
|
|
522
|
+
const { plistPath, logPath } = serviceAgentPaths(homeDir);
|
|
523
|
+
const target = launchctlTarget(uid);
|
|
524
|
+
const domain = launchctlDomain(uid);
|
|
525
|
+
// 2. Our own agent first. Re-install means replace, so a loaded copy of
|
|
526
|
+
// this exact label is booted out and confirmed gone before anything
|
|
527
|
+
// else — probing the host lease first made install refuse over the very
|
|
528
|
+
// agent it was replacing.
|
|
529
|
+
const before = await deps.runLaunchctl(["print", target]);
|
|
530
|
+
// Load-bearing for the rollback below: a plist that was on disk but *not*
|
|
531
|
+
// loaded must never be bootstrapped by a failed install.
|
|
532
|
+
const wasLoadedBefore = before.code === 0;
|
|
533
|
+
if (before.code === 0) {
|
|
534
|
+
const cleared = await verifiedBootout(deps, target, "the previous agent");
|
|
535
|
+
if (!cleared.gone) {
|
|
536
|
+
throw new BridgeError("SERVICE_AGENT_COMMAND_FAILED", `Could not replace the loaded launchd agent: ${cleared.reason}. Nothing was changed.`, true);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else if (!isServiceNotFound(before)) {
|
|
540
|
+
throw launchctlFailure("print", before, " — install stopped before changing anything.");
|
|
541
|
+
}
|
|
542
|
+
// 3. Only now can a held lease mean somebody else's broker.
|
|
543
|
+
await refuseIfAnotherBrokerHoldsLease(deps, homeDir, uid);
|
|
544
|
+
// 4. Side effects begin here. The plist already on disk is read first: a
|
|
545
|
+
// re-install overwrites it, and a rollback that merely deleted it would
|
|
546
|
+
// be a silent uninstall wearing the label of an inert failure.
|
|
547
|
+
await prepareServiceDirectories(homeDir, uid);
|
|
548
|
+
const previous = await readPreviousPlist(plistPath);
|
|
549
|
+
await writePlistAtomically(plistPath, renderLaunchAgentPlist({
|
|
550
|
+
label: SERVICE_AGENT_LABEL,
|
|
551
|
+
programArguments: [deps.execPath, deps.cliPath, "serve"],
|
|
552
|
+
logPath,
|
|
553
|
+
environment,
|
|
554
|
+
}));
|
|
555
|
+
/**
|
|
556
|
+
* Undo this install as far as it can honestly be undone, and say what
|
|
557
|
+
* happened. Two rules keep it from doing harm of its own: it never deletes
|
|
558
|
+
* a plist it could not read, and it re-bootstraps only what was already
|
|
559
|
+
* loaded when install started — starting a broker the user did not have
|
|
560
|
+
* running would take the host lease behind their back.
|
|
561
|
+
*/
|
|
562
|
+
const rollback = async () => {
|
|
563
|
+
const cleared = await verifiedBootout(deps, target, "the new agent");
|
|
564
|
+
if (!cleared.gone)
|
|
565
|
+
return `${cleared.reason}, so its plist was left in place`;
|
|
566
|
+
if (previous.kind === "unreadable") {
|
|
567
|
+
return "the new agent was unloaded, but the previous plist could not be read, so it was left in place rather than deleted; the plist on disk is the one this install wrote";
|
|
568
|
+
}
|
|
569
|
+
if (previous.kind === "absent") {
|
|
570
|
+
try {
|
|
571
|
+
await rm(plistPath, { force: true });
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
return `the new agent was unloaded, but its plist could not be removed (${errnoDetail(error)})`;
|
|
575
|
+
}
|
|
576
|
+
return "the new agent was unloaded and its plist removed; there was no previous install";
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
await writePlistAtomically(plistPath, previous.plist);
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
return `the new agent was unloaded, but the previous plist could not be restored (${errnoDetail(error)}), so the plist on disk is the one this install wrote`;
|
|
583
|
+
}
|
|
584
|
+
if (!wasLoadedBefore) {
|
|
585
|
+
return "the previous plist was restored; it was not loaded before this install, so it stays unloaded";
|
|
586
|
+
}
|
|
587
|
+
const restored = await deps.runLaunchctl(["bootstrap", domain, plistPath]);
|
|
588
|
+
if (restored.code !== 0) {
|
|
589
|
+
return `the previous plist was restored, but re-bootstrapping it failed (launchctl bootstrap exit ${restored.code}: ${launchctlDetail(restored)}); run \`embassy service install\` again`;
|
|
590
|
+
}
|
|
591
|
+
const confirmed = await confirmRunning(deps, target);
|
|
592
|
+
if (!confirmed.running) {
|
|
593
|
+
return `the previous plist was restored and re-bootstrapped, but launchd did not confirm it is running (launchctl ${confirmed.verb} exit ${confirmed.result.code}: ${launchctlDetail(confirmed.result)}); run \`embassy service status\``;
|
|
594
|
+
}
|
|
595
|
+
return "the previous plist was restored and re-bootstrapped";
|
|
596
|
+
};
|
|
597
|
+
const failInstall = async (verb, result) => {
|
|
598
|
+
throw launchctlFailure(verb, result, ` — rollback: ${await rollback()}.`);
|
|
599
|
+
};
|
|
600
|
+
const bootstrap = await deps.runLaunchctl(["bootstrap", domain, plistPath]);
|
|
601
|
+
if (bootstrap.code !== 0)
|
|
602
|
+
await failInstall("bootstrap", bootstrap);
|
|
603
|
+
// RunAtLoad starts the agent as part of bootstrap; `kickstart -k` would
|
|
604
|
+
// additionally *kill* a healthy broker, so it is never used. A plain
|
|
605
|
+
// kickstart is the fallback for the loaded-but-not-running case only.
|
|
606
|
+
const confirmed = await confirmRunning(deps, target);
|
|
607
|
+
if (!confirmed.running)
|
|
608
|
+
await failInstall(confirmed.verb, confirmed.result);
|
|
609
|
+
return {
|
|
610
|
+
label: SERVICE_AGENT_LABEL,
|
|
611
|
+
plistPath,
|
|
612
|
+
logPath,
|
|
613
|
+
capturedEnv: Object.keys(environment),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Boot the agent out, confirm that launchctl reports the label as *not
|
|
618
|
+
* found*, and only then unlink the plist. The confirmation must be that exact
|
|
619
|
+
* answer: treating any launchctl error (a missing binary, a gui domain that
|
|
620
|
+
* is not up, a timeout) as "gone" would unlink the plist while the agent is
|
|
621
|
+
* still loaded — invisible to `install`, unstoppable by `uninstall`. Logs are
|
|
622
|
+
* left in place.
|
|
623
|
+
*/
|
|
624
|
+
export async function uninstallServiceAgent(deps) {
|
|
625
|
+
const uid = requireUid(deps.uid);
|
|
626
|
+
const homeDir = requireAbsolutePath(deps.homeDir, "The login home");
|
|
627
|
+
const { plistPath, logPath } = serviceAgentPaths(homeDir);
|
|
628
|
+
const target = launchctlTarget(uid);
|
|
629
|
+
const cleared = await verifiedBootout(deps, target, "the agent");
|
|
630
|
+
if (!cleared.gone) {
|
|
631
|
+
throw new BridgeError("SERVICE_AGENT_COMMAND_FAILED", `Could not unload the launchd agent: ${cleared.reason}. Its plist was left in place.`, true);
|
|
632
|
+
}
|
|
633
|
+
const existing = await lstat(plistPath).catch(() => undefined);
|
|
634
|
+
if (existing !== undefined && (existing.isSymbolicLink() || !existing.isFile())) {
|
|
635
|
+
throw new BridgeError("SERVICE_AGENT_PATH_UNSAFE", `${plistPath} is not a regular file; refusing to remove it.`, false);
|
|
636
|
+
}
|
|
637
|
+
await rm(plistPath, { force: true });
|
|
638
|
+
return { label: SERVICE_AGENT_LABEL, plistPath, logPath };
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Tolerant reader for `launchctl print`. The sample below is synthetic — it
|
|
642
|
+
* is the shape this parser is written against, not a captured transcript —
|
|
643
|
+
* and the exit-status line is accepted under both spellings macOS has
|
|
644
|
+
* shipped (`last exit code` and `last exit status`):
|
|
645
|
+
*
|
|
646
|
+
* gui/501/com.agent-embassy.broker = {
|
|
647
|
+
* active count = 1
|
|
648
|
+
* state = running
|
|
649
|
+
* pid = 4242
|
|
650
|
+
* last exit code = 0
|
|
651
|
+
* }
|
|
652
|
+
*
|
|
653
|
+
* Anything this cannot recognize is reported as unknown, never as a loaded
|
|
654
|
+
* agent with blank fields.
|
|
655
|
+
*/
|
|
656
|
+
function parseLaunchctlPrintOutput(stdout) {
|
|
657
|
+
const result = {};
|
|
658
|
+
const pid = /^\s*pid\s*=\s*(\d+)\s*$/m.exec(stdout)?.[1];
|
|
659
|
+
if (pid !== undefined)
|
|
660
|
+
result.pid = Number(pid);
|
|
661
|
+
const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(stdout)?.[1];
|
|
662
|
+
if (state !== undefined)
|
|
663
|
+
result.launchdState = state;
|
|
664
|
+
const lastExitStatus = /^\s*last exit (?:code|status)\s*=\s*(-?\d+)\s*$/m.exec(stdout)?.[1];
|
|
665
|
+
if (lastExitStatus !== undefined)
|
|
666
|
+
result.lastExitStatus = Number(lastExitStatus);
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
/** The leading ProgramArguments entries of our own rendered plist, if recognizable. */
|
|
670
|
+
function parseProgramArguments(plist) {
|
|
671
|
+
const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1];
|
|
672
|
+
if (block === undefined)
|
|
673
|
+
return [];
|
|
674
|
+
return [...block.matchAll(/<string>([^<]*)<\/string>/g)].map((match) => xmlUnescape(match[1] ?? ""));
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Report what is actually knowable. A launchctl that cannot run, or output
|
|
678
|
+
* this version does not recognize, is `unknown` with the reason quoted — not
|
|
679
|
+
* "not loaded", and not "loaded" with blank fields. The plist's own
|
|
680
|
+
* ProgramArguments are checked against the filesystem too: a node binary
|
|
681
|
+
* under a version manager can be removed out from under an installed agent,
|
|
682
|
+
* which launchd otherwise reports only as a repeated spawn failure.
|
|
683
|
+
*/
|
|
684
|
+
export async function serviceAgentStatus(deps) {
|
|
685
|
+
const uid = requireUid(deps.uid);
|
|
686
|
+
const homeDir = requireAbsolutePath(deps.homeDir, "The login home");
|
|
687
|
+
const { plistPath, logPath } = serviceAgentPaths(homeDir);
|
|
688
|
+
let plistExists = false;
|
|
689
|
+
const programMissing = [];
|
|
690
|
+
const plistInfo = await lstat(plistPath).catch(() => undefined);
|
|
691
|
+
if (plistInfo !== undefined && plistInfo.isFile()) {
|
|
692
|
+
plistExists = true;
|
|
693
|
+
if (plistInfo.size <= MAX_PLIST_BYTES) {
|
|
694
|
+
const plist = await readFile(plistPath, "utf8").catch(() => undefined);
|
|
695
|
+
if (plist !== undefined) {
|
|
696
|
+
for (const program of parseProgramArguments(plist).slice(0, 2)) {
|
|
697
|
+
if (program.length === 0)
|
|
698
|
+
continue;
|
|
699
|
+
const reachable = await stat(program).then(() => true, () => false);
|
|
700
|
+
if (!reachable)
|
|
701
|
+
programMissing.push(program);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const printed = await deps.runLaunchctl(["print", launchctlTarget(uid)]);
|
|
707
|
+
const base = { label: SERVICE_AGENT_LABEL, plistPath, logPath, plistExists };
|
|
708
|
+
const suffix = `${plistExists ? "" : " The plist is missing."}${programMissing.length === 0
|
|
709
|
+
? ""
|
|
710
|
+
: ` program missing: ${boundedProgramList(programMissing)} — re-run \`embassy service install\`.`}`;
|
|
711
|
+
const missing = programMissing.length === 0 ? {} : { programMissing };
|
|
712
|
+
if (printed.code !== 0) {
|
|
713
|
+
if (isServiceNotFound(printed)) {
|
|
714
|
+
return {
|
|
715
|
+
...base, ...missing, state: "not loaded",
|
|
716
|
+
note: `The broker is not loaded as a launchd agent.${suffix}`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
return {
|
|
720
|
+
...base, ...missing, state: "unknown",
|
|
721
|
+
launchctlStderr: launchctlDetail(printed),
|
|
722
|
+
note: `launchctl could not report on the agent (exit ${printed.code}); its stderr is reported, trimmed and capped at 512 bytes, and its stdout is never quoted.${suffix}`,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
const parsed = parseLaunchctlPrintOutput(printed.stdout);
|
|
726
|
+
if (parsed.pid === undefined && parsed.launchdState === undefined &&
|
|
727
|
+
parsed.lastExitStatus === undefined) {
|
|
728
|
+
return {
|
|
729
|
+
...base, ...missing, state: "unknown",
|
|
730
|
+
note: `launchctl answered, but this version does not recognize its output.${suffix}`,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
...base, ...missing, ...parsed, state: "loaded",
|
|
735
|
+
note: `The broker is loaded as a launchd agent.${suffix}`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function execFileResult(command, args) {
|
|
739
|
+
return new Promise((resolve) => {
|
|
740
|
+
execFile(command, args, { encoding: "utf8", timeout: 15_000 }, (error, stdout, stderr) => {
|
|
741
|
+
if (error === null) {
|
|
742
|
+
resolve({ code: 0, stdout, stderr });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const code = typeof error.code === "number"
|
|
746
|
+
? error.code
|
|
747
|
+
: 1;
|
|
748
|
+
// A launchctl that never ran (missing binary, timeout kill) reports
|
|
749
|
+
// nothing on stderr; keep the spawn failure rather than an empty quote.
|
|
750
|
+
resolve({ code, stdout, stderr: stderr.length > 0 ? stderr : error.message });
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
/** The one production launchctl call site; every other reference to it in this module is through this runner. */
|
|
755
|
+
export async function defaultRunLaunchctl(args) {
|
|
756
|
+
return await execFileResult("/bin/launchctl", args);
|
|
757
|
+
}
|
|
758
|
+
//# sourceMappingURL=service-agent.js.map
|