@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911
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/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
package/src/service.ts
CHANGED
|
@@ -25,10 +25,10 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
|
|
|
25
25
|
export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
|
|
26
26
|
import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
|
|
27
27
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
28
|
-
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
28
|
+
import { readServiceApiTokenState, serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
29
29
|
import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
|
|
30
30
|
import { PROXY_ENV_KEYS } from "./lib/proxy-env";
|
|
31
|
-
import { randomUUID } from "node:crypto";
|
|
31
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
32
32
|
import {
|
|
33
33
|
ELEVATION_REQUEST_TIMEOUT_MS,
|
|
34
34
|
OCX_ELEVATED_PROTOCOL_FAILED,
|
|
@@ -63,7 +63,7 @@ import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
|
|
|
63
63
|
import { withWindowsServiceMutationLock } from "./lib/windows-service-mutation-lock";
|
|
64
64
|
import { maybeShowStarPrompt } from "./cli/star-prompt";
|
|
65
65
|
import { systemdProperty } from "./service-manager-probe";
|
|
66
|
-
import { isTestHomeGuardArmed } from "./lib/test-home-guard";
|
|
66
|
+
import { assertNotRealLaunchAgentsUnderTest, isProtectedHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard";
|
|
67
67
|
|
|
68
68
|
const LABEL = "com.opencodex.proxy";
|
|
69
69
|
const TASK = "opencodex-proxy";
|
|
@@ -71,7 +71,7 @@ const TASK = "opencodex-proxy";
|
|
|
71
71
|
export type ServiceBackend = "scheduler" | "native";
|
|
72
72
|
|
|
73
73
|
function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
|
|
74
|
-
// Bake the bundled Bun (
|
|
74
|
+
// Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than
|
|
75
75
|
// a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
|
|
76
76
|
// standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
|
|
77
77
|
//
|
|
@@ -98,11 +98,27 @@ function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: stri
|
|
|
98
98
|
* Only an absolute path is accepted. A bare `ocx` would be re-resolved through `PATH` on
|
|
99
99
|
* every restart, which turns a service definition into a PATH-hijacking surface; naming
|
|
100
100
|
* one validated absolute file keeps the target fixed at install time.
|
|
101
|
+
*
|
|
102
|
+
* The RECORDED launcher wins over a fresh PATH walk. `ocx service repair` runs from
|
|
103
|
+
* whatever shell the operator (or `ocx update`, or a tray helper) happened to have, and a
|
|
104
|
+
* context without `ocx` on `PATH` used to resolve null here — rewriting a working
|
|
105
|
+
* launcher-form plist into the version-pinned Bun + CLI pair and then booting the healthy
|
|
106
|
+
* job out to load it (#4236, defect 1g). A launcher that is still an executable file is
|
|
107
|
+
* the thing the installed service already runs, so repair must keep naming it; only a
|
|
108
|
+
* recorded launcher that has disappeared falls through to discovery.
|
|
109
|
+
*
|
|
110
|
+
* That preference is NOT macOS-only: `installSystemd` resolves this same function, so a
|
|
111
|
+
* Linux `ocx service repair` from a PATH-less context keeps the `ExecStart` the unit
|
|
112
|
+
* already has instead of rewriting it to the version-pinned pair — the #2898 shape this
|
|
113
|
+
* function exists to avoid. The failure mode it prevents is milder there (systemd
|
|
114
|
+
* `daemon-reload` + `restart` does not evict-then-maybe-nothing the way launchd did), but
|
|
115
|
+
* the rewrite was the same, so the behavior is deliberately shared rather than branched.
|
|
101
116
|
*/
|
|
102
117
|
export function stableLauncherEntry(deps: {
|
|
103
118
|
env?: NodeJS.ProcessEnv;
|
|
104
119
|
isExecutableFile?: (path: string) => boolean;
|
|
105
120
|
pathDelimiter?: string;
|
|
121
|
+
state?: ServiceInstallState | null;
|
|
106
122
|
} = {}): string | null {
|
|
107
123
|
const env = deps.env ?? process.env;
|
|
108
124
|
const isExecutableFile = deps.isExecutableFile ?? ((path: string): boolean => {
|
|
@@ -114,6 +130,8 @@ export function stableLauncherEntry(deps: {
|
|
|
114
130
|
return false;
|
|
115
131
|
}
|
|
116
132
|
});
|
|
133
|
+
const recorded = (deps.state === undefined ? readServiceInstallState() : deps.state)?.launcherPath;
|
|
134
|
+
if (recorded && isAbsolute(recorded) && isExecutableFile(recorded)) return recorded;
|
|
117
135
|
const entries = (env.PATH ?? "").split(deps.pathDelimiter ?? delimiter);
|
|
118
136
|
for (const entry of entries) {
|
|
119
137
|
if (!entry || !isAbsolute(entry)) continue;
|
|
@@ -163,7 +181,44 @@ export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string
|
|
|
163
181
|
}
|
|
164
182
|
|
|
165
183
|
function serviceStatePaths(): string[] {
|
|
166
|
-
|
|
184
|
+
const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
|
|
185
|
+
if (!isTestHomeGuardArmed()) return paths;
|
|
186
|
+
/*
|
|
187
|
+
* Under an armed test process the legacy default-home entry IS the developer's real
|
|
188
|
+
* `~/.opencodex/service-state.json`. It is there so an install made before
|
|
189
|
+
* OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME
|
|
190
|
+
* points at a sandbox still writes their live install state — observed while building
|
|
191
|
+
* the launchd repair coverage: one case replaced the real record's codexHome and
|
|
192
|
+
* opencodexHome with temp-directory paths. Drop it rather than deny the write, so the
|
|
193
|
+
* sandbox path keeps working and the real one is simply not in the list.
|
|
194
|
+
*
|
|
195
|
+
* The predicate is the guard's own, not a local `resolve()` compare: the guard
|
|
196
|
+
* canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...`
|
|
197
|
+
* resolves to `/private/var/folders/...`, so two spellings of one directory must not
|
|
198
|
+
* decide this.
|
|
199
|
+
*/
|
|
200
|
+
return paths.filter(path => !isProtectedHomeUnderTest(dirname(path)));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The state paths a WRITE may use. Same list, but an empty one is an error instead of a
|
|
205
|
+
* silent no-op.
|
|
206
|
+
*
|
|
207
|
+
* With OPENCODEX_HOME unset under an armed test process, `currentOpenCodexHome()` falls
|
|
208
|
+
* back to the real `~/.opencodex` (`os.homedir()` ignores `$HOME`), the filter above then
|
|
209
|
+
* removes every candidate, and `writeServiceInstallState` wrote NOTHING while reporting
|
|
210
|
+
* success — a test asserting on install state would read the previous run's record, or
|
|
211
|
+
* none. Fail the way `assertNotRealHomeUnderTest` does, naming the fix.
|
|
212
|
+
*/
|
|
213
|
+
function serviceStateWritePaths(): string[] {
|
|
214
|
+
const paths = serviceStatePaths();
|
|
215
|
+
if (paths.length > 0) return paths;
|
|
216
|
+
throw new Error(
|
|
217
|
+
"refusing to write service install state with no writable state path: every candidate "
|
|
218
|
+
+ "resolved to the real OpenCodex home and was filtered out. Point OPENCODEX_HOME at a "
|
|
219
|
+
+ "temp directory for this test (the preload does it for every invocation; something "
|
|
220
|
+
+ "deleted the variable without restoring it).",
|
|
221
|
+
);
|
|
167
222
|
}
|
|
168
223
|
|
|
169
224
|
function currentCodexHome(deps: CodexHomeDeps = {}): string {
|
|
@@ -254,7 +309,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler", launche
|
|
|
254
309
|
backend,
|
|
255
310
|
...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
|
|
256
311
|
};
|
|
257
|
-
for (const path of
|
|
312
|
+
for (const path of serviceStateWritePaths()) {
|
|
258
313
|
const dir = dirname(path);
|
|
259
314
|
recordOwnedConfigPath(getConfigDir(), path);
|
|
260
315
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -447,41 +502,93 @@ export function serviceRetryCommand(
|
|
|
447
502
|
* installing shell. This function is the chokepoint that should refuse it rather than
|
|
448
503
|
* writing a file that produces a broken service. Comparison is the same helper doctor
|
|
449
504
|
* uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
|
|
505
|
+
*
|
|
506
|
+
* `source` selects the remedy, not the rule. The token can also arrive from an EXISTING
|
|
507
|
+
* `service-api-token` that install/repair reuses, and there `unset` is meaningless advice —
|
|
508
|
+
* the fix is to delete the file so a data-plane token is generated.
|
|
450
509
|
*/
|
|
451
|
-
export function assertNotAdminToken(
|
|
510
|
+
export function assertNotAdminToken(
|
|
511
|
+
token: string,
|
|
512
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
513
|
+
source: "env" | "file" = "env",
|
|
514
|
+
): void {
|
|
452
515
|
if (!tokenCollidesWithAdmin(token, env)) return;
|
|
516
|
+
if (source === "file") {
|
|
517
|
+
// The file branch of `writeServiceApiTokenFile` used to skip this check entirely, so a
|
|
518
|
+
// hand-pasted admin token already on disk (pre-#2696, or the exact #4236 incident) was
|
|
519
|
+
// silently reused: `ocx status` said `present (file)` and the hub crash-looped at boot.
|
|
520
|
+
// The remedy is NOT `unset` -- there is nothing in the environment to unset.
|
|
521
|
+
throw new Error(
|
|
522
|
+
`${serviceApiTokenFilePath()} holds a management (admin) token, not a data-plane token. `
|
|
523
|
+
+ "The service exports that file as the data-plane secret, which fences the whole management "
|
|
524
|
+
+ "API closed and makes every ocx management command fail with 503, so the hub crash-loops at "
|
|
525
|
+
+ `boot. Delete the file (rm ${serviceApiTokenFilePath()}), then rerun \`ocx service repair\` `
|
|
526
|
+
+ "(or `ocx service install` when the service is not installed yet): a fresh owner-only "
|
|
527
|
+
+ "data-plane token is generated and nothing needs to be exported by hand.",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
453
530
|
throw new Error(
|
|
454
531
|
"OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
|
|
455
532
|
+ "as the data-plane secret, which fences the whole management API closed and makes "
|
|
456
|
-
+ "every ocx management command fail with 503.
|
|
457
|
-
+ "
|
|
533
|
+
+ "every ocx management command fail with 503. Run `unset OPENCODEX_API_AUTH_TOKEN` "
|
|
534
|
+
+ "and rerun: nothing needs to be exported by hand, because the service provisions "
|
|
535
|
+
+ `its own owner-only data-plane token at ${serviceApiTokenFilePath()}.`,
|
|
458
536
|
);
|
|
459
537
|
}
|
|
460
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Preflight for `service install` / `service repair` on the data-plane credential.
|
|
541
|
+
*
|
|
542
|
+
* It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw
|
|
543
|
+
* even when `~/.opencodex/service-api-token` already held a perfectly good token. That is
|
|
544
|
+
* the defect behind the incident this unit exists to close (#4236): an operator exported the
|
|
545
|
+
* ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then
|
|
546
|
+
* crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var
|
|
547
|
+
* again — so the only remembered way to make the command proceed was the thing that broke it.
|
|
548
|
+
*
|
|
549
|
+
* Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile}
|
|
550
|
+
* provisions one, so the only conditions left that install cannot fix are an admin-token
|
|
551
|
+
* collision in the environment and a token file that exists but cannot be used.
|
|
552
|
+
*/
|
|
461
553
|
export function assertServiceAuthEnvironment(): void {
|
|
462
554
|
const config = loadConfig();
|
|
463
|
-
//
|
|
464
|
-
// the token file
|
|
555
|
+
// Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper
|
|
556
|
+
// exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it
|
|
557
|
+
// exists, whatever the hostname): a management token in either source fences the whole
|
|
558
|
+
// management plane closed at boot, even on a loopback install that needs no admission
|
|
559
|
+
// secret. Returning early is what let that broken state through.
|
|
465
560
|
const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
466
561
|
if (present) assertNotAdminToken(present);
|
|
562
|
+
const state = readServiceApiTokenState();
|
|
563
|
+
// An existing FILE holding the admin token is the incident shape itself, and the first round
|
|
564
|
+
// only checked the env var — so install/repair reused it and the hub crash-looped at boot.
|
|
565
|
+
// On a machine connected to a hub this same file holds that hub's issued client key, which
|
|
566
|
+
// is never a management token, so the check is a no-op there.
|
|
567
|
+
if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file");
|
|
467
568
|
if (isLoopbackHostname(config.hostname)) return;
|
|
468
|
-
if (
|
|
469
|
-
//
|
|
470
|
-
//
|
|
569
|
+
if (present) return;
|
|
570
|
+
// Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses
|
|
571
|
+
// to replace a path it cannot vouch for, so say so here, where the operator can still act,
|
|
572
|
+
// instead of failing mid-install. Reached from `service repair` as well as `install`, so
|
|
573
|
+
// name a command that can actually succeed (see serviceRetryCommand).
|
|
574
|
+
if (state.kind !== "unsafe") return;
|
|
471
575
|
const diag = diagnoseService();
|
|
472
|
-
const retry = serviceRetryCommand(diag);
|
|
473
576
|
throw new Error(
|
|
474
|
-
`
|
|
475
|
-
+ `
|
|
577
|
+
`The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. `
|
|
578
|
+
+ `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a `
|
|
579
|
+
+ "fresh owner-only token and needs nothing from the environment.",
|
|
476
580
|
);
|
|
477
581
|
}
|
|
478
582
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
583
|
+
/** How the data-plane token the service will export was obtained. */
|
|
584
|
+
export type ServiceApiTokenOrigin = "env" | "file" | "generated";
|
|
585
|
+
|
|
586
|
+
export interface ProvisionedServiceApiToken {
|
|
587
|
+
path: string;
|
|
588
|
+
origin: ServiceApiTokenOrigin;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function persistServiceApiToken(token: string): string {
|
|
485
592
|
const path = serviceApiTokenFilePath();
|
|
486
593
|
const dir = getConfigDir();
|
|
487
594
|
recordOwnedConfigPath(dir, path);
|
|
@@ -493,6 +600,67 @@ function writeServiceApiTokenFile(): string | null {
|
|
|
493
600
|
return path;
|
|
494
601
|
}
|
|
495
602
|
|
|
603
|
+
/**
|
|
604
|
+
* Put a usable data-plane token on disk for the service to read at launch, and say where.
|
|
605
|
+
*
|
|
606
|
+
* EVERY backend funnels through here — launchd, systemd, the Windows scheduler wrapper and
|
|
607
|
+
* WinSW native — because the launch wrapper's only source of the secret is this file
|
|
608
|
+
* (`buildServiceShellCommand` cats it into the environment; WinSW reads it through
|
|
609
|
+
* `OCX_API_TOKEN_FILE`). One chokepoint is also what makes the admin-token refusal
|
|
610
|
+
* unskippable (#2696).
|
|
611
|
+
*
|
|
612
|
+
* Precedence, in order:
|
|
613
|
+
* 1. `OPENCODEX_API_AUTH_TOKEN` from the installing shell — still refused outright when it is
|
|
614
|
+
* an admin token. An operator who deliberately exports a key keeps full control of it.
|
|
615
|
+
* 2. An existing owner-only `service-api-token`. Reusing it is what makes `repair`, a
|
|
616
|
+
* reinstall and a restart idempotent; regenerating would silently invalidate every client
|
|
617
|
+
* key-exchange already performed against the old value.
|
|
618
|
+
* 3. 32 fresh random bytes, hex. This is the branch that removes the manual step: a hub
|
|
619
|
+
* install on a non-loopback hostname provisions its own secret.
|
|
620
|
+
*
|
|
621
|
+
* A loopback install with no env token gets nothing: admission is not required there, so
|
|
622
|
+
* creating a credential would be inventing a secret nobody asked for — and on a machine
|
|
623
|
+
* connected to a hub the same file holds that hub's issued client key, which must not be
|
|
624
|
+
* overwritten by a local install.
|
|
625
|
+
*
|
|
626
|
+
* The PATH is logged; the value never is, and never reaches argv, a unit file or a plist.
|
|
627
|
+
*/
|
|
628
|
+
export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null {
|
|
629
|
+
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
630
|
+
if (token) {
|
|
631
|
+
// Last line of defence: every install/repair path funnels through here, so a
|
|
632
|
+
// collision cannot reach disk regardless of which caller ran (#2696).
|
|
633
|
+
assertNotAdminToken(token);
|
|
634
|
+
const path = persistServiceApiToken(token);
|
|
635
|
+
console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
|
|
636
|
+
return { path, origin: "env" };
|
|
637
|
+
}
|
|
638
|
+
if (isLoopbackHostname(loadConfig().hostname)) return null;
|
|
639
|
+
const existing = readServiceApiTokenState();
|
|
640
|
+
if (existing.kind === "present") {
|
|
641
|
+
// The collision check is NOT only for the env branch. A file that already holds the admin
|
|
642
|
+
// token -- hand-pasted before #2696, or written by the very incident this unit closes --
|
|
643
|
+
// was silently accepted here, so `ocx status` reported `present (file)` and the hub
|
|
644
|
+
// crash-looped at boot with no command pointing at the cause.
|
|
645
|
+
const path = serviceApiTokenFilePath();
|
|
646
|
+
assertNotAdminToken(existing.token, process.env, "file");
|
|
647
|
+
// `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well
|
|
648
|
+
// be group- or world-readable. Tighten it on the way through rather than claiming
|
|
649
|
+
// "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod
|
|
650
|
+
// and failing the install over it would be worse than the loose mode.
|
|
651
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
652
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: false });
|
|
653
|
+
// No log line: repair/restart hit this on every run and an unconditional notice about a
|
|
654
|
+
// credential file trains operators to ignore the one that matters.
|
|
655
|
+
return { path, origin: "file" };
|
|
656
|
+
}
|
|
657
|
+
if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
|
|
658
|
+
const path = persistServiceApiToken(randomBytes(32).toString("hex"));
|
|
659
|
+
console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
|
|
660
|
+
console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
|
|
661
|
+
return { path, origin: "generated" };
|
|
662
|
+
}
|
|
663
|
+
|
|
496
664
|
/**
|
|
497
665
|
* Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx`
|
|
498
666
|
* executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a
|
|
@@ -537,9 +705,7 @@ export function buildPlist(
|
|
|
537
705
|
...proxyEnv.map(({ name, value }) =>
|
|
538
706
|
` <key>${name}</key><string>${plistString(value)}</string>`),
|
|
539
707
|
].filter((line): line is string => Boolean(line)).join("\n");
|
|
540
|
-
const command = launcher
|
|
541
|
-
? buildServiceLauncherShellCommand(launcher)
|
|
542
|
-
: buildServiceShellCommand(bun, cli);
|
|
708
|
+
const command = launchdServiceCommand(launcher, runtime);
|
|
543
709
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
544
710
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
545
711
|
<plist version="1.0">
|
|
@@ -564,6 +730,46 @@ ${envLines}
|
|
|
564
730
|
`;
|
|
565
731
|
}
|
|
566
732
|
|
|
733
|
+
/**
|
|
734
|
+
* The single `EnvironmentVariables` line {@link buildPlist} fills from the env of whatever
|
|
735
|
+
* process happens to be repairing.
|
|
736
|
+
*/
|
|
737
|
+
const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH`
|
|
741
|
+
* is not the only difference.
|
|
742
|
+
*
|
|
743
|
+
* `buildPlist` bakes `process.env.PATH`, and `ocx service repair` is run by whatever has a
|
|
744
|
+
* shell: a tray helper, `ocx update`'s child, an ssh session, a cron job. Each of those
|
|
745
|
+
* carries a DIFFERENT PATH from the login shell that installed the service, so comparing
|
|
746
|
+
* whole-file bytes made the "nothing to repair" pre-check miss almost every time it
|
|
747
|
+
* mattered: a healthy hub was evicted, and its PATH rewritten to the narrower one, purely
|
|
748
|
+
* because of who asked (#4236, review finding 2).
|
|
749
|
+
*
|
|
750
|
+
* Reuse rather than ignore. A plist that differs only in PATH is not "equal" — dropping the
|
|
751
|
+
* difference silently would let a repair report a no-op while launchd keeps a PATH the
|
|
752
|
+
* operator has changed on purpose. Putting the previous value back makes the two files
|
|
753
|
+
* genuinely identical, so the caller's ordinary byte comparison decides, and the PATH the
|
|
754
|
+
* service already runs with is the one that survives.
|
|
755
|
+
*
|
|
756
|
+
* The caller applies this only when the live job is loaded from exactly the exec line this
|
|
757
|
+
* install baked: that is the evidence that the running definition is the one on disk, which
|
|
758
|
+
* is what makes keeping its PATH correct rather than a guess. Whenever anything ELSE about
|
|
759
|
+
* the definition changed the plist is rewritten in full, PATH included, so a real
|
|
760
|
+
* re-install still updates it.
|
|
761
|
+
*/
|
|
762
|
+
export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null {
|
|
763
|
+
const prev = PLIST_PATH_ENTRY.exec(previous);
|
|
764
|
+
const next = PLIST_PATH_ENTRY.exec(rendered);
|
|
765
|
+
if (!prev || !next || prev[2] === next[2]) return null;
|
|
766
|
+
// A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would
|
|
767
|
+
// otherwise be re-expanded into the file.
|
|
768
|
+
const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) =>
|
|
769
|
+
`${open}${prev[2] ?? ""}${close}`);
|
|
770
|
+
return adopted === previous ? adopted : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
567
773
|
function shellQuote(value: string): string {
|
|
568
774
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
569
775
|
}
|
|
@@ -604,6 +810,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic
|
|
|
604
810
|
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`;
|
|
605
811
|
}
|
|
606
812
|
|
|
813
|
+
/**
|
|
814
|
+
* The exec line {@link buildPlist} bakes, for the launcher and runtime a single install
|
|
815
|
+
* already resolved. Shared so `installLaunchd` can verify the live job against the exact
|
|
816
|
+
* string it just wrote instead of re-deriving it from install state that has not been
|
|
817
|
+
* written yet (a fresh install has no state, so `expectedLaunchdCommand` would hand back
|
|
818
|
+
* the Bun + CLI pair and call a correctly loaded launcher job stale).
|
|
819
|
+
*/
|
|
820
|
+
function launchdServiceCommand(
|
|
821
|
+
launcher: string | null,
|
|
822
|
+
runtime: DurableBunRuntime = durableBunRuntime(),
|
|
823
|
+
port: number = resolveServiceListenPort(),
|
|
824
|
+
): string {
|
|
825
|
+
if (launcher) return buildServiceLauncherShellCommand(launcher, port);
|
|
826
|
+
const { bun, cli } = cliEntry(runtime);
|
|
827
|
+
return buildServiceShellCommand(bun, cli, port);
|
|
828
|
+
}
|
|
829
|
+
|
|
607
830
|
/**
|
|
608
831
|
* The exec line the installed launchd plist is expected to carry, derived from the recorded
|
|
609
832
|
* install state rather than rediscovered: a launcher install runs the launcher, a legacy or
|
|
@@ -788,7 +1011,7 @@ export async function confirmServiceServing(
|
|
|
788
1011
|
* dead port.
|
|
789
1012
|
*/
|
|
790
1013
|
export async function reportServiceServing(
|
|
791
|
-
verb: "installed" | "started" | "repaired",
|
|
1014
|
+
verb: "installed" | "started" | "repaired" | "restarted",
|
|
792
1015
|
deps: Parameters<typeof confirmServiceServing>[0] = {},
|
|
793
1016
|
): Promise<void> {
|
|
794
1017
|
const healthBudgetMs = deps.timeoutMs ?? serviceInstallHealthMs();
|
|
@@ -991,6 +1214,140 @@ export function launchdJobMatchesPlist(
|
|
|
991
1214
|
return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
|
|
992
1215
|
}
|
|
993
1216
|
|
|
1217
|
+
/** `launchctl print`: the domain answered and holds no such service. */
|
|
1218
|
+
const LAUNCHCTL_NO_SUCH_SERVICE = 113;
|
|
1219
|
+
/** `launchctl print`: that domain does not exist at all (label-independent). */
|
|
1220
|
+
const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
|
|
1221
|
+
/** `launchctl bootstrap`: something is already bootstrapped under that label. */
|
|
1222
|
+
const LAUNCHCTL_BOOTSTRAP_BUSY = 5;
|
|
1223
|
+
/** `launchctl bootout`: nothing was loaded under that label, i.e. already stopped. */
|
|
1224
|
+
const LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS = 3;
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Every domain target an eviction has to cover.
|
|
1228
|
+
*
|
|
1229
|
+
* {@link probeLaunchdLoadState} asks `gui/<uid>` AND `user/<uid>` because the two domains
|
|
1230
|
+
* are independent and hold separate service sets, while every MUTATING verb in this file
|
|
1231
|
+
* addressed `gui/<uid>` alone. So a `user/`-domain registration of our Label used to:
|
|
1232
|
+
* survive `ocx service stop` (`bootout gui/<uid>/<label>` exits 3, "No such process", which
|
|
1233
|
+
* the stop path correctly reads as "nothing was loaded" — in the wrong domain); survive the
|
|
1234
|
+
* install cleanup whose whole job is evicting a live manager before new assets land, which
|
|
1235
|
+
* then installed over a serving job; and stay registered while `installLaunchd` bootstrapped
|
|
1236
|
+
* a SECOND registration of the same Label into `gui/`, leaving two KeepAlive jobs fighting
|
|
1237
|
+
* for one port.
|
|
1238
|
+
*
|
|
1239
|
+
* Both domains unconditionally rather than the one a probe reports: `bootout` against a
|
|
1240
|
+
* label a domain does not hold exits 3 and changes nothing, so enumerating first would buy
|
|
1241
|
+
* an extra round trip to learn what the verb itself already reports.
|
|
1242
|
+
*/
|
|
1243
|
+
export function launchdEvictionTargets(uid: number = process.getuid?.() ?? 0): string[] {
|
|
1244
|
+
return [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`];
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Whether a `bootout` exit status means "nothing of ours was loaded there" rather than a
|
|
1249
|
+
* failure. 3 is "No such process"; 113/112 answer for the service and the domain, and a
|
|
1250
|
+
* domain that does not exist cannot be holding a job of ours (a headless Mac has no `gui/`).
|
|
1251
|
+
*/
|
|
1252
|
+
function launchctlBootoutBenign(status: number | null): boolean {
|
|
1253
|
+
return status === 0
|
|
1254
|
+
|| status === LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS
|
|
1255
|
+
|| status === LAUNCHCTL_NO_SUCH_SERVICE
|
|
1256
|
+
|| status === LAUNCHCTL_NO_SUCH_DOMAIN;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Four states, because three of them used to collapse into one bit.
|
|
1261
|
+
*
|
|
1262
|
+
* - `loaded-current` — a domain answers 0 and runs the command we expect.
|
|
1263
|
+
* - `loaded-stale` — a domain answers 0 but runs a different command (an older plist).
|
|
1264
|
+
* - `not-loaded` — every domain answered 112/113, which is proof of absence.
|
|
1265
|
+
* - `unknown` — launchctl could not be asked, or answered something undocumented. NOT
|
|
1266
|
+
* evidence of a problem, and deliberately not a reason to recommend `ocx service
|
|
1267
|
+
* repair`: that command evicts the job, so recommending it on a failed probe is how
|
|
1268
|
+
* #4236 turned a healthy hub into an outage.
|
|
1269
|
+
*/
|
|
1270
|
+
export type LaunchdLoadState = "loaded-current" | "loaded-stale" | "not-loaded" | "unknown";
|
|
1271
|
+
|
|
1272
|
+
export interface LaunchdLoadProbe {
|
|
1273
|
+
state: LaunchdLoadState;
|
|
1274
|
+
/** The domain that answered, when one did. */
|
|
1275
|
+
domain?: string;
|
|
1276
|
+
/** Why the probe is `unknown`. Never carries plist contents or credentials. */
|
|
1277
|
+
detail?: string;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Whether launchd is running our job, and from which plist — asked with `launchctl print`
|
|
1282
|
+
* in BOTH user domains.
|
|
1283
|
+
*
|
|
1284
|
+
* Replaces `launchctl list | grep <label>`, which enumerated the CALLER's bootstrap domain
|
|
1285
|
+
* (so a healthy `gui/$uid` job was invisible from ssh/cron), swallowed every exit code
|
|
1286
|
+
* through `|| true`, and matched the label unanchored anywhere on a line (so
|
|
1287
|
+
* `com.opencodex.proxy.helper` read as ours). `gui/` and `user/` are independent and hold
|
|
1288
|
+
* separate service sets — measured on macOS 27.0: the shipped agent answers 0 under
|
|
1289
|
+
* `gui/<uid>` and 113 under `user/<uid>` — so asking one leaves the other free to hold a
|
|
1290
|
+
* job this probe would then call absent (same reasoning as `inspectLaunchd`).
|
|
1291
|
+
*/
|
|
1292
|
+
export function probeLaunchdLoadState(deps: {
|
|
1293
|
+
launchctl?: typeof runLaunchctl;
|
|
1294
|
+
expectedCommand?: () => string;
|
|
1295
|
+
uid?: number;
|
|
1296
|
+
} = {}): LaunchdLoadProbe {
|
|
1297
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
1298
|
+
const uid = deps.uid ?? process.getuid?.() ?? 0;
|
|
1299
|
+
for (const domain of [`gui/${uid}`, `user/${uid}`]) {
|
|
1300
|
+
const printed = run(["print", `${domain}/${LABEL}`]);
|
|
1301
|
+
if (printed.status === 0) {
|
|
1302
|
+
const expected = (deps.expectedCommand
|
|
1303
|
+
?? (() => expectedLaunchdCommand(installedServiceListenPort())))();
|
|
1304
|
+
const printedText = `${printed.stdout}\n${printed.stderr}`;
|
|
1305
|
+
return {
|
|
1306
|
+
state: printedText.includes(expected) ? "loaded-current" : "loaded-stale",
|
|
1307
|
+
domain,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
|
|
1311
|
+
// 112 is an answer ABOUT THE DOMAIN and is label-independent, so an unreachable
|
|
1312
|
+
// domain cannot be hiding a job of ours. A headless Mac has no GUI domain and no
|
|
1313
|
+
// installation either; calling that `unknown` would refuse every verdict on it.
|
|
1314
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) continue;
|
|
1315
|
+
return {
|
|
1316
|
+
state: "unknown",
|
|
1317
|
+
detail: printed.status === null
|
|
1318
|
+
? `launchctl could not be run: ${printed.stderr || "spawn failed"}`
|
|
1319
|
+
: `launchctl print ${domain}/${LABEL} exited ${String(printed.status)}`,
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
return { state: "not-loaded" };
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */
|
|
1326
|
+
const LAUNCHD_SETTLE_ATTEMPTS = 5;
|
|
1327
|
+
const LAUNCHD_SETTLE_DELAY_MS = 200;
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Wait for a `bootout` to finish.
|
|
1331
|
+
*
|
|
1332
|
+
* `bootout` is asynchronous: it returns before the job has exited, so an immediate
|
|
1333
|
+
* re-registration races it and gets "Bootstrap failed: 5: Input/output error" — which is
|
|
1334
|
+
* exactly what made the old back-to-back retry useless (#4236, defect 1d). Bounded on
|
|
1335
|
+
* purpose: a genuinely wedged domain must reach the diagnosable throw rather than hang.
|
|
1336
|
+
*
|
|
1337
|
+
* Synchronous because `installLaunchd` is (`ServiceOps.install` / `repairLaunchd` are
|
|
1338
|
+
* `() => void`), so this uses `Bun.sleepSync` and exposes the seam for tests.
|
|
1339
|
+
*/
|
|
1340
|
+
function settleLaunchdEviction(
|
|
1341
|
+
run: typeof runLaunchctl,
|
|
1342
|
+
target: string,
|
|
1343
|
+
sleepSync: (ms: number) => void,
|
|
1344
|
+
): void {
|
|
1345
|
+
for (let attempt = 0; attempt < LAUNCHD_SETTLE_ATTEMPTS; attempt += 1) {
|
|
1346
|
+
if (!run(["print", target]).ok) return;
|
|
1347
|
+
sleepSync(LAUNCHD_SETTLE_DELAY_MS);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
994
1351
|
/**
|
|
995
1352
|
* Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
|
|
996
1353
|
* registered task document is UTF-16; reading that as UTF-8 makes every health check
|
|
@@ -2338,9 +2695,32 @@ export function readWindowsSchedulerXmlState(
|
|
|
2338
2695
|
}
|
|
2339
2696
|
|
|
2340
2697
|
// ── macOS (launchd) ──
|
|
2698
|
+
/** Read a file as UTF-8, or null when it is absent/unreadable. */
|
|
2699
|
+
function readTextOrNull(path: string): string | null {
|
|
2700
|
+
try {
|
|
2701
|
+
return readFileSync(path, "utf8");
|
|
2702
|
+
} catch {
|
|
2703
|
+
return null;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
/**
|
|
2708
|
+
* What an install or repair actually DID to launchd.
|
|
2709
|
+
*
|
|
2710
|
+
* `reloaded: false` means the no-op path was taken — the plist on disk was already the
|
|
2711
|
+
* rendered one, the data token was unchanged, and the probe answered `loaded-current` — so
|
|
2712
|
+
* launchd was never asked for anything and the job is still the same process it was. That
|
|
2713
|
+
* is the right answer for `repair` (a repair of a healthy service must not be an outage)
|
|
2714
|
+
* and the WRONG one for `restart`, which is the verb an operator reaches for precisely when
|
|
2715
|
+
* they want a new process. Only `restart` acts on it; see {@link restartLaunchdJob}.
|
|
2716
|
+
*/
|
|
2717
|
+
export interface LaunchdInstallOutcome {
|
|
2718
|
+
reloaded: boolean;
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2341
2721
|
/**
|
|
2342
2722
|
* Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
|
|
2343
|
-
* {@link runLaunchctl} result, not a spawnSync result.
|
|
2723
|
+
* {@link runLaunchctl} result, not a spawnSync result. Every one is optional so this stays
|
|
2344
2724
|
* assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
|
|
2345
2725
|
* (`() => void`), and so `platformOps` wires the same function the tests exercise.
|
|
2346
2726
|
*
|
|
@@ -2349,66 +2729,325 @@ export function readWindowsSchedulerXmlState(
|
|
|
2349
2729
|
* its read-only list, so a test reaching the real runner would fail closed on the guard
|
|
2350
2730
|
* instead of exercising the sequence.
|
|
2351
2731
|
*
|
|
2352
|
-
*
|
|
2353
|
-
*
|
|
2354
|
-
*
|
|
2732
|
+
* `probe` is the TRI-STATE {@link probeLaunchdLoadState}, used twice and for opposite
|
|
2733
|
+
* reasons: once before touching launchd, to prove a repair has nothing to do, and once
|
|
2734
|
+
* after, because stderr cannot prove a load took. It is deliberately not the two-state
|
|
2735
|
+
* `launchdJobMatchesPlist`, which reports `loaded: false` for every non-zero
|
|
2736
|
+
* `launchctl print` — EPERM from a non-Aqua ssh/cron context, an unspawnable launchctl, an
|
|
2737
|
+
* undocumented status. With that one, a healthy serving hub read as "not loaded" in the
|
|
2738
|
+
* pre-check (so repair evicted it) and again in the verification (so the rollback evicted
|
|
2739
|
+
* it a second time and the error claimed "IS NOT RUNNING" about a job that was up). An
|
|
2740
|
+
* `unknown` probe is not evidence, so it refuses to evict instead.
|
|
2741
|
+
*
|
|
2742
|
+
* Protocol (#4236, defect 1). `ocx service repair` on darwin IS this function, and it
|
|
2743
|
+
* evicts the running job — a public proxy, a management ingress and a loopback listener on
|
|
2744
|
+
* a hub. So:
|
|
2745
|
+
*
|
|
2746
|
+
* 1. Ask launchd what it is running BEFORE writing anything. `unknown` throws without
|
|
2747
|
+
* touching a file or a job; `loaded-current` plus an identical plist and an unchanged
|
|
2748
|
+
* token file means there is nothing to repair, and a repair of a healthy service must
|
|
2749
|
+
* never cause an outage.
|
|
2750
|
+
* 2. Keep the previous plist bytes (in memory and at `<plist>.prev`) before overwriting.
|
|
2751
|
+
* 3. `bootout` BOTH user domains, settle, then `bootstrap gui/$uid <plist>` — the verb
|
|
2752
|
+
* that PAIRS with the bootout target. Legacy `load -w` is domain-implicit: it acts on
|
|
2753
|
+
* the caller's own bootstrap domain, so from ssh/cron it deleted the gui-domain job and
|
|
2754
|
+
* registered nothing (defect 1a).
|
|
2755
|
+
* 4. Success is `launchctl print` agreeing, never a stderr regex: measured on macOS 27.0,
|
|
2756
|
+
* `load -w` over a bootstrapped job exits 0 with "Load failed: 5" and does nothing,
|
|
2757
|
+
* and `bootstrap` exits 5 with "Bootstrap failed: 5" for the same condition.
|
|
2758
|
+
* 5. On terminal failure restore the previous plist, try to bootstrap it back, and throw
|
|
2759
|
+
* an error that says what the probe actually found — down, or up on a different
|
|
2760
|
+
* command — and names the manual remedy.
|
|
2761
|
+
*
|
|
2762
|
+
* Returns {@link LaunchdInstallOutcome} so the one caller that needs a RESTART rather than a
|
|
2763
|
+
* repair can tell the no-op path from a reload. See {@link restartLaunchdJob}.
|
|
2355
2764
|
*/
|
|
2356
|
-
export function installLaunchd(deps: {
|
|
2765
|
+
export function installLaunchd(deps: {
|
|
2766
|
+
launchctl?: typeof runLaunchctl;
|
|
2767
|
+
probe?: typeof probeLaunchdLoadState;
|
|
2768
|
+
sleepSync?: (ms: number) => void;
|
|
2769
|
+
/**
|
|
2770
|
+
* Where to write the plist. Only tests pass it: `os.homedir()` reads the password
|
|
2771
|
+
* database rather than `$HOME`, so the suite's HOME sandbox does NOT move
|
|
2772
|
+
* `plistPath()`, and a case without this seam rewrites the developer's live
|
|
2773
|
+
* `com.opencodex.proxy.plist`. `assertNotRealLaunchAgentsUnderTest` below makes that
|
|
2774
|
+
* refusal mechanical rather than a convention.
|
|
2775
|
+
*/
|
|
2776
|
+
plistPath?: string;
|
|
2777
|
+
} = {}): LaunchdInstallOutcome {
|
|
2357
2778
|
const run = deps.launchctl ?? runLaunchctl;
|
|
2358
|
-
const
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
const p = plistPath();
|
|
2779
|
+
const probeLoadState = deps.probe ?? probeLaunchdLoadState;
|
|
2780
|
+
const sleepSync = deps.sleepSync ?? ((ms: number) => { Bun.sleepSync(ms); });
|
|
2781
|
+
const p = deps.plistPath ?? plistPath();
|
|
2782
|
+
const dir = dirname(p);
|
|
2783
|
+
assertNotRealLaunchAgentsUnderTest(dir);
|
|
2364
2784
|
// Capture this BEFORE writing: the write below makes the plist exist unconditionally,
|
|
2365
2785
|
// so a post-write existsSync would call every fresh install an "installed" service.
|
|
2366
2786
|
const wasInstalled = existsSync(p);
|
|
2787
|
+
// The previous definition, kept for rollback. An eviction whose bootstrap fails used to
|
|
2788
|
+
// end with the new plist on disk, nothing in launchd, and nothing listening.
|
|
2789
|
+
const previousPlist = wasInstalled ? readTextOrNull(p) : null;
|
|
2367
2790
|
// Resolve the launcher ONCE and hand the same value to the plist and to install state,
|
|
2368
2791
|
// so the staleness diagnostic judges exactly what launchd runs.
|
|
2369
2792
|
const launcher = stableLauncherEntry();
|
|
2370
|
-
|
|
2371
|
-
//
|
|
2372
|
-
//
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
//
|
|
2793
|
+
// The command THIS install bakes, not the one install state remembers: on a fresh
|
|
2794
|
+
// install there is no state yet, and after a lost state file `expectedLaunchdCommand`
|
|
2795
|
+
// falls back to the Bun + CLI pair and would call a correct launcher job stale (#3464).
|
|
2796
|
+
const expectedCommand = launchdServiceCommand(launcher);
|
|
2797
|
+
const uid = process.getuid?.() ?? 0;
|
|
2798
|
+
const guiDomain = launchdGuiDomain();
|
|
2799
|
+
const guiTarget = `${guiDomain}/${LABEL}`;
|
|
2800
|
+
const evictionTargets = launchdEvictionTargets(uid);
|
|
2801
|
+
const probeLive = (): LaunchdLoadProbe => probeLoadState({ expectedCommand: () => expectedCommand });
|
|
2802
|
+
|
|
2803
|
+
// Nothing has been written yet, deliberately: a probe that cannot answer must leave the
|
|
2804
|
+
// host exactly as it found it.
|
|
2805
|
+
let verdict = probeLive();
|
|
2806
|
+
if (verdict.state === "unknown") {
|
|
2807
|
+
throw new Error(
|
|
2808
|
+
`refusing to ${wasInstalled ? "repair" : "install"} ${LABEL}: launchd state could not be verified `
|
|
2809
|
+
+ `— ${verdict.detail ?? "launchctl could not be asked"}.\n`
|
|
2810
|
+
+ "The job may be RUNNING, and this command evicts it, so nothing was changed.\n"
|
|
2811
|
+
+ `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
|
|
2812
|
+
+ "A non-Aqua context (ssh, cron, a launchd daemon) cannot always reach gui/<uid>; re-run "
|
|
2813
|
+
+ `'${wasInstalled ? "ocx service repair" : "ocx service install"}' from a GUI login session.`,
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
let rendered = buildPlist(resolvedProxyEnv(), { launcher });
|
|
2818
|
+
if (previousPlist !== null && previousPlist !== rendered && verdict.state === "loaded-current") {
|
|
2819
|
+
// The live job runs exactly the exec line this install baked, so the definition on disk
|
|
2820
|
+
// IS the one launchd is running: keep the PATH it already carries instead of replacing
|
|
2821
|
+
// it with the repairing process's. See `reusePreviousPlistPathVariable`.
|
|
2822
|
+
const adopted = reusePreviousPlistPathVariable(previousPlist, rendered);
|
|
2823
|
+
if (adopted !== null) rendered = adopted;
|
|
2824
|
+
}
|
|
2825
|
+
// Whether launchd has to be handed NEW BYTES, which is what decides below whether a
|
|
2826
|
+
// `kickstart` can be trusted. `previousPlist === null` (a fresh install) counts: whatever
|
|
2827
|
+
// the label may hold did not come from a definition we can see.
|
|
2828
|
+
const renderedDiffers = previousPlist !== rendered;
|
|
2829
|
+
|
|
2830
|
+
// ── Writes start here. ──
|
|
2831
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2832
|
+
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
2833
|
+
if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
|
|
2834
|
+
const tokenFile = serviceApiTokenFilePath();
|
|
2835
|
+
const previousToken = readTextOrNull(tokenFile);
|
|
2836
|
+
writeServiceApiTokenFile();
|
|
2837
|
+
// A rotated data token only reaches the job through a restart, so it is part of "is this
|
|
2838
|
+
// repair a no-op?" — the plist `cat`s this file at launch.
|
|
2839
|
+
const tokenUnchanged = readTextOrNull(tokenFile) === previousToken;
|
|
2840
|
+
|
|
2841
|
+
if (!renderedDiffers && tokenUnchanged && verdict.state === "loaded-current") {
|
|
2842
|
+
// The plist is not rewritten and launchd is not touched; only the owner-only mode
|
|
2843
|
+
// is re-asserted, for a definition an older version may have left at 0644.
|
|
2844
|
+
try { chmodSync(p, 0o600); } catch { /* best-effort */ }
|
|
2845
|
+
// Install state is refreshed because it is what `expectedLaunchdCommand` reads, and
|
|
2846
|
+
// a repair that leaves it stale re-creates the false "OLDER plist" report.
|
|
2847
|
+
writeServiceInstallState("scheduler", launcher);
|
|
2848
|
+
console.log("ℹ️ service is already loaded from the current plist; nothing to do.");
|
|
2849
|
+
// The ONLY `reloaded: false` exit: the process launchd was running when this command
|
|
2850
|
+
// started is still running, same pid. `ocx service restart` turns that into a kickstart.
|
|
2851
|
+
return { reloaded: false };
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
if (previousPlist !== null) {
|
|
2855
|
+
// Best-effort: a backup we could not write must not stop the repair, but it is the
|
|
2856
|
+
// only thing that makes the rollback below able to restore bytes rather than guesses.
|
|
2857
|
+
//
|
|
2858
|
+
// `.plist.prev`, not `.prev.plist`: launchd globs `~/Library/LaunchAgents/*.plist` at
|
|
2859
|
+
// login, so a backup ending in `.plist` would be a second registration of the same
|
|
2860
|
+
// Label fighting the real one for the port.
|
|
2861
|
+
try { writeServiceDefinitionFile(`${p}.prev`, previousPlist, "utf8"); } catch { /* best-effort */ }
|
|
2862
|
+
}
|
|
2863
|
+
writeServiceDefinitionFile(p, rendered, "utf8");
|
|
2864
|
+
|
|
2865
|
+
// This EVICTS the running job, and from here until the verification below nothing is
|
|
2866
|
+
// listening. `unload` is the legacy verb and does not evict a job bootstrapped into the
|
|
2867
|
+
// GUI domain — precisely the state that could not repair itself (#4141).
|
|
2381
2868
|
//
|
|
2382
|
-
// Absence is fine: booting out a job that is not there
|
|
2383
|
-
// is reported by the
|
|
2869
|
+
// Absence is fine: booting out a job that is not there exits 3 ("No such process"), and
|
|
2870
|
+
// a real failure is reported by the verification below with a better message than a raw
|
|
2384
2871
|
// eviction error would carry.
|
|
2385
|
-
const
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2872
|
+
const evictEveryDomain = (): void => {
|
|
2873
|
+
for (const target of evictionTargets) {
|
|
2874
|
+
// Settle only where something was actually evicted. `bootout` is asynchronous, so a
|
|
2875
|
+
// job it DID remove needs waiting for; one it never held (exit 3) has nothing to
|
|
2876
|
+
// wait on, and probing it would only add a round trip per install.
|
|
2877
|
+
if (run(["bootout", target]).status === 0) settleLaunchdEviction(run, target, sleepSync);
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
const evictThenBootstrap = (): { ok: boolean; stdout: string; stderr: string; status: number | null } => {
|
|
2881
|
+
evictEveryDomain();
|
|
2882
|
+
return run(["bootstrap", guiDomain, p]);
|
|
2883
|
+
};
|
|
2884
|
+
|
|
2885
|
+
let loaded = evictThenBootstrap();
|
|
2886
|
+
verdict = probeLive();
|
|
2887
|
+
// `unknown` is excluded on purpose: a retry means another eviction, and a probe that
|
|
2888
|
+
// could not answer is not a reason to take the job down again.
|
|
2889
|
+
if (verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
|
|
2890
|
+
// ONE retry. A bounded retry recovers the race; a loop would turn a wedged domain
|
|
2891
|
+
// into a hang instead of the diagnosable throw below.
|
|
2892
|
+
if (loaded.status === LAUNCHCTL_BOOTSTRAP_BUSY || launchctlLoadFailed(loaded.stderr)) {
|
|
2893
|
+
// "Bootstrap failed: 5: Input/output error" has TWO causes, measured on macOS 27.0
|
|
2894
|
+
// with a throwaway label, and they need opposite remedies:
|
|
2895
|
+
//
|
|
2896
|
+
// - something is still bootstrapped under our label (the job re-registered, or it
|
|
2897
|
+
// had not finished exiting). `kickstart -k` restarts what the domain holds without
|
|
2898
|
+
// opening a second eviction window — but it restarts launchd's CACHED definition
|
|
2899
|
+
// and does NOT re-read the plist, so it can only settle this when the rendered
|
|
2900
|
+
// bytes are the ones already on disk. With new bytes it would restart the OLD
|
|
2901
|
+
// definition, and since the exec line is unchanged whenever only
|
|
2902
|
+
// `EnvironmentVariables` moved, the verification below would agree and install
|
|
2903
|
+
// state would be written while launchd kept the stale environment. So: new bytes
|
|
2904
|
+
// skip kickstart and go to the eviction, which is the only way to publish them.
|
|
2905
|
+
// - the label is in the domain's DISABLED list, so `bootstrap` refuses it while
|
|
2906
|
+
// `launchctl print` reports 113. This is the one thing the legacy `load -w` did
|
|
2907
|
+
// that plain `bootstrap` does not: `-w` cleared that flag. `enable` is the modern
|
|
2908
|
+
// spelling of it, and it is idempotent on a job that was never disabled — but it
|
|
2909
|
+
// runs only here, so an ordinary repair does not quietly undo a deliberate
|
|
2910
|
+
// `launchctl disable`.
|
|
2911
|
+
const kicked = renderedDiffers ? null : run(["kickstart", "-k", guiTarget]);
|
|
2912
|
+
if (kicked?.ok) verdict = probeLive();
|
|
2913
|
+
if (verdict.state !== "loaded-current") {
|
|
2914
|
+
run(["enable", guiTarget]);
|
|
2915
|
+
loaded = evictThenBootstrap();
|
|
2916
|
+
verdict = probeLive();
|
|
2917
|
+
}
|
|
2918
|
+
} else if (loaded.ok) {
|
|
2919
|
+
// Exit 0 while `print` disagrees: the load silently no-op'd. That IS worth evicting
|
|
2920
|
+
// again. A load that failed for any OTHER reason — a malformed plist, EPERM — is not
|
|
2921
|
+
// fixed by evicting a job, so it falls straight through to the throw and the
|
|
2922
|
+
// operator sees the real stderr instead of a delayed copy of it.
|
|
2923
|
+
loaded = evictThenBootstrap();
|
|
2924
|
+
verdict = probeLive();
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
if (verdict.state === "unknown") {
|
|
2929
|
+
// The probe stopped answering between the pre-check and here. Do NOT evict again, do
|
|
2930
|
+
// NOT roll back (a rollback is another eviction) and do NOT claim the job is down: the
|
|
2931
|
+
// bytes we asked launchd to load are the ones on disk either way.
|
|
2932
|
+
if (!loaded.ok) {
|
|
2933
|
+
throw new Error(
|
|
2934
|
+
`launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
|
|
2935
|
+
+ `and the state of ${LABEL} could not be verified afterwards — ${verdict.detail ?? "launchctl could not be asked"}.\n`
|
|
2936
|
+
+ "The job was NOT evicted again and the plist was left in place, so this says nothing about "
|
|
2937
|
+
+ "whether it is running.\n"
|
|
2938
|
+
+ `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
|
|
2939
|
+
+ `and load it if it is absent:\n launchctl bootstrap ${guiDomain} ${p}`,
|
|
2940
|
+
);
|
|
2941
|
+
}
|
|
2942
|
+
console.warn(
|
|
2943
|
+
`⚠️ launchctl accepted the bootstrap but the job state could not be verified — ${
|
|
2944
|
+
verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${guiTarget}`,
|
|
2945
|
+
);
|
|
2946
|
+
writeServiceInstallState("scheduler", launcher);
|
|
2947
|
+
return { reloaded: true };
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
if (verdict.state !== "loaded-current") {
|
|
2951
|
+
// Do NOT write install state for a load that did not take: state describing an unused
|
|
2952
|
+
// plist is what made this failure invisible.
|
|
2953
|
+
let rolledBack: "restored" | "on-disk-only" | "none" = "none";
|
|
2954
|
+
if (previousPlist !== null) {
|
|
2955
|
+
try {
|
|
2956
|
+
writeServiceDefinitionFile(p, previousPlist, "utf8");
|
|
2957
|
+
evictEveryDomain();
|
|
2958
|
+
run(["bootstrap", guiDomain, p]);
|
|
2959
|
+
rolledBack = run(["print", guiTarget]).ok ? "restored" : "on-disk-only";
|
|
2960
|
+
} catch {
|
|
2961
|
+
rolledBack = "on-disk-only";
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
// What the probe actually found, rather than one sentence for both outcomes: a
|
|
2965
|
+
// `loaded-stale` job IS running, and telling its operator "nothing is listening" sends
|
|
2966
|
+
// them to fix the wrong thing.
|
|
2967
|
+
const state = verdict.state === "loaded-stale"
|
|
2968
|
+
? `is still loaded in ${verdict.domain ?? guiDomain} from a DIFFERENT command than the plist just written`
|
|
2969
|
+
: rolledBack === "restored"
|
|
2970
|
+
? `was evicted from ${guiDomain}; the PREVIOUS plist was restored and re-bootstrapped`
|
|
2971
|
+
: `was evicted from ${guiDomain} and IS NOT RUNNING — nothing is listening`;
|
|
2399
2972
|
throw new Error(
|
|
2400
|
-
`launchctl could not
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
+ `
|
|
2973
|
+
`launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
|
|
2974
|
+
+ `The ${LABEL} job ${state}.\n`
|
|
2975
|
+
+ (rolledBack === "on-disk-only"
|
|
2976
|
+
? "The previous plist was restored on disk but could not be bootstrapped either.\n"
|
|
2977
|
+
: "")
|
|
2978
|
+
+ `Recover manually with:\n launchctl bootstrap ${guiDomain} ${p}\n`
|
|
2979
|
+
+ `Inspect it with:\n launchctl print ${guiTarget}\n launchctl print-disabled ${guiDomain}\n`
|
|
2406
2980
|
// macOS `service repair` delegates straight to installLaunchd, so this fires for
|
|
2407
2981
|
// an already-installed service too; repair reloads it without re-registering.
|
|
2408
2982
|
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
|
|
2409
2983
|
);
|
|
2410
2984
|
}
|
|
2411
2985
|
writeServiceInstallState("scheduler", launcher);
|
|
2986
|
+
// The rollback copy has done its job: the new definition is verified loaded. Leaving it
|
|
2987
|
+
// behind makes the NEXT repair's backup ambiguous (which failure did it come from?) and
|
|
2988
|
+
// `uninstall` the only thing that ever cleaned it up.
|
|
2989
|
+
if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
|
|
2990
|
+
return { reloaded: true };
|
|
2991
|
+
}
|
|
2992
|
+
/**
|
|
2993
|
+
* Restart the loaded job IN PLACE — the `restart` half of `ocx service restart`.
|
|
2994
|
+
*
|
|
2995
|
+
* Only reached when {@link installLaunchd} reported `reloaded: false`, i.e. the plist is
|
|
2996
|
+
* already the current one and the probe proved the job is loaded from it. Nothing has to be
|
|
2997
|
+
* published, so this must NOT evict: `kickstart -k` restarts what the domain already holds
|
|
2998
|
+
* without opening an eviction window, which is the whole reason `restart` can be honest
|
|
2999
|
+
* about a healthy service while `repair` stays a no-op on it. `kickstart` restarts the
|
|
3000
|
+
* definition launchd has CACHED and does not re-read the plist — harmless here, and exactly
|
|
3001
|
+
* why the retry path inside `installLaunchd` may only use it for bytes already on disk.
|
|
3002
|
+
*
|
|
3003
|
+
* `launchctl print` answers about REGISTRATION, not liveness, so the verification asks the
|
|
3004
|
+
* same tri-state probe `installLaunchd` does: `loaded-current` is the restart confirmed,
|
|
3005
|
+
* `unknown` is not evidence of anything and only warns, and absence after a kick means the
|
|
3006
|
+
* job went away and KeepAlive did not bring it back — which throws, so the repair branch
|
|
3007
|
+
* reports it and still runs its serving check.
|
|
3008
|
+
*
|
|
3009
|
+
* Both deps are test seams, and the default runner is also refused by
|
|
3010
|
+
* `assertLiveServiceManagerAllowed`: `kickstart` is not a read-only verb, so an armed test
|
|
3011
|
+
* process that reached the real runner would fail closed rather than bounce the developer's
|
|
3012
|
+
* own hub.
|
|
3013
|
+
*/
|
|
3014
|
+
export function restartLaunchdJob(deps: {
|
|
3015
|
+
launchctl?: typeof runLaunchctl;
|
|
3016
|
+
probe?: typeof probeLaunchdLoadState;
|
|
3017
|
+
/** The exec line the live job must carry; defaults to the one an install would bake. */
|
|
3018
|
+
expectedCommand?: () => string;
|
|
3019
|
+
} = {}): void {
|
|
3020
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
3021
|
+
const target = `${launchdGuiDomain()}/${LABEL}`;
|
|
3022
|
+
const expectedCommand = deps.expectedCommand
|
|
3023
|
+
?? (() => launchdServiceCommand(stableLauncherEntry()));
|
|
3024
|
+
const kicked = run(["kickstart", "-k", target]);
|
|
3025
|
+
const verdict = (deps.probe ?? probeLaunchdLoadState)({ expectedCommand });
|
|
3026
|
+
if (!kicked.ok || verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
|
|
3027
|
+
// Three different things to say, because they send the operator to three different
|
|
3028
|
+
// places: the job is gone, the job is up on an older definition, or the job is up and
|
|
3029
|
+
// `kickstart` refused — in which case the proxy is fine and only the restart failed.
|
|
3030
|
+
const state = verdict.state === "not-loaded"
|
|
3031
|
+
? `is NOT loaded in ${launchdGuiDomain()} — nothing is listening`
|
|
3032
|
+
: verdict.state === "loaded-stale"
|
|
3033
|
+
? "is loaded from a DIFFERENT command than the plist on disk"
|
|
3034
|
+
: "is still loaded, so it may be serving the process this restart failed to replace";
|
|
3035
|
+
throw new Error(
|
|
3036
|
+
`launchctl could not restart ${LABEL}: ${kicked.stderr || "kickstart reported failure"}\n`
|
|
3037
|
+
+ `The ${LABEL} job ${state}.\n`
|
|
3038
|
+
+ `Restart it manually with:\n launchctl kickstart -k ${target}\n`
|
|
3039
|
+
+ `Inspect it with:\n launchctl print ${target}\n`
|
|
3040
|
+
+ "and run 'ocx service repair' if it is absent.",
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
if (verdict.state === "unknown") {
|
|
3044
|
+
console.warn(
|
|
3045
|
+
`⚠️ launchctl accepted the restart but the job state could not be verified — ${
|
|
3046
|
+
verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${target}`,
|
|
3047
|
+
);
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
console.log(`ℹ️ service restarted (launchctl kickstart -k ${target}).`);
|
|
2412
3051
|
}
|
|
2413
3052
|
/**
|
|
2414
3053
|
* Deps are named for the layer they replace, not for the process API: `launchctl`
|
|
@@ -2445,12 +3084,60 @@ export function startLaunchd(deps: {
|
|
|
2445
3084
|
: "The job is not loaded. Run 'ocx service repair' to reload it."),
|
|
2446
3085
|
);
|
|
2447
3086
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
3087
|
+
/**
|
|
3088
|
+
* Evict the job with the modern, domain-explicit verb, in EVERY domain that can hold it;
|
|
3089
|
+
* fall back to legacy `unload` only when `bootout` could not be run at all.
|
|
3090
|
+
*
|
|
3091
|
+
* `unload` cannot evict a job bootstrapped into the GUI domain (the same reason
|
|
3092
|
+
* `installLaunchd` stopped using it), so a stop built on it reported success while the
|
|
3093
|
+
* proxy kept serving. Exit 3 ("Boot-out failed: 3: No such process") is the not-loaded
|
|
3094
|
+
* case and is not a failure here.
|
|
3095
|
+
*
|
|
3096
|
+
* `gui/<uid>` alone was the remaining half of that bug: `probeLaunchdLoadState` reports a
|
|
3097
|
+
* `user/<uid>` job too, and against one of those this function exited 3 in the wrong domain
|
|
3098
|
+
* and returned as if it had stopped something. See {@link launchdEvictionTargets}.
|
|
3099
|
+
*/
|
|
3100
|
+
function stopLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
|
|
3101
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
3102
|
+
let spawnable = true;
|
|
3103
|
+
try {
|
|
3104
|
+
for (const target of launchdEvictionTargets()) {
|
|
3105
|
+
// Any real exit status is final — including 3, which only means the job was not
|
|
3106
|
+
// loaded THERE. `status: null` is "launchctl could not be spawned at all", and only
|
|
3107
|
+
// then is the legacy verb worth one attempt.
|
|
3108
|
+
if (run(["bootout", target]).status === null) spawnable = false;
|
|
3109
|
+
}
|
|
3110
|
+
} catch {
|
|
3111
|
+
// The armed-test guard refuses mutating verbs; retrying through `sh` would only hit it
|
|
3112
|
+
// again.
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
if (spawnable) return;
|
|
3116
|
+
try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ }
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
/**
|
|
3120
|
+
* Registration for `ocx service stop`'s "is anything installed?" guard, as a human string.
|
|
3121
|
+
* Empty means "no job of ours is loaded"; the tri-state lives in
|
|
3122
|
+
* {@link probeLaunchdLoadState}, which `diagnoseService` uses instead of this.
|
|
3123
|
+
*/
|
|
3124
|
+
function statusLaunchd(deps: { probe?: typeof probeLaunchdLoadState } = {}): string {
|
|
3125
|
+
const probe = (deps.probe ?? probeLaunchdLoadState)();
|
|
3126
|
+
if (probe.state === "loaded-current") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
|
|
3127
|
+
if (probe.state === "loaded-stale") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()} from an OLDER plist`;
|
|
3128
|
+
if (probe.state === "unknown") return `${LABEL} state unknown: ${probe.detail ?? "launchctl could not be asked"}`;
|
|
3129
|
+
return "";
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function uninstallLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
|
|
2451
3133
|
const p = plistPath();
|
|
2452
|
-
|
|
3134
|
+
// Same reason as `installLaunchd`: HOME isolation does not move this path, so without
|
|
3135
|
+
// the guard an armed test process deletes the developer's live plist.
|
|
3136
|
+
assertNotRealLaunchAgentsUnderTest(dirname(p));
|
|
3137
|
+
stopLaunchd(deps);
|
|
2453
3138
|
if (existsSync(p)) unlinkSync(p);
|
|
3139
|
+
// The rollback copy is part of the installation, not a user file.
|
|
3140
|
+
if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
|
|
2454
3141
|
}
|
|
2455
3142
|
|
|
2456
3143
|
/**
|
|
@@ -2928,6 +3615,13 @@ async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string): Promi
|
|
|
2928
3615
|
}
|
|
2929
3616
|
}
|
|
2930
3617
|
|
|
3618
|
+
/**
|
|
3619
|
+
* The two CLI verbs `repairService` serves. They differ on ONE platform and ONE case: a
|
|
3620
|
+
* macOS job that is already loaded from the current plist, which `repair` must not touch and
|
|
3621
|
+
* `restart` must restart.
|
|
3622
|
+
*/
|
|
3623
|
+
export type ServiceRepairVerb = "repair" | "restart";
|
|
3624
|
+
|
|
2931
3625
|
export interface RepairServiceDeps {
|
|
2932
3626
|
diagnose?: () => ServiceDiagnostic;
|
|
2933
3627
|
assertEnv?: () => void;
|
|
@@ -2938,8 +3632,17 @@ export interface RepairServiceDeps {
|
|
|
2938
3632
|
writeSchedulerState?: () => void;
|
|
2939
3633
|
writeNativeState?: () => void;
|
|
2940
3634
|
repairNative?: () => void | Promise<void>;
|
|
2941
|
-
repairLaunchd?: () => void;
|
|
3635
|
+
repairLaunchd?: () => LaunchdInstallOutcome | void;
|
|
2942
3636
|
repairSystemd?: () => void;
|
|
3637
|
+
/** Restarts a launchd job the install path deliberately left alone. `restart` only. */
|
|
3638
|
+
restartLaunchd?: () => void;
|
|
3639
|
+
/**
|
|
3640
|
+
* Which CLI verb is being served. `repair` must leave a healthy service alone — that no-op
|
|
3641
|
+
* IS the #4236 fix — while `restart` promises a new process, so on darwin it kicks the job
|
|
3642
|
+
* the no-op path did not touch. Windows (stop + start) and Linux
|
|
3643
|
+
* (`systemctl --user restart`) already restart unconditionally, so neither reads this.
|
|
3644
|
+
*/
|
|
3645
|
+
verb?: ServiceRepairVerb;
|
|
2943
3646
|
/** Reads live registered task XML; may be called again after failure, empty when unreadable. */
|
|
2944
3647
|
readSchedulerXml?: () => string;
|
|
2945
3648
|
/** Bounded wait before retrying an unreadable live registration snapshot. */
|
|
@@ -3212,10 +3915,19 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
|
|
|
3212
3915
|
return;
|
|
3213
3916
|
}
|
|
3214
3917
|
if (platform === "darwin") {
|
|
3215
|
-
(deps.repairLaunchd ?? installLaunchd)();
|
|
3918
|
+
const outcome = (deps.repairLaunchd ?? installLaunchd)();
|
|
3919
|
+
// `installLaunchd` is the only function that knows whether it published anything, and its
|
|
3920
|
+
// no-op path leaves the live process running on purpose. `repair` wants exactly that;
|
|
3921
|
+
// `restart` would otherwise restart NOTHING on a healthy hub and send the operator to run
|
|
3922
|
+
// `launchctl kickstart -k` by hand, which is the opposite of what these verbs are for.
|
|
3923
|
+
if ((deps.verb ?? "repair") === "restart" && outcome?.reloaded === false) {
|
|
3924
|
+
(deps.restartLaunchd ?? restartLaunchdJob)();
|
|
3925
|
+
}
|
|
3216
3926
|
return;
|
|
3217
3927
|
}
|
|
3218
3928
|
if (platform === "linux") {
|
|
3929
|
+
// `installSystemd` ends in `systemctl --user restart`, unconditionally, so the unit is
|
|
3930
|
+
// restarted whichever verb asked — there is no no-op path here to compensate for.
|
|
3219
3931
|
(deps.repairSystemd ?? installSystemd)();
|
|
3220
3932
|
return;
|
|
3221
3933
|
}
|
|
@@ -3596,7 +4308,10 @@ export function systemdServiceInstallCleanupOps(deps: {
|
|
|
3596
4308
|
|
|
3597
4309
|
function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
3598
4310
|
if (process.platform === "darwin")
|
|
3599
|
-
|
|
4311
|
+
// Wrapped, not passed: `installLaunchd` reports whether it reloaded launchd, and only
|
|
4312
|
+
// `repairService` (for the `restart` verb) has any use for that. `ServiceOps.install` is
|
|
4313
|
+
// the generic install seam and deliberately promises nothing about a return value.
|
|
4314
|
+
return { install: () => { installLaunchd(); }, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
|
|
3600
4315
|
if (process.platform === "win32") {
|
|
3601
4316
|
if (backend === "native")
|
|
3602
4317
|
return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
|
|
@@ -3628,11 +4343,29 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
|
|
|
3628
4343
|
function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
|
|
3629
4344
|
if (process.platform === "darwin") {
|
|
3630
4345
|
return {
|
|
4346
|
+
// Same probe as `diagnoseService`, so the two answers to one question can no longer
|
|
4347
|
+
// have opposite failure semantics: this one used to throw on a launchctl error while
|
|
4348
|
+
// `statusLaunchd` swallowed it into "absent". Installing over an unverifiable
|
|
4349
|
+
// manager is the unsafe case, so `unknown` fails closed here.
|
|
3631
4350
|
status: () => {
|
|
3632
|
-
const
|
|
3633
|
-
|
|
4351
|
+
const probe = probeLaunchdLoadState();
|
|
4352
|
+
if (probe.state === "unknown") {
|
|
4353
|
+
throw new Error(`launchd job status could not be verified: ${probe.detail ?? "launchctl could not be asked"}`);
|
|
4354
|
+
}
|
|
4355
|
+
return probe.state === "not-loaded" ? null : `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
|
|
4356
|
+
},
|
|
4357
|
+
// `unload` is legacy and CANNOT evict a gui-domain job, which is what this cleanup
|
|
4358
|
+
// exists to do before new assets are installed. Both user domains, because the probe
|
|
4359
|
+
// above reports `user/<uid>` too: a gui-only bootout exits 3 against one of those,
|
|
4360
|
+
// which this function would have read as "already stopped" and installed over a live
|
|
4361
|
+
// job (see `launchdEvictionTargets`).
|
|
4362
|
+
stop: () => {
|
|
4363
|
+
for (const target of launchdEvictionTargets()) {
|
|
4364
|
+
const booted = runLaunchctl(["bootout", target]);
|
|
4365
|
+
if (launchctlBootoutBenign(booted.status)) continue;
|
|
4366
|
+
throw new Error(`launchctl bootout ${target} failed: ${booted.stderr || `exit ${String(booted.status)}`}`);
|
|
4367
|
+
}
|
|
3634
4368
|
},
|
|
3635
|
-
stop: () => { sh(`launchctl unload "${plistPath()}"`); },
|
|
3636
4369
|
};
|
|
3637
4370
|
}
|
|
3638
4371
|
if (process.platform === "win32") {
|
|
@@ -4310,6 +5043,61 @@ export function deriveWindowsServiceDiagnosticForCurrentUser(
|
|
|
4310
5043
|
});
|
|
4311
5044
|
}
|
|
4312
5045
|
|
|
5046
|
+
export interface LaunchdServiceDiagnosticInputs {
|
|
5047
|
+
installed: boolean;
|
|
5048
|
+
stale: boolean;
|
|
5049
|
+
load: LaunchdLoadProbe;
|
|
5050
|
+
diagnostics: string;
|
|
5051
|
+
}
|
|
5052
|
+
|
|
5053
|
+
/**
|
|
5054
|
+
* Turn the launchd tri-state into a {@link ServiceDiagnostic}. Pure, so the four states
|
|
5055
|
+
* are testable without a live launchd.
|
|
5056
|
+
*
|
|
5057
|
+
* `unknown` is the one that used to do damage. The old probe collapsed "launchctl could
|
|
5058
|
+
* not be asked" into "not loaded", which printed `installed, not loaded` for a serving hub
|
|
5059
|
+
* and recommended `ocx service repair` — the command that evicts the job (#4236). So:
|
|
5060
|
+
*
|
|
5061
|
+
* - the summary says the state could not be verified and names NO repair command, and
|
|
5062
|
+
* - `viable` stays true, because `isServiceViable() === false` is what makes
|
|
5063
|
+
* `src/update/index.ts` and `src/update/job.ts` treat a successful repair as a dead
|
|
5064
|
+
* supervisor and start a competing proxy on the service's own port. A failed probe is
|
|
5065
|
+
* not evidence against the service; `startable` is likewise left alone so the tray can
|
|
5066
|
+
* still hand a start to `ocx service start`, which no-ops on an already-loaded job.
|
|
5067
|
+
*
|
|
5068
|
+
* `loaded-stale` keeps the viability the `launchctl list` era gave it (loaded ⇒ viable, so
|
|
5069
|
+
* the update fallback behaves as before), and only the summary is upgraded — the operator
|
|
5070
|
+
* is told the live job came from an older plist, which is the one case where `repair` is
|
|
5071
|
+
* exactly right.
|
|
5072
|
+
*/
|
|
5073
|
+
export function deriveLaunchdServiceDiagnostic(inputs: LaunchdServiceDiagnosticInputs): ServiceDiagnostic {
|
|
5074
|
+
const { installed, stale, load, diagnostics } = inputs;
|
|
5075
|
+
const loaded = load.state === "loaded-current" || load.state === "loaded-stale";
|
|
5076
|
+
const running = installed && loaded;
|
|
5077
|
+
const verified = load.state !== "unknown";
|
|
5078
|
+
const viable = installed && !stale && (loaded || !verified);
|
|
5079
|
+
const summary = !installed ? `not installed (${diagnostics})`
|
|
5080
|
+
: stale ? `installed, but stale (launchd; ${diagnostics})`
|
|
5081
|
+
: load.state === "loaded-current" ? `installed and loaded (launchd; ${diagnostics})`
|
|
5082
|
+
: load.state === "loaded-stale"
|
|
5083
|
+
? `installed and loaded from an OLDER plist (launchd; ${diagnostics})`
|
|
5084
|
+
: load.state === "unknown"
|
|
5085
|
+
? `installed; launchd state could not be verified — ${load.detail ?? "launchctl could not be asked"} (launchd; ${diagnostics})`
|
|
5086
|
+
: `installed, not loaded (launchd; ${diagnostics})`;
|
|
5087
|
+
return {
|
|
5088
|
+
supported: true,
|
|
5089
|
+
installed,
|
|
5090
|
+
enabled: running,
|
|
5091
|
+
running,
|
|
5092
|
+
viable,
|
|
5093
|
+
startable: installed && !stale,
|
|
5094
|
+
stale,
|
|
5095
|
+
conflict: false,
|
|
5096
|
+
backend: "launchd",
|
|
5097
|
+
summary,
|
|
5098
|
+
};
|
|
5099
|
+
}
|
|
5100
|
+
|
|
4313
5101
|
/**
|
|
4314
5102
|
* Fail-closed restart diagnostic. Presence alone is never enough: conflicting
|
|
4315
5103
|
* managers, stale baked paths, disabled registrations, and unknown/stopped
|
|
@@ -4319,14 +5107,13 @@ export function diagnoseService(): ServiceDiagnostic {
|
|
|
4319
5107
|
const diagnostics = serviceDiagnosticsSummary();
|
|
4320
5108
|
if (process.platform === "darwin") {
|
|
4321
5109
|
const installed = existsSync(plistPath());
|
|
4322
|
-
const running = installed && Boolean(statusLaunchd());
|
|
4323
5110
|
const stale = installed && bakedServicePathsDiagnostic() !== null;
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
5111
|
+
return deriveLaunchdServiceDiagnostic({
|
|
5112
|
+
installed,
|
|
5113
|
+
stale,
|
|
5114
|
+
load: installed ? probeLaunchdLoadState() : { state: "not-loaded" },
|
|
5115
|
+
diagnostics,
|
|
5116
|
+
});
|
|
4330
5117
|
}
|
|
4331
5118
|
if (process.platform === "win32") {
|
|
4332
5119
|
const schedulerXml = statusWindowsXml();
|
|
@@ -4419,8 +5206,18 @@ export async function serviceStatusReport(
|
|
|
4419
5206
|
+ " Meanwhile: ocx start (serves in the foreground)";
|
|
4420
5207
|
}
|
|
4421
5208
|
|
|
5209
|
+
/**
|
|
5210
|
+
* `restart` is NO LONGER folded into `repair`.
|
|
5211
|
+
*
|
|
5212
|
+
* It used to be, and on macOS that made it a lie: `repair` now returns early when the plist
|
|
5213
|
+
* is already current and the job is loaded from it (#4236), so `ocx service restart` of a
|
|
5214
|
+
* healthy service restarted nothing and the operator had to run `launchctl kickstart -k` by
|
|
5215
|
+
* hand. The two verbs share the whole repair path and diverge only in `repairService`, which
|
|
5216
|
+
* kicks the launchd job the no-op left running. A BARE `ocx service` still maps to `repair`
|
|
5217
|
+
* (see {@link selectServiceSubcommand}): it is an idempotent "make it current", not a
|
|
5218
|
+
* request to bounce a healthy hub.
|
|
5219
|
+
*/
|
|
4422
5220
|
export function normalizeServiceSubcommand(sub?: string): string {
|
|
4423
|
-
if (sub === "restart") return "repair";
|
|
4424
5221
|
return sub ?? "install";
|
|
4425
5222
|
}
|
|
4426
5223
|
|
|
@@ -4579,15 +5376,30 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
4579
5376
|
process.exit(1);
|
|
4580
5377
|
}
|
|
4581
5378
|
const { parsed, command } = plan;
|
|
4582
|
-
if (command === "repair") {
|
|
5379
|
+
if (command === "repair" || command === "restart") {
|
|
5380
|
+
const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
|
|
4583
5381
|
assertServiceEnvironmentMatchesInstall();
|
|
4584
5382
|
assertServiceAuthEnvironment();
|
|
4585
|
-
|
|
5383
|
+
// A throw used to escape straight to the top level, so the one command that can
|
|
5384
|
+
// leave a macOS hub evicted never reached its own serving check (#4236, defect 1f).
|
|
5385
|
+
// Report the failure, then still ask whether anything is listening: on darwin the
|
|
5386
|
+
// rollback inside installLaunchd may have brought the previous job back, and on
|
|
5387
|
+
// Windows the preserve/restart protocol may have done the same. The operator needs
|
|
5388
|
+
// both halves of that answer, and the exit code stays non-zero either way.
|
|
5389
|
+
let repairError: unknown;
|
|
5390
|
+
try {
|
|
5391
|
+
await repairService({ verb });
|
|
5392
|
+
} catch (error) {
|
|
5393
|
+
repairError = error;
|
|
5394
|
+
console.error(`❌ Service ${verb} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
5395
|
+
process.exitCode = 1;
|
|
5396
|
+
}
|
|
4586
5397
|
// All three platforms: a repair that reports success while nothing serves is the
|
|
4587
5398
|
// defect class this unit exists to close. Windows bakes its port into the
|
|
4588
5399
|
// scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
|
|
4589
5400
|
// now reads.
|
|
4590
|
-
await reportServiceServing("repaired");
|
|
5401
|
+
await reportServiceServing(verb === "restart" ? "restarted" : "repaired");
|
|
5402
|
+
if (repairError !== undefined) process.exitCode = 1;
|
|
4591
5403
|
return;
|
|
4592
5404
|
}
|
|
4593
5405
|
// Non-install subcommands follow the backend recorded at install time (state v2).
|
|
@@ -4727,8 +5539,8 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
4727
5539
|
default:
|
|
4728
5540
|
console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
|
|
4729
5541
|
console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
|
|
4730
|
-
console.error(" repair: refresh
|
|
4731
|
-
console.error(" restart:
|
|
5542
|
+
console.error(" repair: refresh the installed backend, reloading it only when the definition changed; stale Windows tasks may request admin approval.");
|
|
5543
|
+
console.error(" restart: the same refresh, but always restarts the service — on macOS a healthy job is kickstarted in place.");
|
|
4732
5544
|
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
|
|
4733
5545
|
process.exit(1);
|
|
4734
5546
|
}
|