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.
Files changed (144) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CONTRIBUTING.md +19 -34
  3. package/README.md +110 -228
  4. package/SECURITY.md +55 -89
  5. package/dist/src/errors.d.ts +10 -1
  6. package/dist/src/errors.js +3 -1
  7. package/dist/src/errors.js.map +1 -1
  8. package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
  9. package/dist/src/gateway/claude-helper-protocol.js +12 -11
  10. package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
  11. package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
  12. package/dist/src/gateway/claude-helper-supervisor.js +10 -9
  13. package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
  14. package/dist/src/gateway/claude-helper.js +6 -6
  15. package/dist/src/gateway/claude-helper.js.map +1 -1
  16. package/dist/src/gateway/claude-peer.d.ts +0 -3
  17. package/dist/src/gateway/claude-peer.js +6 -18
  18. package/dist/src/gateway/claude-peer.js.map +1 -1
  19. package/dist/src/gateway/cli.d.ts +17 -6
  20. package/dist/src/gateway/cli.js +912 -261
  21. package/dist/src/gateway/cli.js.map +1 -1
  22. package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
  23. package/dist/src/gateway/codex-socket-holder.js +76 -0
  24. package/dist/src/gateway/codex-socket-holder.js.map +1 -0
  25. package/dist/src/gateway/codex-stateless-transport.js +1 -1
  26. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  27. package/dist/src/gateway/config.d.ts +1 -10
  28. package/dist/src/gateway/config.js +4 -10
  29. package/dist/src/gateway/config.js.map +1 -1
  30. package/dist/src/gateway/control.d.ts +45 -77
  31. package/dist/src/gateway/control.js +56 -141
  32. package/dist/src/gateway/control.js.map +1 -1
  33. package/dist/src/gateway/federation-nodes.d.ts +29 -2
  34. package/dist/src/gateway/federation-nodes.js +177 -7
  35. package/dist/src/gateway/federation-nodes.js.map +1 -1
  36. package/dist/src/gateway/peer-client.d.ts +4 -3
  37. package/dist/src/gateway/peer-client.js +22 -13
  38. package/dist/src/gateway/peer-client.js.map +1 -1
  39. package/dist/src/gateway/peer-protocol.d.ts +8 -11
  40. package/dist/src/gateway/peer-protocol.js +5 -12
  41. package/dist/src/gateway/peer-protocol.js.map +1 -1
  42. package/dist/src/gateway/provenance-envelope.d.ts +0 -1
  43. package/dist/src/gateway/provenance-envelope.js +4 -19
  44. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  45. package/dist/src/gateway/providers.d.ts +7 -4
  46. package/dist/src/gateway/providers.js +16 -20
  47. package/dist/src/gateway/providers.js.map +1 -1
  48. package/dist/src/gateway/server.d.ts +3 -12
  49. package/dist/src/gateway/server.js +31 -50
  50. package/dist/src/gateway/server.js.map +1 -1
  51. package/dist/src/gateway/service-agent.d.ts +187 -0
  52. package/dist/src/gateway/service-agent.js +758 -0
  53. package/dist/src/gateway/service-agent.js.map +1 -0
  54. package/dist/src/gateway/service.d.ts +114 -31
  55. package/dist/src/gateway/service.js +461 -566
  56. package/dist/src/gateway/service.js.map +1 -1
  57. package/dist/src/gateway/status-view.d.ts +167 -0
  58. package/dist/src/gateway/status-view.js +488 -0
  59. package/dist/src/gateway/status-view.js.map +1 -0
  60. package/dist/src/gateway/store.d.ts +103 -21
  61. package/dist/src/gateway/store.js +454 -529
  62. package/dist/src/gateway/store.js.map +1 -1
  63. package/dist/src/gateway/types.d.ts +48 -99
  64. package/dist/src/gateway/types.js +15 -52
  65. package/dist/src/gateway/types.js.map +1 -1
  66. package/docs/CONFIGURATION.md +173 -44
  67. package/docs/DELIVERY.md +11 -11
  68. package/docs/GATEWAY-ARCHITECTURE.md +277 -375
  69. package/package.json +4 -12
  70. package/skills/embassy-peer/SKILL.md +65 -90
  71. package/skills/embassy-peer/agents/openai.yaml +1 -1
  72. package/README.zh-CN.md +0 -275
  73. package/assets/live-dashboard/app.css +0 -1619
  74. package/assets/vendor/react/LICENSE +0 -21
  75. package/assets/vendor/react/react-dom.production.min.js +0 -267
  76. package/assets/vendor/react/react.production.min.js +0 -31
  77. package/dist/src/gateway/acp-client.d.ts +0 -110
  78. package/dist/src/gateway/acp-client.js +0 -407
  79. package/dist/src/gateway/acp-client.js.map +0 -1
  80. package/dist/src/gateway/acp-provider.d.ts +0 -66
  81. package/dist/src/gateway/acp-provider.js +0 -275
  82. package/dist/src/gateway/acp-provider.js.map +0 -1
  83. package/dist/src/gateway/cli-copy.d.ts +0 -8
  84. package/dist/src/gateway/cli-copy.en.d.ts +0 -22
  85. package/dist/src/gateway/cli-copy.en.js +0 -62
  86. package/dist/src/gateway/cli-copy.en.js.map +0 -1
  87. package/dist/src/gateway/cli-copy.js +0 -27
  88. package/dist/src/gateway/cli-copy.js.map +0 -1
  89. package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
  90. package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
  91. package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
  92. package/dist/src/gateway/codex-doctor.d.ts +0 -36
  93. package/dist/src/gateway/codex-doctor.js +0 -127
  94. package/dist/src/gateway/codex-doctor.js.map +0 -1
  95. package/dist/src/gateway/dashboard-copy.d.ts +0 -7
  96. package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
  97. package/dist/src/gateway/dashboard-copy.en.js +0 -505
  98. package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
  99. package/dist/src/gateway/dashboard-copy.js +0 -514
  100. package/dist/src/gateway/dashboard-copy.js.map +0 -1
  101. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
  102. package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
  103. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
  104. package/dist/src/gateway/dashboard-model.d.ts +0 -343
  105. package/dist/src/gateway/dashboard-model.js +0 -1061
  106. package/dist/src/gateway/dashboard-model.js.map +0 -1
  107. package/dist/src/gateway/dashboard.d.ts +0 -20
  108. package/dist/src/gateway/dashboard.js +0 -874
  109. package/dist/src/gateway/dashboard.js.map +0 -1
  110. package/dist/src/gateway/deepseek-detect.d.ts +0 -14
  111. package/dist/src/gateway/deepseek-detect.js +0 -41
  112. package/dist/src/gateway/deepseek-detect.js.map +0 -1
  113. package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
  114. package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
  115. package/dist/src/gateway/live-dashboard-assets.js +0 -74
  116. package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
  117. package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
  118. package/dist/src/gateway/live-dashboard-command.js +0 -334
  119. package/dist/src/gateway/live-dashboard-command.js.map +0 -1
  120. package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
  121. package/dist/src/gateway/live-dashboard-http.js +0 -383
  122. package/dist/src/gateway/live-dashboard-http.js.map +0 -1
  123. package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
  124. package/dist/src/gateway/live-dashboard-protocol.js +0 -114
  125. package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
  126. package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
  127. package/dist/src/gateway/live-dashboard-server.js +0 -144
  128. package/dist/src/gateway/live-dashboard-server.js.map +0 -1
  129. package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
  130. package/dist/src/gateway/live-dashboard-stream.js +0 -234
  131. package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
  132. package/dist/src/gateway/live-dashboard.d.ts +0 -28
  133. package/dist/src/gateway/live-dashboard.js +0 -154
  134. package/dist/src/gateway/live-dashboard.js.map +0 -1
  135. package/dist/src/gateway/locale.d.ts +0 -4
  136. package/dist/src/gateway/locale.js +0 -10
  137. package/dist/src/gateway/locale.js.map +0 -1
  138. package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
  139. package/dist/src/gateway/progress-watch-machine.js +0 -70
  140. package/dist/src/gateway/progress-watch-machine.js.map +0 -1
  141. package/docs/CONFIGURATION.zh-CN.md +0 -97
  142. package/docs/DASHBOARD.md +0 -98
  143. package/docs/DASHBOARD.zh-CN.md +0 -49
  144. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
68
+ }
69
+ function xmlUnescape(value) {
70
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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