@bitkyc08/opencodex 2.10.0 → 2.10.1
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/AGENTS_INSTALL.md +77 -0
- package/README.md +4 -10
- package/bin/ocx.mjs +71 -18
- package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
- package/gui/dist/assets/index-ChZQsmBY.js +70 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/alibaba-color.svg +1 -1
- package/gui/dist/provider-icons/antigravity-color.svg +1 -1
- package/gui/dist/provider-icons/claude-color.svg +1 -1
- package/gui/dist/provider-icons/cline-color.svg +16 -0
- package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/copilot-color.svg +1 -1
- package/gui/dist/provider-icons/cursor-color.svg +1 -1
- package/gui/dist/provider-icons/deepseek-color.svg +1 -1
- package/gui/dist/provider-icons/firepass-color.svg +1 -1
- package/gui/dist/provider-icons/fireworks-color.svg +1 -1
- package/gui/dist/provider-icons/gemini-color.svg +1 -1
- package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
- package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
- package/gui/dist/provider-icons/grok.svg +1 -1
- package/gui/dist/provider-icons/groq-color.svg +1 -1
- package/gui/dist/provider-icons/huggingface-color.svg +1 -1
- package/gui/dist/provider-icons/kimi-color.svg +1 -1
- package/gui/dist/provider-icons/kiro-color.svg +2 -2
- package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
- package/gui/dist/provider-icons/mistral-color.svg +1 -1
- package/gui/dist/provider-icons/moonshot-color.svg +1 -1
- package/gui/dist/provider-icons/nvidia-color.svg +1 -1
- package/gui/dist/provider-icons/ollama-color.svg +1 -1
- package/gui/dist/provider-icons/openai.svg +1 -1
- package/gui/dist/provider-icons/opencode.svg +2 -1
- package/gui/dist/provider-icons/openrouter-color.svg +1 -1
- package/gui/dist/provider-icons/pi.svg +2 -2
- package/gui/dist/provider-icons/qianfan-color.svg +1 -1
- package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
- package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
- package/gui/dist/provider-icons/vllm-color.svg +1 -1
- package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
- package/package.json +8 -4
- package/src/adapters/anthropic.ts +208 -14
- package/src/adapters/base.ts +16 -5
- package/src/adapters/cursor/effort-map.ts +3 -2
- package/src/adapters/cursor/framing.ts +39 -0
- package/src/adapters/cursor/live-transport.ts +105 -95
- package/src/adapters/cursor/native-exec.ts +32 -6
- package/src/adapters/cursor/protobuf-request.ts +20 -15
- package/src/adapters/cursor/request-builder.ts +21 -7
- package/src/adapters/cursor/types.ts +7 -0
- package/src/adapters/google-antigravity-replay.ts +237 -21
- package/src/adapters/google-truncation.ts +11 -0
- package/src/adapters/google.ts +50 -9
- package/src/adapters/identity.ts +39 -6
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +10 -2
- package/src/adapters/kiro-tools.ts +10 -1
- package/src/adapters/kiro.ts +37 -11
- package/src/adapters/openai-chat.ts +284 -83
- package/src/adapters/openai-responses.ts +182 -24
- package/src/bridge.ts +177 -7
- package/src/chat/outbound.ts +78 -23
- package/src/claude/agents-inject.ts +27 -5
- package/src/claude/inbound.ts +11 -1
- package/src/claude/model-info.ts +13 -10
- package/src/claude/outbound.ts +17 -0
- package/src/cli/account-api.ts +24 -0
- package/src/cli/account-auth.ts +31 -6
- package/src/cli/account-main.ts +317 -0
- package/src/cli/account.ts +5 -0
- package/src/cli/claude.ts +2 -1
- package/src/cli/doctor.ts +93 -22
- package/src/cli/export-command.ts +26 -12
- package/src/cli/help.ts +8 -6
- package/src/cli/index.ts +56 -22
- package/src/cli/integrations.ts +84 -1
- package/src/cli/observe.ts +54 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +18 -1
- package/src/cli/route-policy.ts +92 -0
- package/src/cli/runtime-api.ts +6 -3
- package/src/cli/star-prompt.ts +71 -15
- package/src/cli/status.ts +1 -1
- package/src/cli/v2.ts +36 -9
- package/src/clients/config-export.ts +687 -10
- package/src/codex/account-lifecycle.ts +30 -5
- package/src/codex/account-usability.ts +22 -2
- package/src/codex/admission.ts +255 -0
- package/src/codex/auth-api.ts +427 -140
- package/src/codex/auth-context.ts +155 -30
- package/src/codex/autostart-health.ts +8 -1
- package/src/codex/catalog/account-models.ts +62 -0
- package/src/codex/catalog/aggregation.ts +14 -1
- package/src/codex/catalog/bundled.ts +282 -32
- package/src/codex/catalog/filesystem-evidence.ts +302 -0
- package/src/codex/catalog/metadata.ts +51 -6
- package/src/codex/catalog/parsing.ts +6 -3
- package/src/codex/catalog/provider-fetch.ts +576 -41
- package/src/codex/catalog/sync.ts +505 -66
- package/src/codex/catalog-admission.ts +197 -0
- package/src/codex/catalog-write-serialization.ts +241 -0
- package/src/codex/catalog.ts +2 -1
- package/src/codex/codex-write-lock.ts +372 -0
- package/src/codex/convergence-types.ts +593 -0
- package/src/codex/convergence.ts +441 -0
- package/src/codex/desired-state.ts +177 -0
- package/src/codex/features.ts +52 -8
- package/src/codex/generation.ts +202 -0
- package/src/codex/history-job.ts +257 -0
- package/src/codex/history-lock.ts +241 -0
- package/src/codex/history-migration-guardian.ts +18 -5
- package/src/codex/history-provider.ts +9 -2
- package/src/codex/history-transition.ts +105 -0
- package/src/codex/history-worker.ts +176 -0
- package/src/codex/inject-coordination.ts +245 -0
- package/src/codex/inject.ts +605 -124
- package/src/codex/integration-record.ts +266 -0
- package/src/codex/internal/catalog-writer.ts +203 -0
- package/src/codex/internal/history-writer.ts +80 -0
- package/src/codex/journal.ts +10 -1
- package/src/codex/main-account-cache.ts +24 -0
- package/src/codex/management-convergence.ts +114 -0
- package/src/codex/native-main-admission.ts +47 -0
- package/src/codex/native-main-auth-temp.ts +187 -0
- package/src/codex/native-main-claim.ts +167 -0
- package/src/codex/native-main-lock-file.ts +158 -0
- package/src/codex/native-main-owner.ts +315 -0
- package/src/codex/native-profile-api.ts +247 -0
- package/src/codex/native-profile-manager.ts +1512 -0
- package/src/codex/native-profile-processes.ts +121 -0
- package/src/codex/native-profile-recovery.ts +99 -0
- package/src/codex/native-profile-stage-store.ts +387 -0
- package/src/codex/native-profile-startup.ts +340 -0
- package/src/codex/native-profile-store.ts +855 -0
- package/src/codex/native-profile-types.ts +120 -0
- package/src/codex/native-residue.ts +557 -0
- package/src/codex/project-config-warnings.ts +18 -4
- package/src/codex/prompt-journal.ts +311 -0
- package/src/codex/prompt-layers.ts +967 -0
- package/src/codex/prompt-lock.ts +143 -0
- package/src/codex/quota-rejection.ts +224 -0
- package/src/codex/quota.ts +86 -3
- package/src/codex/routing.ts +299 -62
- package/src/codex/runtime.ts +159 -38
- package/src/codex/shim.ts +39 -13
- package/src/codex/subagent-model-fallback.ts +73 -12
- package/src/codex/transition-state.ts +604 -0
- package/src/codex/upstream-host-health.ts +70 -0
- package/src/codex/user-identity.ts +266 -0
- package/src/codex/write-coordination.ts +114 -0
- package/src/config.ts +562 -26
- package/src/generated/jawcode-model-metadata.ts +2 -2
- package/src/grok/inject.ts +15 -4
- package/src/grok/inspect.ts +45 -0
- package/src/images/loop.ts +113 -20
- package/src/integrations/config-io.ts +151 -0
- package/src/integrations/journal.ts +315 -0
- package/src/integrations/merge.ts +135 -0
- package/src/integrations/native/ownership-preflight.ts +165 -0
- package/src/integrations/ownership.ts +111 -0
- package/src/integrations/registry.ts +101 -0
- package/src/integrations/serialize.ts +235 -0
- package/src/integrations/state.ts +290 -0
- package/src/integrations/store.ts +103 -0
- package/src/integrations/writer.ts +492 -0
- package/src/lib/bounded-body.ts +46 -8
- package/src/lib/bun-runtime.ts +110 -1
- package/src/lib/bun-stream-caps.ts +2 -1
- package/src/lib/redact.ts +407 -2
- package/src/lib/shadow-call.ts +24 -0
- package/src/lib/translator-budget.ts +10 -0
- package/src/lib/upstream-reachability.ts +91 -0
- package/src/lib/upstream-retry.ts +154 -2
- package/src/lib/windows-secret-acl.ts +212 -11
- package/src/lib/winsw.ts +9 -3
- package/src/oauth/index.ts +61 -3
- package/src/oauth/key-providers.ts +4 -0
- package/src/oauth/kiro.ts +50 -6
- package/src/oauth/store.ts +31 -6
- package/src/oauth/token-guardian.ts +9 -3
- package/src/providers/codex-capacity.ts +288 -0
- package/src/providers/derive.ts +33 -1
- package/src/providers/free-directory.ts +3 -1
- package/src/providers/key-failover.ts +71 -3
- package/src/providers/openai-sidecar.ts +64 -4
- package/src/providers/openai-virtual-models.ts +1 -0
- package/src/providers/quota.ts +334 -26
- package/src/providers/registry.ts +284 -16
- package/src/providers/xai-transport.ts +11 -4
- package/src/responses/compaction.ts +8 -1
- package/src/responses/hosted-tool-policy.ts +9 -0
- package/src/responses/parser.ts +44 -2
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/reasoning-replay-cache.ts +105 -0
- package/src/responses/spill-store.ts +45 -8
- package/src/responses/state.ts +161 -17
- package/src/router.ts +302 -16
- package/src/routing/analytics.ts +377 -0
- package/src/routing/capability.ts +204 -0
- package/src/routing/cost.ts +77 -0
- package/src/routing/evaluator.ts +444 -0
- package/src/routing/health.ts +401 -0
- package/src/routing/history/cursor.ts +43 -0
- package/src/routing/history/indexer.ts +590 -0
- package/src/routing/history/schema.ts +72 -0
- package/src/routing/profile.ts +423 -0
- package/src/routing/quota.ts +124 -0
- package/src/routing/request-evidence.ts +45 -0
- package/src/routing/trace.ts +686 -0
- package/src/server/auth-cors.ts +46 -6
- package/src/server/chat-completions.ts +28 -13
- package/src/server/claude-messages.ts +23 -15
- package/src/server/gui-static.ts +39 -10
- package/src/server/images.ts +10 -1
- package/src/server/index.ts +238 -52
- package/src/server/lifecycle.ts +228 -9
- package/src/server/live.ts +14 -3
- package/src/server/management/agent-settings-routes.ts +64 -14
- package/src/server/management/combo-routes.ts +5 -5
- package/src/server/management/config-routes.ts +1 -1
- package/src/server/management/context.ts +42 -2
- package/src/server/management/integration-routes.ts +538 -0
- package/src/server/management/logs-usage-routes.ts +1 -1
- package/src/server/management/model-routes.ts +32 -113
- package/src/server/management/model-rows.ts +117 -0
- package/src/server/management/native-integration-routes.ts +587 -0
- package/src/server/management/oauth-account-routes.ts +1 -1
- package/src/server/management/provider-routes.ts +218 -117
- package/src/server/management/request-history-routes.ts +191 -0
- package/src/server/management/routing-analytics-routes.ts +74 -0
- package/src/server/management/routing-profile-routes.ts +177 -0
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +47 -31
- package/src/server/management/sync-response.ts +69 -0
- package/src/server/management/system-restart.ts +276 -24
- package/src/server/management/system-routes.ts +4 -0
- package/src/server/management-api.ts +84 -9
- package/src/server/management-auth.ts +43 -5
- package/src/server/relay-eager.ts +82 -42
- package/src/server/relay.ts +120 -6
- package/src/server/request-log.ts +26 -6
- package/src/server/responses/collaboration.ts +63 -8
- package/src/server/responses/compact.ts +272 -41
- package/src/server/responses/core.ts +730 -132
- package/src/server/responses/fetch-helpers.ts +15 -1
- package/src/server/responses-item-id-repair.ts +32 -3
- package/src/server/responses-json-events.ts +52 -0
- package/src/server/responses-snapshot-repair.ts +621 -0
- package/src/server/search.ts +51 -6
- package/src/server/sse-payload-rewrite.ts +89 -12
- package/src/server/startup-health-cache.ts +7 -1
- package/src/server/ws-bridge.ts +11 -17
- package/src/service-manager-probe.ts +297 -0
- package/src/service.ts +222 -32
- package/src/tray/windows-tray.ps1 +9 -0
- package/src/tray/windows.ts +15 -7
- package/src/types.ts +194 -14
- package/src/update/index.ts +13 -13
- package/src/update/job.ts +24 -21
- package/src/update/notify.ts +7 -3
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +129 -10
- package/src/usage/log.ts +50 -15
- package/src/usage/summary.ts +4 -4
- package/src/vision/index.ts +6 -1
- package/src/web-search/loop.ts +161 -34
- package/gui/dist/assets/index-OY43ubAq.css +0 -1
- package/gui/dist/assets/index-YwNnKZcL.js +0 -67
- package/gui/dist/provider-icons/antigravity.svg +0 -1
- package/gui/dist/provider-icons/claude.svg +0 -1
- package/gui/dist/provider-icons/copilot.svg +0 -1
- package/gui/dist/provider-icons/cursor.svg +0 -2
- package/gui/dist/provider-icons/gemini.svg +0 -1
- package/gui/dist/provider-icons/grok-color.svg +0 -1
- package/gui/dist/provider-icons/kiro.svg +0 -14
package/src/server/search.ts
CHANGED
|
@@ -11,20 +11,30 @@
|
|
|
11
11
|
import { formatErrorResponse } from "../bridge";
|
|
12
12
|
import {
|
|
13
13
|
CodexAccountCooldownError,
|
|
14
|
+
codexMainProfileDrainingResponse,
|
|
14
15
|
cooldownErrorResponse,
|
|
15
16
|
CodexAuthContextError,
|
|
17
|
+
CodexMainProfileDrainingError,
|
|
16
18
|
CodexPoolAuthenticationError,
|
|
17
19
|
CodexThreadAffinityExpiredError,
|
|
18
20
|
} from "../codex/auth-context";
|
|
21
|
+
import { codexAccountNamespaceForModel } from "../codex/account-namespace-match";
|
|
19
22
|
import { formatCodexProviderForLog } from "../codex/routing";
|
|
20
23
|
import { signalWithTimeout } from "../lib/abort";
|
|
21
24
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
22
25
|
import type { OcxConfig } from "../types";
|
|
23
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
listOpenAiForwardSidecarCandidates,
|
|
28
|
+
resolveFirstUsableOpenAiSidecar,
|
|
29
|
+
type ExactOpenAiSidecarAccount,
|
|
30
|
+
} from "../providers/openai-sidecar";
|
|
31
|
+
import { routeModel } from "../router";
|
|
24
32
|
import { readJsonRequestBody } from "./request-decompress";
|
|
25
33
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
26
34
|
import type { RequestLogContext } from "./request-log";
|
|
27
35
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
36
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
37
|
+
import { codexAccountSelectionForTurn } from "./lifecycle";
|
|
28
38
|
|
|
29
39
|
/**
|
|
30
40
|
* Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response
|
|
@@ -40,6 +50,7 @@ export async function handleSearch(
|
|
|
40
50
|
req: Request,
|
|
41
51
|
config: OcxConfig,
|
|
42
52
|
logCtx: RequestLogContext,
|
|
53
|
+
turnAdmissionLease?: AdmissionLease,
|
|
43
54
|
): Promise<Response> {
|
|
44
55
|
try { validateForwardAdmissionCredential(req.headers, config); }
|
|
45
56
|
catch (err) {
|
|
@@ -55,6 +66,32 @@ export async function handleSearch(
|
|
|
55
66
|
const model = (body as { model?: unknown } | null)?.model;
|
|
56
67
|
if (typeof model === "string" && model) logCtx.model = model;
|
|
57
68
|
|
|
69
|
+
let exactAccount: ExactOpenAiSidecarAccount | undefined;
|
|
70
|
+
let relayBody = body;
|
|
71
|
+
const accountNamespace = typeof model === "string"
|
|
72
|
+
? codexAccountNamespaceForModel(config.codexAccountNamespaces, model)
|
|
73
|
+
: undefined;
|
|
74
|
+
if (accountNamespace && typeof model === "string") {
|
|
75
|
+
try {
|
|
76
|
+
const route = routeModel(config, model);
|
|
77
|
+
if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) {
|
|
78
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model");
|
|
79
|
+
}
|
|
80
|
+
exactAccount = { accountId: route.codexAccountId, modelId: route.modelId };
|
|
81
|
+
logCtx.provider = `${route.providerName}-${accountNamespace}`;
|
|
82
|
+
logCtx.routeDecision = route.routeDecision;
|
|
83
|
+
// The ChatGPT search endpoint only understands the native model slug. The
|
|
84
|
+
// account namespace is proxy routing syntax and must not cross the wire.
|
|
85
|
+
relayBody = { ...(body as Record<string, unknown>), model: route.modelId };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return formatErrorResponse(
|
|
88
|
+
400,
|
|
89
|
+
"invalid_request_error",
|
|
90
|
+
err instanceof Error ? err.message : "Invalid Codex account-qualified search model",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
const candidates = listOpenAiForwardSidecarCandidates(config);
|
|
59
96
|
if (candidates.length === 0) {
|
|
60
97
|
return formatErrorResponse(
|
|
@@ -67,7 +104,10 @@ export async function handleSearch(
|
|
|
67
104
|
|
|
68
105
|
let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
|
|
69
106
|
try {
|
|
70
|
-
upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config
|
|
107
|
+
upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config, {
|
|
108
|
+
exactAccount,
|
|
109
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
110
|
+
});
|
|
71
111
|
if (!upstream) {
|
|
72
112
|
return formatErrorResponse(
|
|
73
113
|
401,
|
|
@@ -75,16 +115,21 @@ export async function handleSearch(
|
|
|
75
115
|
"web search relay needs ChatGPT auth (Authorization header)",
|
|
76
116
|
);
|
|
77
117
|
}
|
|
78
|
-
logCtx.provider =
|
|
118
|
+
logCtx.provider = accountNamespace
|
|
119
|
+
? `${upstream.providerName}-${accountNamespace}`
|
|
120
|
+
: formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
|
|
79
121
|
} catch (err) {
|
|
80
122
|
if (err instanceof CodexAccountCooldownError) {
|
|
81
|
-
return cooldownErrorResponse(err);
|
|
123
|
+
return cooldownErrorResponse(err, Date.now(), accountNamespace);
|
|
82
124
|
}
|
|
125
|
+
if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse();
|
|
83
126
|
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
84
127
|
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
85
128
|
}
|
|
86
129
|
if (err instanceof CodexAuthContextError) {
|
|
87
|
-
const safeAccountLabel =
|
|
130
|
+
const safeAccountLabel = accountNamespace
|
|
131
|
+
? `openai-${accountNamespace}`
|
|
132
|
+
: formatCodexProviderForLog("openai", err.accountId, config);
|
|
88
133
|
console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
89
134
|
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
90
135
|
}
|
|
@@ -103,7 +148,7 @@ export async function handleSearch(
|
|
|
103
148
|
const upstreamResponse = await fetch(url, {
|
|
104
149
|
method: "POST",
|
|
105
150
|
headers,
|
|
106
|
-
body: JSON.stringify(
|
|
151
|
+
body: JSON.stringify(relayBody),
|
|
107
152
|
signal: linkedSignal.signal,
|
|
108
153
|
});
|
|
109
154
|
const payload = await upstreamResponse.arrayBuffer();
|
|
@@ -9,6 +9,56 @@ import type { TranslatorBudget } from "../lib/translator-budget";
|
|
|
9
9
|
|
|
10
10
|
export type SsePayloadRewrite = (payload: string) => string;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Block-level SSE rewrite: maps one complete SSE event block (without its
|
|
14
|
+
* blank-line delimiter) to zero or more replacement blocks. This is the
|
|
15
|
+
* contract lifecycle repair needs: injecting missing canonical events
|
|
16
|
+
* (#893) is impossible in the one-payload-in/one-payload-out model.
|
|
17
|
+
*
|
|
18
|
+
* `dispose` releases any retained state (budget-charged collectors) when the
|
|
19
|
+
* relay tears down — terminal, EOF, cancel, or error. Relays call it exactly
|
|
20
|
+
* once per teardown path.
|
|
21
|
+
*/
|
|
22
|
+
export type SseBlockRewrite = ((block: string) => readonly string[]) & {
|
|
23
|
+
dispose?: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Adapt a payload rewrite to the block contract (replace only on change). */
|
|
27
|
+
export function payloadRewriteAsBlockRewrite(rewrite: SsePayloadRewrite): SseBlockRewrite {
|
|
28
|
+
return (block) => {
|
|
29
|
+
const payload = sseDataPayload(block);
|
|
30
|
+
if (payload === null) return [block];
|
|
31
|
+
const rewritten = rewrite(payload);
|
|
32
|
+
return rewritten !== payload ? [replaceSseDataPayload(block, rewritten)] : [block];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Chain block rewrites: every block stage N emits feeds stage N+1. */
|
|
37
|
+
export function composeSseBlockRewrites(...rewrites: SseBlockRewrite[]): SseBlockRewrite {
|
|
38
|
+
const active = rewrites.filter(Boolean);
|
|
39
|
+
if (active.length === 0) return Object.assign((block: string) => [block], {});
|
|
40
|
+
const composed: SseBlockRewrite = (block: string) => {
|
|
41
|
+
let blocks: readonly string[] = [block];
|
|
42
|
+
for (const rewrite of active) {
|
|
43
|
+
const next: string[] = [];
|
|
44
|
+
for (const current of blocks) next.push(...rewrite(current));
|
|
45
|
+
blocks = next;
|
|
46
|
+
}
|
|
47
|
+
return blocks;
|
|
48
|
+
};
|
|
49
|
+
// Child disposal is part of the contract: one idempotent disposer for the
|
|
50
|
+
// whole chain, so relay teardown never leaks a nested collector.
|
|
51
|
+
let disposed = false;
|
|
52
|
+
composed.dispose = () => {
|
|
53
|
+
if (disposed) return;
|
|
54
|
+
disposed = true;
|
|
55
|
+
for (const rewrite of active) {
|
|
56
|
+
try { rewrite.dispose?.(); } catch { /* teardown must not throw */ }
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return composed;
|
|
60
|
+
}
|
|
61
|
+
|
|
12
62
|
/** Split one complete SSE event block while retaining its original blank-line delimiter. */
|
|
13
63
|
export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
|
|
14
64
|
const match = buffer.match(/\r?\n\r?\n/);
|
|
@@ -69,12 +119,34 @@ export function relaySseWithPayloadRewrite(
|
|
|
69
119
|
body: ReadableStream<Uint8Array>,
|
|
70
120
|
rewrite: SsePayloadRewrite,
|
|
71
121
|
translatorBudget: TranslatorBudget,
|
|
122
|
+
): ReadableStream<Uint8Array> {
|
|
123
|
+
return relaySseWithBlockRewrite(body, payloadRewriteAsBlockRewrite(rewrite), translatorBudget);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Relay an SSE body through a single JS pull wrapper, applying a block-level
|
|
128
|
+
* rewrite that may emit zero or more blocks per upstream event (lifecycle
|
|
129
|
+
* event injection, #893). The original stream's delimiter style is preserved
|
|
130
|
+
* for every emitted block.
|
|
131
|
+
*/
|
|
132
|
+
export function relaySseWithBlockRewrite(
|
|
133
|
+
body: ReadableStream<Uint8Array>,
|
|
134
|
+
rewrite: SseBlockRewrite,
|
|
135
|
+
translatorBudget: TranslatorBudget,
|
|
72
136
|
): ReadableStream<Uint8Array> {
|
|
73
137
|
const reader = body.getReader();
|
|
74
138
|
const decoder = new TextDecoder();
|
|
75
139
|
const encoder = new TextEncoder();
|
|
76
140
|
let buffer = "";
|
|
77
141
|
let bufferBytes = 0;
|
|
142
|
+
// Relays have several independent teardown paths; disposal is exactly once.
|
|
143
|
+
let disposed = false;
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
const disposeRewrite = (): void => {
|
|
146
|
+
if (disposed) return;
|
|
147
|
+
disposed = true;
|
|
148
|
+
try { rewrite.dispose?.(); } catch { /* teardown must not throw */ }
|
|
149
|
+
};
|
|
78
150
|
|
|
79
151
|
const appendBuffer = (fragment: string): void => {
|
|
80
152
|
if (!fragment) return;
|
|
@@ -130,20 +202,18 @@ export function relaySseWithPayloadRewrite(
|
|
|
130
202
|
let next: { block: string; delimiter: string; rest: string } | null;
|
|
131
203
|
while ((next = nextSseBlock(buffer))) {
|
|
132
204
|
replaceBuffer(next.rest);
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
? replaceSseDataPayload(next.block, rewrittenPayload)
|
|
137
|
-
: next.block;
|
|
138
|
-
enqueueText(controller, block + next.delimiter);
|
|
205
|
+
for (const outBlock of rewrite(next.block)) {
|
|
206
|
+
enqueueText(controller, outBlock + next.delimiter);
|
|
207
|
+
}
|
|
139
208
|
}
|
|
140
209
|
if (flushFinal && buffer.length > 0) {
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
210
|
+
const tailBlocks = rewrite(buffer);
|
|
211
|
+
// A trailing fragment has no delimiter of its own; multiple emitted
|
|
212
|
+
// blocks must still be framed as separate events (#893 review).
|
|
213
|
+
const tailDelimiter = buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n";
|
|
214
|
+
for (let i = 0; i < tailBlocks.length; i++) {
|
|
215
|
+
enqueueText(controller, tailBlocks[i]! + (i < tailBlocks.length - 1 ? tailDelimiter : ""));
|
|
216
|
+
}
|
|
147
217
|
releaseBuffer();
|
|
148
218
|
}
|
|
149
219
|
};
|
|
@@ -152,10 +222,14 @@ export function relaySseWithPayloadRewrite(
|
|
|
152
222
|
async pull(controller) {
|
|
153
223
|
try {
|
|
154
224
|
const { done, value } = await reader.read();
|
|
225
|
+
// A cancel raced this pending read: never feed the rewriter again
|
|
226
|
+
// after its disposal (#893 review).
|
|
227
|
+
if (cancelled) return;
|
|
155
228
|
if (done) {
|
|
156
229
|
appendBuffer(decoder.decode());
|
|
157
230
|
emitProcessedBlocks(controller, true);
|
|
158
231
|
releaseBuffer();
|
|
232
|
+
disposeRewrite();
|
|
159
233
|
controller.close();
|
|
160
234
|
return;
|
|
161
235
|
}
|
|
@@ -163,12 +237,15 @@ export function relaySseWithPayloadRewrite(
|
|
|
163
237
|
emitProcessedBlocks(controller);
|
|
164
238
|
} catch (error) {
|
|
165
239
|
releaseBuffer();
|
|
240
|
+
disposeRewrite();
|
|
166
241
|
try { await reader.cancel(error); } catch { /* already closed */ }
|
|
167
242
|
controller.error(error);
|
|
168
243
|
}
|
|
169
244
|
},
|
|
170
245
|
cancel(reason) {
|
|
246
|
+
cancelled = true;
|
|
171
247
|
releaseBuffer();
|
|
248
|
+
disposeRewrite();
|
|
172
249
|
reader.cancel(reason).catch(() => {});
|
|
173
250
|
},
|
|
174
251
|
});
|
|
@@ -23,9 +23,14 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH
|
|
|
23
23
|
rebootSafe: false,
|
|
24
24
|
protection: "none",
|
|
25
25
|
diagnosticStale: true,
|
|
26
|
+
// Mirror deriveStartupHealth's choice: an already-registered service is refreshed in
|
|
27
|
+
// place. Hardcoding installService here silently undid that for every stale-cache
|
|
28
|
+
// read, which is the path the dashboard hits while a probe is revalidating.
|
|
26
29
|
recommendedCommand: value.routingKind === "custom-local" || value.routingKind === "unknown"
|
|
27
30
|
? value.commands.restoreNative
|
|
28
|
-
: value.
|
|
31
|
+
: value.serviceInstalled && !value.serviceConflict
|
|
32
|
+
? value.commands.repairService
|
|
33
|
+
: value.commands.installService,
|
|
29
34
|
};
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -72,6 +77,7 @@ function runProbe(config: Pick<OcxConfig, "codexAutoStart">): Promise<StartupHea
|
|
|
72
77
|
: truncateRetainedUtf8(parsed.recommendedCommand, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
73
78
|
commands: {
|
|
74
79
|
installService: truncateRetainedUtf8(parsed.commands.installService, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
80
|
+
repairService: truncateRetainedUtf8(parsed.commands.repairService, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
75
81
|
installShim: truncateRetainedUtf8(parsed.commands.installShim, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
76
82
|
restoreNative: truncateRetainedUtf8(parsed.commands.restoreNative, MAX_DIAGNOSTIC_VALUE_BYTES),
|
|
77
83
|
},
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { responsesJsonEventSequence } from "./responses-json-events";
|
|
2
3
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
3
4
|
import type { CodexAuthContext } from "../codex/auth-context";
|
|
4
5
|
import { headersForCodexAuthContext } from "../codex/auth-context";
|
|
5
6
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
6
7
|
import type { DataPlaneAdmission } from "./auth-cors";
|
|
7
|
-
import type { AdmissionReservation } from "../lib/admission";
|
|
8
|
+
import type { AdmissionLease, AdmissionReservation } from "../lib/admission";
|
|
8
9
|
|
|
9
10
|
const OPEN = 1;
|
|
10
11
|
type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
|
|
@@ -39,6 +40,12 @@ export interface WsData {
|
|
|
39
40
|
liveUpstreamHeaders?: Record<string, string>;
|
|
40
41
|
livePending?: Array<string | Buffer>;
|
|
41
42
|
liveOpened?: boolean;
|
|
43
|
+
/** Once teardown starts, ignore new client frames until the upstream closes. */
|
|
44
|
+
liveClosing?: boolean;
|
|
45
|
+
/** Schedules one bounded close retry without surrendering native-main ownership. */
|
|
46
|
+
liveCloseFallback?: ReturnType<typeof setTimeout>;
|
|
47
|
+
/** Turn/account ownership retained for the complete sideband socket lifetime. */
|
|
48
|
+
liveTurnAdmissionLease?: AdmissionLease;
|
|
42
49
|
admissionLease?: AdmissionReservation<ServerWebSocket<WsData>>;
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -301,25 +308,12 @@ export function sendResponsesJsonAsEvents(
|
|
|
301
308
|
}
|
|
302
309
|
sendTextFrame(ws, text);
|
|
303
310
|
};
|
|
304
|
-
const output = Array.isArray(response.output) ? response.output : [];
|
|
305
|
-
sendObservedFrame({
|
|
306
|
-
type: "response.created",
|
|
307
|
-
response: { ...response, status: "in_progress", output: [] },
|
|
308
|
-
});
|
|
309
|
-
output.forEach((item, outputIndex) => {
|
|
310
|
-
sendObservedFrame({
|
|
311
|
-
type: "response.output_item.done",
|
|
312
|
-
output_index: outputIndex,
|
|
313
|
-
item,
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
311
|
const finalStatus = response.status === "failed" || response.status === "incomplete"
|
|
317
312
|
? response.status
|
|
318
313
|
: "completed";
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
});
|
|
314
|
+
for (const frame of responsesJsonEventSequence(response)) {
|
|
315
|
+
sendObservedFrame(frame);
|
|
316
|
+
}
|
|
323
317
|
onTerminal?.(finalStatus);
|
|
324
318
|
}
|
|
325
319
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the service manager says about this machine, read-only and fail-closed.
|
|
3
|
+
*
|
|
4
|
+
* The question this answers is NOT "is a job loaded". Installation writes the
|
|
5
|
+
* definition BEFORE the state file (`service.ts` install paths) and embeds
|
|
6
|
+
* `CODEX_HOME`/`OPENCODEX_HOME` inside it, so an interrupted reinstall leaves a
|
|
7
|
+
* valid state file for one home beside an installed definition for another. A
|
|
8
|
+
* probe that only asked about registration would call that owned. On macOS it is
|
|
9
|
+
* worse: a logged-out user has the plist on disk with no GUI domain at all, so
|
|
10
|
+
* registration reports nothing while a foreign definition sits right there.
|
|
11
|
+
*
|
|
12
|
+
* So the probe reads the DEFINITION, parses the homes out of it, and reports
|
|
13
|
+
* what it saw. Comparing those homes is the caller's job — a probe that returned
|
|
14
|
+
* a verdict would be deciding ownership from half the evidence.
|
|
15
|
+
*
|
|
16
|
+
* Every command here is read-only and bounded. The user's proxy runs under a
|
|
17
|
+
* service manager while this executes; a probe that could start, stop or reload
|
|
18
|
+
* anything is not a probe. An unbounded one is not much better, since a wedged
|
|
19
|
+
* manager would hold the event loop.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** Short: this runs inside admission, and a slow answer is the same as none. */
|
|
27
|
+
export const SERVICE_PROBE_TIMEOUT_MS = 2_000;
|
|
28
|
+
|
|
29
|
+
export type ServiceManagerBackend = "launchd" | "systemd" | "scheduler" | "winsw";
|
|
30
|
+
|
|
31
|
+
export interface ServiceManagerClaim {
|
|
32
|
+
readonly backend: ServiceManagerBackend;
|
|
33
|
+
readonly definitionPath: string;
|
|
34
|
+
/**
|
|
35
|
+
* The homes the definition names. `null` means the definition deliberately
|
|
36
|
+
* omits that key, which is different from naming a different one: an install
|
|
37
|
+
* that ran without `CODEX_HOME` set writes no such key at all.
|
|
38
|
+
*/
|
|
39
|
+
readonly homes: {
|
|
40
|
+
readonly codexHome: string | null;
|
|
41
|
+
readonly opencodexHome: string | null;
|
|
42
|
+
};
|
|
43
|
+
readonly registration: "present" | "absent";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ServiceManagerInstallation =
|
|
47
|
+
| { readonly kind: "absent" }
|
|
48
|
+
| { readonly kind: "present"; readonly claims: readonly ServiceManagerClaim[] }
|
|
49
|
+
| { readonly kind: "conflict"; readonly claims: readonly ServiceManagerClaim[] }
|
|
50
|
+
| { readonly kind: "unknown"; readonly reason: string };
|
|
51
|
+
|
|
52
|
+
/** Injected so a test can observe the EXACT argv production emits. */
|
|
53
|
+
export interface ProbeRunner {
|
|
54
|
+
(file: string, args: readonly string[]): {
|
|
55
|
+
status: number | null;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
timedOut: boolean;
|
|
59
|
+
spawnFailed: boolean;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const defaultProbeRunner: ProbeRunner = (file, args) => {
|
|
64
|
+
const result = spawnSync(file, [...args], {
|
|
65
|
+
encoding: "utf8",
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
timeout: SERVICE_PROBE_TIMEOUT_MS,
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
status: result.status,
|
|
71
|
+
stdout: String(result.stdout ?? ""),
|
|
72
|
+
stderr: String(result.stderr ?? ""),
|
|
73
|
+
// `signal` is SIGTERM when the timeout fired; a spawn failure sets `error`.
|
|
74
|
+
timedOut: result.signal !== null && result.error === undefined,
|
|
75
|
+
spawnFailed: result.error !== undefined,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export interface ProbeDeps {
|
|
80
|
+
readonly run?: ProbeRunner;
|
|
81
|
+
readonly platform?: NodeJS.Platform;
|
|
82
|
+
readonly uid?: number;
|
|
83
|
+
readonly home?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const LABEL = "com.opencodex.proxy";
|
|
87
|
+
const TASK = "opencodex-proxy";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `launchctl print` exits 113 for a service that is not there and 112 when the
|
|
91
|
+
* domain itself cannot be reached — measured against nonexistent targets rather
|
|
92
|
+
* than assumed. Only 113 is an answer; everything else is a failure to ask.
|
|
93
|
+
*/
|
|
94
|
+
const LAUNCHCTL_NO_SUCH_SERVICE = 113;
|
|
95
|
+
/**
|
|
96
|
+
* 112 is an answer about the DOMAIN, not the label.
|
|
97
|
+
*
|
|
98
|
+
* Measured on macOS 27.0: querying a domain with no service name at all still
|
|
99
|
+
* returns it, and a domain that does not exist cannot be running one of our
|
|
100
|
+
* jobs. Treating it as "could not ask" would refuse every write on a fresh
|
|
101
|
+
* headless Mac — no GUI domain, and no installation either.
|
|
102
|
+
*/
|
|
103
|
+
const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Residue on disk, distinguished from a path that could not be read.
|
|
107
|
+
*
|
|
108
|
+
* `existsSync` answers "no" for a dangling symlink and for a path whose parent
|
|
109
|
+
* denies traversal. Both are residue, not absence — only ENOENT is absence.
|
|
110
|
+
*/
|
|
111
|
+
function artifactPresence(path: string): "present" | "absent" | "unreadable" {
|
|
112
|
+
try {
|
|
113
|
+
lstatSync(path);
|
|
114
|
+
return "present";
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unknown(reason: string): ServiceManagerInstallation {
|
|
121
|
+
return { kind: "unknown", reason };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Pull `<key>NAME</key><string>VALUE</string>` out of a plist body. */
|
|
125
|
+
function plistEnvValue(body: string, key: string): string | null {
|
|
126
|
+
const match = body.match(
|
|
127
|
+
new RegExp(`<key>\\s*${key}\\s*</key>\\s*<string>([^<]*)</string>`),
|
|
128
|
+
);
|
|
129
|
+
return match ? match[1] : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Pull `Environment="NAME=VALUE"` (quoted or bare) out of a systemd unit. */
|
|
133
|
+
function unitEnvValue(body: string, key: string): string | null {
|
|
134
|
+
for (const line of body.split("\n")) {
|
|
135
|
+
const match = line.match(new RegExp(`^\\s*Environment=\\s*"?${key}=([^"\\n]*)"?\\s*$`));
|
|
136
|
+
if (match) return match[1];
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function inspectLaunchd(deps: Required<Pick<ProbeDeps, "run" | "uid" | "home">>): ServiceManagerInstallation {
|
|
142
|
+
const definitionPath = join(deps.home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* BOTH domains, because they are independent and hold separate service sets.
|
|
146
|
+
* Measured on macOS 27.0: the shipped agent answers 0 under `gui/<uid>` and
|
|
147
|
+
* 113 under `user/<uid>`. Asking only one leaves the other free to hold a job
|
|
148
|
+
* this probe would then call absent.
|
|
149
|
+
*/
|
|
150
|
+
let registration: "present" | "absent" = "absent";
|
|
151
|
+
let unreachableDomains = 0;
|
|
152
|
+
for (const domain of [`gui/${deps.uid}`, `user/${deps.uid}`]) {
|
|
153
|
+
const printed = deps.run("/bin/launchctl", ["print", `${domain}/${LABEL}`]);
|
|
154
|
+
if (printed.spawnFailed || printed.timedOut) {
|
|
155
|
+
return unknown(`launchctl could not be asked: ${printed.timedOut ? "timed out" : printed.stderr.trim()}`);
|
|
156
|
+
}
|
|
157
|
+
if (printed.status === 0) { registration = "present"; break; }
|
|
158
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
|
|
159
|
+
if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) { unreachableDomains += 1; continue; }
|
|
160
|
+
return unknown(`launchctl print exited ${String(printed.status)}: ${printed.stderr.trim()}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const definition = artifactPresence(definitionPath);
|
|
164
|
+
if (definition === "absent") {
|
|
165
|
+
// No file. A registration without one means launchd holds a definition whose
|
|
166
|
+
// file is gone — real, and not something to resolve unattended.
|
|
167
|
+
if (registration === "present") {
|
|
168
|
+
return unknown("launchd has a job loaded but its plist is missing");
|
|
169
|
+
}
|
|
170
|
+
/*
|
|
171
|
+
* Nothing staged, and every domain either answered "no such service" or does
|
|
172
|
+
* not exist. 112 is an answer ABOUT THE DOMAIN and is label-independent —
|
|
173
|
+
* querying a domain with no service name at all returns it — so an
|
|
174
|
+
* unreachable domain cannot be hiding a job of ours. Calling this `unknown`
|
|
175
|
+
* instead would refuse every write on a fresh headless Mac, which has no
|
|
176
|
+
* GUI domain and no installation either.
|
|
177
|
+
*/
|
|
178
|
+
void unreachableDomains;
|
|
179
|
+
return { kind: "absent" };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let body: string;
|
|
183
|
+
try {
|
|
184
|
+
body = readFileSync(definitionPath, "utf-8");
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Present-but-unreadable cannot supply homes, and `present` without homes
|
|
187
|
+
// would compare equal to nothing and read as agreement.
|
|
188
|
+
return unknown(`the launchd plist exists but could not be read: ${String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
kind: "present",
|
|
193
|
+
claims: [{
|
|
194
|
+
backend: "launchd",
|
|
195
|
+
definitionPath,
|
|
196
|
+
homes: {
|
|
197
|
+
codexHome: plistEnvValue(body, "CODEX_HOME"),
|
|
198
|
+
opencodexHome: plistEnvValue(body, "OPENCODEX_HOME"),
|
|
199
|
+
},
|
|
200
|
+
registration,
|
|
201
|
+
}],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function systemdProperty(out: string, key: string): string | null {
|
|
206
|
+
for (const line of out.split("\n")) {
|
|
207
|
+
const match = line.match(new RegExp(`^${key}=(.*)$`));
|
|
208
|
+
if (match) return match[1].trim();
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function inspectSystemd(deps: Required<Pick<ProbeDeps, "run" | "home">>): ServiceManagerInstallation {
|
|
214
|
+
const definitionPath = join(deps.home, ".config", "systemd", "user", `${TASK}.service`);
|
|
215
|
+
|
|
216
|
+
/*
|
|
217
|
+
* All four properties in one call. LoadState alone is not enough — it is
|
|
218
|
+
* orthogonal to ActiveState — and neither says whether the LOADED bytes match
|
|
219
|
+
* the file. NeedDaemonReload is that signal, and this repository already
|
|
220
|
+
* documents it as the systemd analogue of launchd's stale plist.
|
|
221
|
+
*/
|
|
222
|
+
const shown = deps.run("systemctl", [
|
|
223
|
+
"--user", "show", TASK,
|
|
224
|
+
"-p", "LoadState", "-p", "ActiveState", "-p", "FragmentPath", "-p", "NeedDaemonReload",
|
|
225
|
+
]);
|
|
226
|
+
if (shown.spawnFailed || shown.timedOut) {
|
|
227
|
+
return unknown(`systemctl could not be asked: ${shown.timedOut ? "timed out" : shown.stderr.trim()}`);
|
|
228
|
+
}
|
|
229
|
+
if (shown.status !== 0) {
|
|
230
|
+
// A missing unit still exits ZERO and says not-found; a non-zero status means
|
|
231
|
+
// the question never reached the bus.
|
|
232
|
+
return unknown(`systemctl show exited ${String(shown.status)}: ${shown.stderr.trim()}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const loadState = systemdProperty(shown.stdout, "LoadState");
|
|
236
|
+
const activeState = systemdProperty(shown.stdout, "ActiveState");
|
|
237
|
+
const fragmentPath = systemdProperty(shown.stdout, "FragmentPath");
|
|
238
|
+
const needReload = systemdProperty(shown.stdout, "NeedDaemonReload");
|
|
239
|
+
if (loadState === null || activeState === null || needReload === null) {
|
|
240
|
+
return unknown("systemctl show did not report the properties it was asked for");
|
|
241
|
+
}
|
|
242
|
+
if (needReload === "yes") {
|
|
243
|
+
return unknown("systemd has a stale definition loaded; it needs daemon-reload");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const registration: "present" | "absent" =
|
|
247
|
+
loadState === "not-found" && activeState === "inactive" && !fragmentPath ? "absent" : "present";
|
|
248
|
+
|
|
249
|
+
if (artifactPresence(definitionPath) === "absent") {
|
|
250
|
+
return registration === "absent"
|
|
251
|
+
? { kind: "absent" }
|
|
252
|
+
: unknown("systemd knows this unit but its file is missing");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let body: string;
|
|
256
|
+
try {
|
|
257
|
+
body = readFileSync(definitionPath, "utf-8");
|
|
258
|
+
} catch (error) {
|
|
259
|
+
return unknown(`the systemd unit exists but could not be read: ${String(error)}`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
kind: "present",
|
|
264
|
+
claims: [{
|
|
265
|
+
backend: "systemd",
|
|
266
|
+
definitionPath,
|
|
267
|
+
homes: {
|
|
268
|
+
codexHome: unitEnvValue(body, "CODEX_HOME"),
|
|
269
|
+
opencodexHome: unitEnvValue(body, "OPENCODEX_HOME"),
|
|
270
|
+
},
|
|
271
|
+
registration,
|
|
272
|
+
}],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Windows is deferred to its own phase and reports `unknown` until then.
|
|
278
|
+
*
|
|
279
|
+
* Not an oversight: the definition there is a chain, not a file. The task XML
|
|
280
|
+
* names only the launcher, and the homes live in the batch wrapper it eventually
|
|
281
|
+
* runs — a probe that parsed the XML and stopped would find no homes and read
|
|
282
|
+
* that as agreement. Reporting `unknown` refuses unattended convergence on
|
|
283
|
+
* Windows, which is the safe direction while the chain walk is unwritten.
|
|
284
|
+
*/
|
|
285
|
+
function inspectWindows(): ServiceManagerInstallation {
|
|
286
|
+
return unknown("the Windows definition chain is not inspected yet");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function inspectServiceManagerInstallation(deps: ProbeDeps = {}): ServiceManagerInstallation {
|
|
290
|
+
const platform = deps.platform ?? process.platform;
|
|
291
|
+
const run = deps.run ?? defaultProbeRunner;
|
|
292
|
+
const home = deps.home ?? homedir();
|
|
293
|
+
if (platform === "darwin") return inspectLaunchd({ run, uid: deps.uid ?? process.getuid?.() ?? 0, home });
|
|
294
|
+
if (platform === "linux") return inspectSystemd({ run, home });
|
|
295
|
+
if (platform === "win32") return inspectWindows();
|
|
296
|
+
return unknown(`no service manager probe for platform ${platform}`);
|
|
297
|
+
}
|