@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { statusWinswRaw, winswStatusSummary } from "../lib/winsw";
|
|
3
|
+
import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
|
|
4
|
+
import { sh } from "./guards";
|
|
5
|
+
import { installedServiceListenPort, confirmServiceServing } from "./health";
|
|
6
|
+
import { expectedLaunchdCommand, launchdJobMatchesPlist, probeLaunchdLoadState } from "./launchd";
|
|
7
|
+
import type { LaunchdLoadProbe } from "./launchd";
|
|
8
|
+
import { plistPath, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, readServiceInstallState } from "./state";
|
|
9
|
+
import type { ServiceBackend } from "./state";
|
|
10
|
+
import { unitPath, isSystemd } from "./systemd";
|
|
11
|
+
import { statusWindowsXml } from "./windows-ops";
|
|
12
|
+
import { cachedWindowsTaskUserIds, taskXmlSection, taskXmlWithoutCommentsAndCdata, taskXmlElementCount, readWindowsSchedulerXmlState } from "./windows-taskxml";
|
|
13
|
+
import type { ExpectedWindowsTaskUserId } from "./windows-taskxml";
|
|
14
|
+
import { join, win32 } from "node:path";
|
|
15
|
+
import { WINSW_VERSION } from "../lib/winsw";
|
|
16
|
+
import { serviceRepairCommand } from "./health";
|
|
17
|
+
import { LABEL, TASK, serviceLogPath } from "./state";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Warn when the paths baked into installed service assets no longer exist (npm prefix
|
|
21
|
+
* moved, nvm switch, reinstall) — the service manager would restart-loop on a dead path
|
|
22
|
+
* while `schtasks`/`launchctl` still report "installed".
|
|
23
|
+
*/
|
|
24
|
+
export function bakedServicePathsDiagnostic(): string | null {
|
|
25
|
+
const state = readServiceInstallState();
|
|
26
|
+
// A launcher install runs the launcher, not the baked pair, so the pair's existence says
|
|
27
|
+
// nothing about whether the service can start. Judging the recorded launcher is both
|
|
28
|
+
// necessary (a deleted launcher IS stale) and sufficient (a replaced version directory
|
|
29
|
+
// is not, which is exactly what #2898 made routine).
|
|
30
|
+
if (state?.launcherPath) {
|
|
31
|
+
if (existsSync(state.launcherPath)) return null;
|
|
32
|
+
return `STALE baked paths (missing: ${state.launcherPath}) — run 'ocx service repair' to re-bake`;
|
|
33
|
+
}
|
|
34
|
+
if (!state?.bunPath || !state?.cliPath) return null;
|
|
35
|
+
const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
|
|
36
|
+
if (missing.length === 0) return null;
|
|
37
|
+
return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function serviceDiagnosticsSummary(): string {
|
|
41
|
+
const stale = bakedServicePathsDiagnostic();
|
|
42
|
+
return stale ? `${stale}; logs: ${serviceLogPath()}` : `logs: ${serviceLogPath()}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ServiceDiagnostic {
|
|
46
|
+
supported: boolean;
|
|
47
|
+
installed: boolean;
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
running: boolean;
|
|
50
|
+
viable: boolean;
|
|
51
|
+
startable: boolean;
|
|
52
|
+
stale: boolean;
|
|
53
|
+
conflict: boolean;
|
|
54
|
+
backend: ServiceBackend | "launchd" | "systemd" | null;
|
|
55
|
+
summary: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Windows tray may restart a healthy-but-stopped native service; stale/conflicting installs remain blocked. */
|
|
59
|
+
export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
|
|
60
|
+
return service.startable && !service.stale && !service.conflict;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface WindowsTaskDiagnosticIdentityDeps {
|
|
64
|
+
currentIdentity?: () => Readonly<{ sid: string; name: string }> | null;
|
|
65
|
+
resolvePrincipal?: (timeoutMs: number) => string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the effective account only when the registered task carries an explicit unprefixed
|
|
70
|
+
* trigger scope. Empty/unscoped tasks do not need identity and must not pay a repeated sync
|
|
71
|
+
* lookup timeout; prefixed scopes remain unreadable and fail closed in the XML validator.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveWindowsTaskDiagnosticUserId(
|
|
74
|
+
schedulerXml: string,
|
|
75
|
+
deps: WindowsTaskDiagnosticIdentityDeps = {},
|
|
76
|
+
): readonly string[] | null {
|
|
77
|
+
const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity;
|
|
78
|
+
const cached = currentIdentity();
|
|
79
|
+
if (cached) return [cached.sid, cached.name];
|
|
80
|
+
|
|
81
|
+
const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml);
|
|
82
|
+
const triggers = taskXmlSection(scrubbed, "Triggers");
|
|
83
|
+
if (taskXmlElementCount(triggers, "UserId") === 0) return null;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
(deps.resolvePrincipal ?? resolveCurrentWindowsPrincipal)(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const resolved = currentIdentity();
|
|
91
|
+
return resolved ? [resolved.sid, resolved.name] : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WindowsServiceDiagnosticInputs {
|
|
95
|
+
/**
|
|
96
|
+
* Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
|
|
97
|
+
* XML rather than pre-computed booleans so every caller reads the document through
|
|
98
|
+
* readWindowsSchedulerXmlState() — a second, stricter reading elsewhere would
|
|
99
|
+
* silently reintroduce the stale-status false positive (#432).
|
|
100
|
+
*/
|
|
101
|
+
schedulerXml: string;
|
|
102
|
+
/** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */
|
|
103
|
+
schedulerExpectedUserId?: ExpectedWindowsTaskUserId | null;
|
|
104
|
+
/** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
|
|
105
|
+
schedulerAssetsPresent: boolean;
|
|
106
|
+
nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
|
|
107
|
+
recordedBackend: ServiceBackend | null;
|
|
108
|
+
staleBakedPaths: boolean;
|
|
109
|
+
nativeRepairAssetsOnly: boolean;
|
|
110
|
+
diagnostics: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
|
|
114
|
+
const expectedUserId = inputs.schedulerExpectedUserId === undefined
|
|
115
|
+
? cachedWindowsTaskUserIds()
|
|
116
|
+
: inputs.schedulerExpectedUserId;
|
|
117
|
+
const schedulerState = readWindowsSchedulerXmlState(
|
|
118
|
+
inputs.schedulerXml,
|
|
119
|
+
undefined,
|
|
120
|
+
undefined,
|
|
121
|
+
expectedUserId,
|
|
122
|
+
);
|
|
123
|
+
const schedulerInstalled = schedulerState.installed;
|
|
124
|
+
const schedulerEnabled = schedulerState.enabled;
|
|
125
|
+
const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
|
|
126
|
+
const nativeInstalled = inputs.nativeStatus !== "nonexistent";
|
|
127
|
+
const conflict = schedulerInstalled && nativeInstalled;
|
|
128
|
+
const backendStateMismatch = schedulerInstalled
|
|
129
|
+
? inputs.recordedBackend !== "scheduler"
|
|
130
|
+
: nativeInstalled && inputs.recordedBackend !== "native";
|
|
131
|
+
const stale = inputs.staleBakedPaths
|
|
132
|
+
|| (schedulerInstalled && !schedulerAssetsHealthy)
|
|
133
|
+
|| backendStateMismatch
|
|
134
|
+
|| (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly);
|
|
135
|
+
const backend = schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
|
|
136
|
+
const enabled = schedulerInstalled ? schedulerEnabled : inputs.nativeStatus === "started";
|
|
137
|
+
const running = nativeInstalled ? inputs.nativeStatus === "started" : schedulerInstalled && schedulerEnabled;
|
|
138
|
+
const viable = !conflict && !stale
|
|
139
|
+
&& (schedulerInstalled ? schedulerEnabled && schedulerAssetsHealthy : inputs.nativeStatus === "started");
|
|
140
|
+
const startable = !conflict && !stale
|
|
141
|
+
&& (schedulerInstalled
|
|
142
|
+
? schedulerEnabled && schedulerAssetsHealthy
|
|
143
|
+
: inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
|
|
144
|
+
const detail = conflict
|
|
145
|
+
? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
|
|
146
|
+
: stale
|
|
147
|
+
? "stale or missing service assets — run 'ocx service repair'"
|
|
148
|
+
: schedulerInstalled
|
|
149
|
+
? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
|
|
150
|
+
: nativeInstalled
|
|
151
|
+
? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}`
|
|
152
|
+
: "not installed";
|
|
153
|
+
const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`;
|
|
154
|
+
return {
|
|
155
|
+
supported: true,
|
|
156
|
+
installed: schedulerInstalled || nativeInstalled,
|
|
157
|
+
enabled,
|
|
158
|
+
running,
|
|
159
|
+
viable,
|
|
160
|
+
startable,
|
|
161
|
+
stale,
|
|
162
|
+
conflict,
|
|
163
|
+
backend,
|
|
164
|
+
summary,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bind the live Windows identity to a scheduler snapshot before deriving service health. */
|
|
169
|
+
export function deriveWindowsServiceDiagnosticForCurrentUser(
|
|
170
|
+
inputs: Omit<WindowsServiceDiagnosticInputs, "schedulerExpectedUserId">,
|
|
171
|
+
identityDeps: WindowsTaskDiagnosticIdentityDeps = {},
|
|
172
|
+
): ServiceDiagnostic {
|
|
173
|
+
return deriveWindowsServiceDiagnostic({
|
|
174
|
+
...inputs,
|
|
175
|
+
schedulerExpectedUserId: resolveWindowsTaskDiagnosticUserId(inputs.schedulerXml, identityDeps),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface LaunchdServiceDiagnosticInputs {
|
|
180
|
+
installed: boolean;
|
|
181
|
+
stale: boolean;
|
|
182
|
+
load: LaunchdLoadProbe;
|
|
183
|
+
diagnostics: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Turn the launchd tri-state into a {@link ServiceDiagnostic}. Pure, so the four states
|
|
188
|
+
* are testable without a live launchd.
|
|
189
|
+
*
|
|
190
|
+
* `unknown` is the one that used to do damage. The old probe collapsed "launchctl could
|
|
191
|
+
* not be asked" into "not loaded", which printed `installed, not loaded` for a serving hub
|
|
192
|
+
* and recommended `ocx service repair` — the command that evicts the job (#4236). So:
|
|
193
|
+
*
|
|
194
|
+
* - the summary says the state could not be verified and names NO repair command, and
|
|
195
|
+
* - `viable` stays true, because `isServiceViable() === false` is what makes
|
|
196
|
+
* `src/update/index.ts` and `src/update/job.ts` treat a successful repair as a dead
|
|
197
|
+
* supervisor and start a competing proxy on the service's own port. A failed probe is
|
|
198
|
+
* not evidence against the service; `startable` is likewise left alone so the tray can
|
|
199
|
+
* still hand a start to `ocx service start`, which no-ops on an already-loaded job.
|
|
200
|
+
*
|
|
201
|
+
* `loaded-stale` keeps the viability the `launchctl list` era gave it (loaded ⇒ viable, so
|
|
202
|
+
* the update fallback behaves as before), and only the summary is upgraded — the operator
|
|
203
|
+
* is told the live job came from an older plist, which is the one case where `repair` is
|
|
204
|
+
* exactly right.
|
|
205
|
+
*/
|
|
206
|
+
export function deriveLaunchdServiceDiagnostic(inputs: LaunchdServiceDiagnosticInputs): ServiceDiagnostic {
|
|
207
|
+
const { installed, stale, load, diagnostics } = inputs;
|
|
208
|
+
const loaded = load.state === "loaded-current" || load.state === "loaded-stale";
|
|
209
|
+
const running = installed && loaded;
|
|
210
|
+
const verified = load.state !== "unknown";
|
|
211
|
+
const viable = installed && !stale && (loaded || !verified);
|
|
212
|
+
const summary = !installed ? `not installed (${diagnostics})`
|
|
213
|
+
: stale ? `installed, but stale (launchd; ${diagnostics})`
|
|
214
|
+
: load.state === "loaded-current" ? `installed and loaded (launchd; ${diagnostics})`
|
|
215
|
+
: load.state === "loaded-stale"
|
|
216
|
+
? `installed and loaded from an OLDER plist (launchd; ${diagnostics})`
|
|
217
|
+
: load.state === "unknown"
|
|
218
|
+
? `installed; launchd state could not be verified — ${load.detail ?? "launchctl could not be asked"} (launchd; ${diagnostics})`
|
|
219
|
+
: `installed, not loaded (launchd; ${diagnostics})`;
|
|
220
|
+
return {
|
|
221
|
+
supported: true,
|
|
222
|
+
installed,
|
|
223
|
+
enabled: running,
|
|
224
|
+
running,
|
|
225
|
+
viable,
|
|
226
|
+
startable: installed && !stale,
|
|
227
|
+
stale,
|
|
228
|
+
conflict: false,
|
|
229
|
+
backend: "launchd",
|
|
230
|
+
summary,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Fail-closed restart diagnostic. Presence alone is never enough: conflicting
|
|
236
|
+
* managers, stale baked paths, disabled registrations, and unknown/stopped
|
|
237
|
+
* native managers cannot claim that Codex will reconnect after a reboot.
|
|
238
|
+
*/
|
|
239
|
+
export function diagnoseService(): ServiceDiagnostic {
|
|
240
|
+
const diagnostics = serviceDiagnosticsSummary();
|
|
241
|
+
if (process.platform === "darwin") {
|
|
242
|
+
const installed = existsSync(plistPath());
|
|
243
|
+
const stale = installed && bakedServicePathsDiagnostic() !== null;
|
|
244
|
+
return deriveLaunchdServiceDiagnostic({
|
|
245
|
+
installed,
|
|
246
|
+
stale,
|
|
247
|
+
load: installed ? probeLaunchdLoadState() : { state: "not-loaded" },
|
|
248
|
+
diagnostics,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (process.platform === "win32") {
|
|
252
|
+
const schedulerXml = statusWindowsXml();
|
|
253
|
+
const schedulerAssetsPresent = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()]
|
|
254
|
+
.every(existsSync);
|
|
255
|
+
const nativeStatus = statusWinswRaw();
|
|
256
|
+
const installState = readServiceInstallState();
|
|
257
|
+
const recordedBackend: ServiceBackend | null = !installState
|
|
258
|
+
? null
|
|
259
|
+
: installState.backend === "native" ? "native" : "scheduler";
|
|
260
|
+
return deriveWindowsServiceDiagnosticForCurrentUser({
|
|
261
|
+
schedulerXml,
|
|
262
|
+
schedulerAssetsPresent,
|
|
263
|
+
nativeStatus,
|
|
264
|
+
recordedBackend,
|
|
265
|
+
staleBakedPaths: bakedServicePathsDiagnostic() !== null,
|
|
266
|
+
nativeRepairAssetsOnly: Boolean(winswStatusSummary()),
|
|
267
|
+
diagnostics,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
if (process.platform === "linux") {
|
|
271
|
+
if (existsSync("/.dockerenv")) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported in Docker" };
|
|
272
|
+
if (!isSystemd()) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported: systemd not found" };
|
|
273
|
+
const installed = existsSync(unitPath());
|
|
274
|
+
const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
|
|
275
|
+
const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
|
|
276
|
+
const stale = installed && bakedServicePathsDiagnostic() !== null;
|
|
277
|
+
const viable = installed && enabled && running && !stale;
|
|
278
|
+
const summary = !installed ? `not installed (${diagnostics})`
|
|
279
|
+
: stale ? `installed, but stale (systemd user; ${diagnostics})`
|
|
280
|
+
: viable ? `installed, enabled and running (systemd user; ${diagnostics})`
|
|
281
|
+
: `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
|
|
282
|
+
return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
|
|
283
|
+
}
|
|
284
|
+
return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function serviceStatusSummary(): string {
|
|
288
|
+
return diagnoseService().summary;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Status a human can act on: registration state, whether a proxy actually answers,
|
|
293
|
+
* and — when it does not — whether launchd is running the plist we have on disk.
|
|
294
|
+
*
|
|
295
|
+
* `launchctl list` membership cannot distinguish "serving", "bootstrapped from an
|
|
296
|
+
* older plist", and "loaded but never bound"; the reported failure was the middle
|
|
297
|
+
* one presented as the first.
|
|
298
|
+
*
|
|
299
|
+
* Resolves the port through `confirmServiceServing`, i.e. the same
|
|
300
|
+
* `installedServiceListenPort()` path install/start/repair use, so those surfaces can
|
|
301
|
+
* never disagree about one service. The budget is short (2 probes) because this is a
|
|
302
|
+
* status read, not a post-install wait.
|
|
303
|
+
*/
|
|
304
|
+
export async function serviceStatusReport(
|
|
305
|
+
deps: {
|
|
306
|
+
diagnose?: () => ServiceDiagnostic;
|
|
307
|
+
serving?: () => Promise<{ ok: boolean; port: number }>;
|
|
308
|
+
matchesPlist?: () => { loaded: boolean; matchesPlist: boolean };
|
|
309
|
+
} = {},
|
|
310
|
+
): Promise<string> {
|
|
311
|
+
const diag = (deps.diagnose ?? diagnoseService)();
|
|
312
|
+
if (!diag.installed) return `❌ ${diag.summary}`;
|
|
313
|
+
|
|
314
|
+
const serving = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
|
|
315
|
+
if (serving.ok) return `✅ ${diag.summary}\n Serving on port ${serving.port}.`;
|
|
316
|
+
|
|
317
|
+
// The dep is consulted FIRST; the platform check only guards the default. Wrapping
|
|
318
|
+
// the whole expression in a darwin check would discard an injected seam on
|
|
319
|
+
// Linux/Windows and make the stale-plist case untestable there.
|
|
320
|
+
const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
|
|
321
|
+
? (() => {
|
|
322
|
+
// Pass the INSTALLED port explicitly: the default third argument is
|
|
323
|
+
// resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
|
|
324
|
+
// a config edit the expected string would never match and every run would
|
|
325
|
+
// print a false "OLDER plist".
|
|
326
|
+
return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort()));
|
|
327
|
+
})()
|
|
328
|
+
: null);
|
|
329
|
+
const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
|
|
330
|
+
? " launchd is running an OLDER plist than the one on disk.\n"
|
|
331
|
+
+ ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
|
|
332
|
+
: "";
|
|
333
|
+
|
|
334
|
+
return `⚠️ ${diag.summary}\n`
|
|
335
|
+
+ ` Registered, but no proxy is answering on port ${serving.port}.\n`
|
|
336
|
+
+ staleLine
|
|
337
|
+
+ ` Log: ${serviceLogPath()}\n`
|
|
338
|
+
+ ` Repair: ${serviceRepairCommand()}\n`
|
|
339
|
+
+ " Meanwhile: ocx start (serves in the foreground)";
|
|
340
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { getConfigDir, loadConfig } from "../config";
|
|
4
|
+
import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
5
|
+
import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
|
|
8
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
9
|
+
import { isTestHomeGuardArmed } from "../lib/test-home-guard";
|
|
10
|
+
import { diagnoseService } from "./diagnostics";
|
|
11
|
+
import type { ServiceDiagnostic } from "./diagnostics";
|
|
12
|
+
import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState } from "./state";
|
|
13
|
+
import { win32 } from "node:path";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
|
|
17
|
+
* touch it. Distinct from "stop failed": the manager was never even contacted, which means the
|
|
18
|
+
* installed service is still live and shared state (native Codex config, the Grok fence) must be
|
|
19
|
+
* left alone — tearing it down would strip config out from under a running service.
|
|
20
|
+
*/
|
|
21
|
+
export class ServiceOwnershipError extends Error {
|
|
22
|
+
readonly code = "service-ownership-mismatch" as const;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
|
|
26
|
+
return err instanceof ServiceOwnershipError;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True when no installed service exists, or the installed one belongs to THIS
|
|
31
|
+
* CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
|
|
32
|
+
* (native Codex config, the Grok fence) that a foreign service would still be relying on.
|
|
33
|
+
*/
|
|
34
|
+
export function serviceEnvironmentOwnedHere(): boolean {
|
|
35
|
+
try {
|
|
36
|
+
assertServiceEnvironmentMatchesInstall();
|
|
37
|
+
return true;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (isServiceOwnershipError(err)) return false;
|
|
40
|
+
return true; // unrelated failure: fall back to the previous behavior rather than wedging
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assertServiceEnvironmentMatchesInstall(): void {
|
|
45
|
+
const state = readServiceInstallState();
|
|
46
|
+
if (!state) return;
|
|
47
|
+
const actualCodexHome = currentCodexHome();
|
|
48
|
+
const expected = normalizePathForCompare(state.codexHome);
|
|
49
|
+
const actual = normalizePathForCompare(actualCodexHome);
|
|
50
|
+
if (expected !== actual) {
|
|
51
|
+
throw new ServiceOwnershipError(
|
|
52
|
+
`Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
|
|
53
|
+
"Run the service command from the same Codex home so native Codex restore updates the correct config.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
|
|
57
|
+
const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
|
|
58
|
+
if (expectedOpenCodexHome !== actualOpenCodexHome) {
|
|
59
|
+
throw new ServiceOwnershipError(
|
|
60
|
+
`Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
|
|
61
|
+
"Run the service command from the same OpenCodex home so service state and secrets match.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
67
|
+
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
|
|
68
|
+
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The `ocx` command a user should rerun for the service state they actually have.
|
|
73
|
+
*
|
|
74
|
+
* `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
|
|
75
|
+
* conflict outright, so recommending repair there names a command guaranteed to fail.
|
|
76
|
+
* Install IS the valid conflict recovery, because `installWindows` removes the native
|
|
77
|
+
* backend first. Exported so the guard tests the real selector rather than a copy of it.
|
|
78
|
+
*/
|
|
79
|
+
export function serviceRetryCommand(
|
|
80
|
+
diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
|
|
81
|
+
): string {
|
|
82
|
+
return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Refuse a management (admin) token as the data-plane secret.
|
|
87
|
+
*
|
|
88
|
+
* The service exports the contents of the service token file as
|
|
89
|
+
* `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
|
|
90
|
+
* token, the server treats the management credential as a data-plane admission secret
|
|
91
|
+
* and fails the ENTIRE management plane closed at boot, so every `/api/*` request
|
|
92
|
+
* returns 503 — even on a loopback install that never needed a data-plane secret.
|
|
93
|
+
* Exporting the admin token in the CLI cannot recover it, because the fence is decided
|
|
94
|
+
* server-side at startup (#2696).
|
|
95
|
+
*
|
|
96
|
+
* Nothing in this codebase puts an admin token in that env var; it arrives from the
|
|
97
|
+
* installing shell. This function is the chokepoint that should refuse it rather than
|
|
98
|
+
* writing a file that produces a broken service. Comparison is the same helper doctor
|
|
99
|
+
* uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
|
|
100
|
+
*
|
|
101
|
+
* `source` selects the remedy, not the rule. The token can also arrive from an EXISTING
|
|
102
|
+
* `service-api-token` that install/repair reuses, and there `unset` is meaningless advice —
|
|
103
|
+
* the fix is to delete the file so a data-plane token is generated.
|
|
104
|
+
*/
|
|
105
|
+
export function assertNotAdminToken(
|
|
106
|
+
token: string,
|
|
107
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
108
|
+
source: "env" | "file" = "env",
|
|
109
|
+
): void {
|
|
110
|
+
if (!tokenCollidesWithAdmin(token, env)) return;
|
|
111
|
+
if (source === "file") {
|
|
112
|
+
// The file branch of `writeServiceApiTokenFile` used to skip this check entirely, so a
|
|
113
|
+
// hand-pasted admin token already on disk (pre-#2696, or the exact #4236 incident) was
|
|
114
|
+
// silently reused: `ocx status` said `present (file)` and the hub crash-looped at boot.
|
|
115
|
+
// The remedy is NOT `unset` -- there is nothing in the environment to unset.
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${serviceApiTokenFilePath()} holds a management (admin) token, not a data-plane token. `
|
|
118
|
+
+ "The service exports that file as the data-plane secret, which fences the whole management "
|
|
119
|
+
+ "API closed and makes every ocx management command fail with 503, so the hub crash-loops at "
|
|
120
|
+
+ `boot. Delete the file (rm ${serviceApiTokenFilePath()}), then rerun \`ocx service repair\` `
|
|
121
|
+
+ "(or `ocx service install` when the service is not installed yet): a fresh owner-only "
|
|
122
|
+
+ "data-plane token is generated and nothing needs to be exported by hand.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
throw new Error(
|
|
126
|
+
"OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
|
|
127
|
+
+ "as the data-plane secret, which fences the whole management API closed and makes "
|
|
128
|
+
+ "every ocx management command fail with 503. Run `unset OPENCODEX_API_AUTH_TOKEN` "
|
|
129
|
+
+ "and rerun: nothing needs to be exported by hand, because the service provisions "
|
|
130
|
+
+ `its own owner-only data-plane token at ${serviceApiTokenFilePath()}.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Preflight for `service install` / `service repair` on the data-plane credential.
|
|
136
|
+
*
|
|
137
|
+
* It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw
|
|
138
|
+
* even when `~/.opencodex/service-api-token` already held a perfectly good token. That is
|
|
139
|
+
* the defect behind the incident this unit exists to close (#4236): an operator exported the
|
|
140
|
+
* ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then
|
|
141
|
+
* crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var
|
|
142
|
+
* again — so the only remembered way to make the command proceed was the thing that broke it.
|
|
143
|
+
*
|
|
144
|
+
* Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile}
|
|
145
|
+
* provisions one, so the only conditions left that install cannot fix are an admin-token
|
|
146
|
+
* collision in the environment and a token file that exists but cannot be used.
|
|
147
|
+
*/
|
|
148
|
+
export function assertServiceAuthEnvironment(): void {
|
|
149
|
+
const config = loadConfig();
|
|
150
|
+
// Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper
|
|
151
|
+
// exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it
|
|
152
|
+
// exists, whatever the hostname): a management token in either source fences the whole
|
|
153
|
+
// management plane closed at boot, even on a loopback install that needs no admission
|
|
154
|
+
// secret. Returning early is what let that broken state through.
|
|
155
|
+
const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
156
|
+
if (present) assertNotAdminToken(present);
|
|
157
|
+
const state = readServiceApiTokenState();
|
|
158
|
+
// An existing FILE holding the admin token is the incident shape itself, and the first round
|
|
159
|
+
// only checked the env var — so install/repair reused it and the hub crash-looped at boot.
|
|
160
|
+
// On a machine connected to a hub this same file holds that hub's issued client key, which
|
|
161
|
+
// is never a management token, so the check is a no-op there.
|
|
162
|
+
if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file");
|
|
163
|
+
if (isLoopbackHostname(config.hostname)) return;
|
|
164
|
+
if (present) return;
|
|
165
|
+
// Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses
|
|
166
|
+
// to replace a path it cannot vouch for, so say so here, where the operator can still act,
|
|
167
|
+
// instead of failing mid-install. Reached from `service repair` as well as `install`, so
|
|
168
|
+
// name a command that can actually succeed (see serviceRetryCommand).
|
|
169
|
+
if (state.kind !== "unsafe") return;
|
|
170
|
+
const diag = diagnoseService();
|
|
171
|
+
throw new Error(
|
|
172
|
+
`The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. `
|
|
173
|
+
+ `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a `
|
|
174
|
+
+ "fresh owner-only token and needs nothing from the environment.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How the data-plane token the service will export was obtained. */
|
|
179
|
+
export type ServiceApiTokenOrigin = "env" | "file" | "generated";
|
|
180
|
+
|
|
181
|
+
export interface ProvisionedServiceApiToken {
|
|
182
|
+
path: string;
|
|
183
|
+
origin: ServiceApiTokenOrigin;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function persistServiceApiToken(token: string): string {
|
|
187
|
+
const path = serviceApiTokenFilePath();
|
|
188
|
+
const dir = getConfigDir();
|
|
189
|
+
recordOwnedConfigPath(dir, path);
|
|
190
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
191
|
+
if (process.platform === "win32") hardenSecretDir(dir, { required: true });
|
|
192
|
+
writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
193
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
194
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: true });
|
|
195
|
+
return path;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Put a usable data-plane token on disk for the service to read at launch, and say where.
|
|
200
|
+
*
|
|
201
|
+
* EVERY backend funnels through here — launchd, systemd, the Windows scheduler wrapper and
|
|
202
|
+
* WinSW native — because the launch wrapper's only source of the secret is this file
|
|
203
|
+
* (`buildServiceShellCommand` cats it into the environment; WinSW reads it through
|
|
204
|
+
* `OCX_API_TOKEN_FILE`). One chokepoint is also what makes the admin-token refusal
|
|
205
|
+
* unskippable (#2696).
|
|
206
|
+
*
|
|
207
|
+
* Precedence, in order:
|
|
208
|
+
* 1. `OPENCODEX_API_AUTH_TOKEN` from the installing shell — still refused outright when it is
|
|
209
|
+
* an admin token. An operator who deliberately exports a key keeps full control of it.
|
|
210
|
+
* 2. An existing owner-only `service-api-token`. Reusing it is what makes `repair`, a
|
|
211
|
+
* reinstall and a restart idempotent; regenerating would silently invalidate every client
|
|
212
|
+
* key-exchange already performed against the old value.
|
|
213
|
+
* 3. 32 fresh random bytes, hex. This is the branch that removes the manual step: a hub
|
|
214
|
+
* install on a non-loopback hostname provisions its own secret.
|
|
215
|
+
*
|
|
216
|
+
* A loopback install with no env token gets nothing: admission is not required there, so
|
|
217
|
+
* creating a credential would be inventing a secret nobody asked for — and on a machine
|
|
218
|
+
* connected to a hub the same file holds that hub's issued client key, which must not be
|
|
219
|
+
* overwritten by a local install.
|
|
220
|
+
*
|
|
221
|
+
* The PATH is logged; the value never is, and never reaches argv, a unit file or a plist.
|
|
222
|
+
*/
|
|
223
|
+
export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null {
|
|
224
|
+
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
225
|
+
if (token) {
|
|
226
|
+
// Last line of defence: every install/repair path funnels through here, so a
|
|
227
|
+
// collision cannot reach disk regardless of which caller ran (#2696).
|
|
228
|
+
assertNotAdminToken(token);
|
|
229
|
+
const path = persistServiceApiToken(token);
|
|
230
|
+
console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
|
|
231
|
+
return { path, origin: "env" };
|
|
232
|
+
}
|
|
233
|
+
if (isLoopbackHostname(loadConfig().hostname)) return null;
|
|
234
|
+
const existing = readServiceApiTokenState();
|
|
235
|
+
if (existing.kind === "present") {
|
|
236
|
+
// The collision check is NOT only for the env branch. A file that already holds the admin
|
|
237
|
+
// token -- hand-pasted before #2696, or written by the very incident this unit closes --
|
|
238
|
+
// was silently accepted here, so `ocx status` reported `present (file)` and the hub
|
|
239
|
+
// crash-looped at boot with no command pointing at the cause.
|
|
240
|
+
const path = serviceApiTokenFilePath();
|
|
241
|
+
assertNotAdminToken(existing.token, process.env, "file");
|
|
242
|
+
// `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well
|
|
243
|
+
// be group- or world-readable. Tighten it on the way through rather than claiming
|
|
244
|
+
// "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod
|
|
245
|
+
// and failing the install over it would be worse than the loose mode.
|
|
246
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
247
|
+
if (process.platform === "win32") hardenSecretPath(path, { required: false });
|
|
248
|
+
// No log line: repair/restart hit this on every run and an unconditional notice about a
|
|
249
|
+
// credential file trains operators to ignore the one that matters.
|
|
250
|
+
return { path, origin: "file" };
|
|
251
|
+
}
|
|
252
|
+
if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
|
|
253
|
+
const path = persistServiceApiToken(randomBytes(32).toString("hex"));
|
|
254
|
+
console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
|
|
255
|
+
console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
|
|
256
|
+
return { path, origin: "generated" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function sh(cmd: string): string {
|
|
260
|
+
assertLiveServiceManagerAllowed(cmd);
|
|
261
|
+
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Service-manager invocations that only observe. Everything else changes a job that
|
|
266
|
+
* launchd or the systemd user manager is running right now.
|
|
267
|
+
*/
|
|
268
|
+
const READ_ONLY_SERVICE_MANAGER = new RegExp(
|
|
269
|
+
"^(?:launchctl\\s+(?:list|print|print-disabled|blame|managerpid|manageruid)\\b"
|
|
270
|
+
+ "|systemctl\\s+(?:--user\\s+)?(?:show|show-environment|status|is-active|is-enabled|is-failed|cat|list-units|list-unit-files|--version)\\b)",
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const SERVICE_MANAGER_COMMAND = /^(?:launchctl|systemctl)\b/;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Refuse to mutate a live service manager from an armed test process.
|
|
277
|
+
*
|
|
278
|
+
* The test preload isolates HOME, OPENCODEX_HOME and CODEX_HOME, and that is enough for
|
|
279
|
+
* anything addressed by path. It is not enough here. `systemctl --user stop
|
|
280
|
+
* opencodex-proxy.service` is addressed by job NAME and talks to the user manager that is
|
|
281
|
+
* already running, so it stops the proxy the developer is actually using no matter what
|
|
282
|
+
* HOME says. `launchctl bootout gui/<uid>/com.opencodex.proxy` has the same shape.
|
|
283
|
+
*
|
|
284
|
+
* Windows already had this guard: `querySchtasks` refuses every non-query call while the
|
|
285
|
+
* test-home guard is armed, after a partially-faked test replaced a real scheduled task
|
|
286
|
+
* with a launcher inside a temporary test home. macOS and Linux were left without the
|
|
287
|
+
* equivalent, which means the person most likely to run this suite - someone running
|
|
288
|
+
* opencodex on the machine they are developing it on - is the person it can disrupt.
|
|
289
|
+
*
|
|
290
|
+
* Read-only verbs stay allowed: probing what the manager reports is the whole point of
|
|
291
|
+
* the diagnostics, and observation cannot take a service down.
|
|
292
|
+
*/
|
|
293
|
+
export function assertLiveServiceManagerAllowed(command: string): void {
|
|
294
|
+
if (!isTestHomeGuardArmed()) return;
|
|
295
|
+
const trimmed = command.trim();
|
|
296
|
+
if (!SERVICE_MANAGER_COMMAND.test(trimmed)) return;
|
|
297
|
+
if (READ_ONLY_SERVICE_MANAGER.test(trimmed)) return;
|
|
298
|
+
throw new Error(
|
|
299
|
+
`refusing to run \`${trimmed}\` from an armed test process: launchd and the systemd user `
|
|
300
|
+
+ "manager address a job by name, not by HOME, so this reaches the service the developer is "
|
|
301
|
+
+ "actually running. Inject the service operation instead of calling the live manager.",
|
|
302
|
+
);
|
|
303
|
+
}
|