@bitkyc08/opencodex 2.81.0 → 2.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +43 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +303 -171
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +6 -2
- package/src/oauth/store.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +31 -2
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +34 -13
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +19 -2
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +25 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +5 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IncomingMeta, ProviderAdapter } from "../../adapters/base";
|
|
2
|
+
import type { HandleResponsesOptions } from "./core-options";
|
|
3
|
+
import type { ResponsesSendBudget } from "./request-send-budget";
|
|
4
|
+
import type { ProviderFetchOptions } from "./fetch-helpers";
|
|
5
|
+
import type { SingleUseDispatchPermit } from "../../lib/request-execution-budget";
|
|
6
|
+
|
|
7
|
+
/** Hosted inference consumes the same child-owned booking as ordinary dispatch. */
|
|
8
|
+
export function createSidecarSendBudget(
|
|
9
|
+
options: HandleResponsesOptions,
|
|
10
|
+
budget: Pick<ResponsesSendBudget, "adapterDispatchBudget" | "noteInitialDispatch" | "noteAdapterPhysicalSend" | "noteAdapterRecoveryWithheld" | "pendingHopPermit">,
|
|
11
|
+
inputTokens: () => number | undefined,
|
|
12
|
+
) {
|
|
13
|
+
const initial = options.comboInitialSend;
|
|
14
|
+
let hopPermit: SingleUseDispatchPermit | undefined;
|
|
15
|
+
let producerActive = false;
|
|
16
|
+
/** Detach this owner’s hop before refunding its still-unused reservation. */
|
|
17
|
+
const releaseHop = (): void => {
|
|
18
|
+
const permit = hopPermit;
|
|
19
|
+
hopPermit = undefined;
|
|
20
|
+
if (budget.pendingHopPermit === permit) budget.pendingHopPermit = undefined;
|
|
21
|
+
permit?.release();
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
/** Retain a Combo hop until dispatch; direct requests keep eager settlement. */
|
|
25
|
+
ownCredentialHop(permit?: SingleUseDispatchPermit): void {
|
|
26
|
+
if (!initial) { permit?.use(); return; } // Preserve direct callers' reporting contract.
|
|
27
|
+
releaseHop();
|
|
28
|
+
hopPermit = permit;
|
|
29
|
+
budget.pendingHopPermit = permit;
|
|
30
|
+
},
|
|
31
|
+
// Adapter-owned transports settle through the live dispatch view, not an HTTP receipt too.
|
|
32
|
+
incomingMeta: initial ? {
|
|
33
|
+
comboAttempt: options.comboAttempt === true,
|
|
34
|
+
sendBudget: budget.adapterDispatchBudget,
|
|
35
|
+
onPhysicalSend: send => budget.noteAdapterPhysicalSend(inputTokens(), send),
|
|
36
|
+
onRecoveryWithheld: budget.noteAdapterRecoveryWithheld,
|
|
37
|
+
} satisfies Partial<IncomingMeta> : {},
|
|
38
|
+
/** Attach HTTP receipts only when the adapter does not own physical dispatch. */
|
|
39
|
+
fetchOptions(adapter: ProviderAdapter): Pick<ProviderFetchOptions, "onPhysicalDispatch"> {
|
|
40
|
+
return initial && !adapter.fetchResponse && !adapter.runTurn
|
|
41
|
+
? { onPhysicalDispatch: () => {
|
|
42
|
+
const prepaid = budget.pendingHopPermit;
|
|
43
|
+
budget.pendingHopPermit = undefined;
|
|
44
|
+
budget.noteInitialDispatch(prepaid);
|
|
45
|
+
} } : {};
|
|
46
|
+
},
|
|
47
|
+
/** Transfer cleanup to the asynchronous producer before it can outlive the response. */
|
|
48
|
+
takeProducerOwnership(): void {
|
|
49
|
+
if (initial) { initial.producerOwned = true; producerActive = true; }
|
|
50
|
+
},
|
|
51
|
+
/** Release unused bookings only after the owning producer has settled. */
|
|
52
|
+
release(): void {
|
|
53
|
+
initial?.permit.release(); producerActive = false; releaseHop();
|
|
54
|
+
},
|
|
55
|
+
/** An iteration may finish before runTurn; defer its refund while that producer is active. */
|
|
56
|
+
releaseUnsentHop(): void { if (!producerActive) releaseHop(); },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -15,6 +15,7 @@ import type { NativeResponseControl } from "./native-response-control";
|
|
|
15
15
|
// returned event frames as an SSE byte stream, so every downstream consumer
|
|
16
16
|
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.
|
|
17
17
|
|
|
18
|
+
import { codexWsReuseAcrossTurnsEnabled } from "../../config/codex-ws-reuse-setting";
|
|
18
19
|
import { compareBunVersions } from "../../lib/bun-stream-caps";
|
|
19
20
|
import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env";
|
|
20
21
|
import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
@@ -26,7 +27,7 @@ import { codexWsCreateFrameExceedsLimit } from "./codex-ws-wire";
|
|
|
26
27
|
import { isLoopbackUrl, rewriteWebSocketDial } from "../../plugins/upstream-hooks";
|
|
27
28
|
export { CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES, MAX_CODEX_WS_QUEUE_BYTES,
|
|
28
29
|
MAX_CODEX_WS_CREATE_FRAME_BYTES, CODEX_WS_CREATE_FRAME_LIMIT_BYTES, codexWsCreateFrameExceedsLimit,
|
|
29
|
-
isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse } from "./codex-ws-wire";
|
|
30
|
+
isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./codex-ws-wire";
|
|
30
31
|
export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -99,8 +100,10 @@ export function planCodexWsDial(
|
|
|
99
100
|
// and is authoritative; a pooled socket's upgrade copy is intentionally ignored. A rewriter
|
|
100
101
|
// therefore cannot change them here, and the upgrade keeps the values the frame carries.
|
|
101
102
|
const dialHeaders = { ...rewritten.headers };
|
|
103
|
+
const reuseAcrossTurns = codexWsReuseAcrossTurnsEnabled();
|
|
102
104
|
for (const name of CODEX_WS_FRAME_HEADERS) {
|
|
103
|
-
if (
|
|
105
|
+
if (reuseAcrossTurns) delete dialHeaders[name];
|
|
106
|
+
else if (Object.hasOwn(headers, name)) dialHeaders[name] = headers[name]!;
|
|
104
107
|
else delete dialHeaders[name];
|
|
105
108
|
}
|
|
106
109
|
const dial = { ...rewritten, headers: dialHeaders };
|
|
@@ -148,7 +151,8 @@ export function shouldUseCodexWsUpstream(
|
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
|
|
151
|
-
|
|
154
|
+
/** Select the bounded WS lane or HTTP fallback, forwarding receipts at physical dispatch. */
|
|
155
|
+
export async function codexWsUpstreamFetch(
|
|
152
156
|
url: string,
|
|
153
157
|
init: RequestInit,
|
|
154
158
|
sseFallback: typeof globalThis.fetch,
|
|
@@ -157,6 +161,7 @@ export function codexWsUpstreamFetch(
|
|
|
157
161
|
beforeDispatch?: (headers: Headers) => void,
|
|
158
162
|
nativeControl?: NativeResponseControl,
|
|
159
163
|
beforeContinuation?: () => Promise<void>,
|
|
164
|
+
onPhysicalDispatch?: () => void,
|
|
160
165
|
): Promise<Response> {
|
|
161
166
|
const prepared = prepareCodexWsRequest(url, init);
|
|
162
167
|
if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
|
|
@@ -217,8 +222,16 @@ export function codexWsUpstreamFetch(
|
|
|
217
222
|
const dial = planCodexWsDial(wsUrl, headers, proxy);
|
|
218
223
|
if (!dial) return sseFallback(url, init);
|
|
219
224
|
const identity = control ? null : codexWsReuseIdentity(url, dial.headers, frameText, dial.proxy, dial.url);
|
|
220
|
-
|
|
221
|
-
|
|
225
|
+
const reuseAcrossTurns = identity !== null && codexWsReuseAcrossTurnsEnabled();
|
|
226
|
+
const pooled = identity
|
|
227
|
+
? reuseAcrossTurns
|
|
228
|
+
? await codexWsPool.acquireWaiting(identity, dial.url, dial.headers, dial.proxy, signal)
|
|
229
|
+
: codexWsPool.acquire(identity, dial.url, dial.headers, dial.proxy)
|
|
230
|
+
: null;
|
|
231
|
+
if (reuseAcrossTurns && signal?.aborted) {
|
|
232
|
+
return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
|
|
233
|
+
}
|
|
234
|
+
session = pooled ?? new CodexWsSession(dial.url, dial.headers, false, undefined, dial.proxy);
|
|
222
235
|
if (!session.busy && !session.reserve()) {
|
|
223
236
|
session.dispose();
|
|
224
237
|
return sseFallback(url, init);
|
|
@@ -229,7 +242,7 @@ export function codexWsUpstreamFetch(
|
|
|
229
242
|
return codexWsExchange({
|
|
230
243
|
session, url, init, prepared, sseFallback, onQuota, beforeDispatch,
|
|
231
244
|
nativeControl: control,
|
|
232
|
-
beforeContinuation,
|
|
245
|
+
beforeContinuation, onPhysicalDispatch,
|
|
233
246
|
bunVersion: typeof runtime === "string" ? runtime : runtime.version,
|
|
234
247
|
});
|
|
235
248
|
}
|
|
@@ -88,7 +88,8 @@ export function markStartupHealthDiagnosticStale(value: StartupHealth): StartupH
|
|
|
88
88
|
// Mirror deriveStartupHealth's choice: an already-registered service is refreshed in
|
|
89
89
|
// place. Hardcoding installService here silently undid that for every stale-cache
|
|
90
90
|
// read, which is the path the dashboard hits while a probe is revalidating.
|
|
91
|
-
|
|
91
|
+
recommendedAction: value.desktop ? "Reopen OpenCodex and check Start at Login." : value.recommendedAction,
|
|
92
|
+
recommendedCommand: value.routingKind === "opencodex-local" && value.desktop !== undefined
|
|
92
93
|
? null
|
|
93
94
|
: value.routingKind === "custom-local" || value.routingKind === "unknown"
|
|
94
95
|
? value.commands.restoreNative
|
|
@@ -41,6 +41,8 @@ export interface WorkflowRefusalLog {
|
|
|
41
41
|
readonly requestId: string;
|
|
42
42
|
readonly start: number;
|
|
43
43
|
readonly logCtx: RequestLogContext;
|
|
44
|
+
/** Receiving HTTP route's notification after this refusal's final-log call. */
|
|
45
|
+
readonly onLogged?: () => void;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/**
|
|
@@ -84,6 +86,7 @@ export function workflowRefusalResponse(
|
|
|
84
86
|
addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, 429, {
|
|
85
87
|
closeReason: "terminal",
|
|
86
88
|
});
|
|
89
|
+
refusalLog.onLogged?.();
|
|
87
90
|
}
|
|
88
91
|
const refusal = formatErrorResponse(
|
|
89
92
|
429,
|
package/src/service/cli.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { WindowsSchedulerTaskProbe } from "./windows-scheduler";
|
|
|
26
26
|
import { win32 } from "node:path";
|
|
27
27
|
import { serviceDiagnosticsSummary } from "./diagnostics";
|
|
28
28
|
import { runServiceClaim } from "./claim";
|
|
29
|
+
import { createSupervisionLatch, desktopServiceCommandRefusal } from "./desktop-command-guard";
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* `restart` is NO LONGER folded into `repair`.
|
|
@@ -214,6 +215,7 @@ export function removeServiceTokenAfterUninstall(
|
|
|
214
215
|
/** Execute a service verb while preserving client-owned credentials during uninstall. */
|
|
215
216
|
export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
|
|
216
217
|
const filteredArgs = args.filter((a): a is string => Boolean(a));
|
|
218
|
+
const supervisionLatch = createSupervisionLatch();
|
|
217
219
|
const execute = async (): Promise<void> => {
|
|
218
220
|
// `claim` is not an install verb: it is deliberately outside planServiceCommand (whose
|
|
219
221
|
// backend/installation checks do not apply to an ownership write) and outside
|
|
@@ -231,6 +233,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
231
233
|
process.exit(1);
|
|
232
234
|
}
|
|
233
235
|
const { parsed, command } = plan;
|
|
236
|
+
const refusal = desktopServiceCommandRefusal(command, undefined, supervisionLatch);
|
|
237
|
+
if (refusal) {
|
|
238
|
+
console.error(refusal);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
234
242
|
if (command === "repair" || command === "restart") {
|
|
235
243
|
const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
|
|
236
244
|
assertServiceEnvironmentMatchesInstall();
|
|
@@ -249,7 +257,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
249
257
|
// existing registration had been restarted, not repaired (#4914).
|
|
250
258
|
let repairError: unknown;
|
|
251
259
|
try {
|
|
252
|
-
await repairService({ verb });
|
|
260
|
+
await repairService({ verb, supervisionLatch });
|
|
253
261
|
} catch (error) {
|
|
254
262
|
repairError = error;
|
|
255
263
|
process.exitCode = 1;
|
|
@@ -295,12 +303,12 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
295
303
|
throw new Error(`Task Scheduler state could not be verified before install: ${scheduler.detail}`);
|
|
296
304
|
}
|
|
297
305
|
if (scheduler.status === "absent") {
|
|
298
|
-
await installFreshWindowsSchedulerSafely();
|
|
306
|
+
await installFreshWindowsSchedulerSafely({ supervisionLatch });
|
|
299
307
|
} else {
|
|
300
|
-
await installServiceSafely(backend, ops.install);
|
|
308
|
+
await installServiceSafely(backend, ops.install, { supervisionLatch });
|
|
301
309
|
}
|
|
302
310
|
} else {
|
|
303
|
-
await installServiceSafely(backend, ops.install);
|
|
311
|
+
await installServiceSafely(backend, ops.install, { supervisionLatch });
|
|
304
312
|
}
|
|
305
313
|
} catch (error) {
|
|
306
314
|
console.error(`❌ Service install cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -474,6 +482,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
|
|
|
474
482
|
};
|
|
475
483
|
|
|
476
484
|
const preliminary = parseServiceArgs(filteredArgs);
|
|
485
|
+
if (preliminary.invalid.length === 0) {
|
|
486
|
+
const refusal = desktopServiceCommandRefusal(preliminary.sub, undefined, supervisionLatch);
|
|
487
|
+
if (refusal) {
|
|
488
|
+
console.error(refusal);
|
|
489
|
+
process.exitCode = 1;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
477
493
|
const windowsMutation = process.platform === "win32"
|
|
478
494
|
&& preliminary.invalid.length === 0
|
|
479
495
|
&& preliminary.sub !== "status";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createSupervisionLatch, inspectDesktopSupervision } from "./desktop-supervision.mjs";
|
|
2
|
+
|
|
3
|
+
export { createSupervisionLatch };
|
|
4
|
+
export type DesktopSupervision = ReturnType<typeof inspectDesktopSupervision>;
|
|
5
|
+
export type SupervisionInspector = (deps?: Parameters<typeof inspectDesktopSupervision>[0]) => DesktopSupervision;
|
|
6
|
+
export type SupervisionLatch = ReturnType<typeof createSupervisionLatch>;
|
|
7
|
+
|
|
8
|
+
export function desktopServiceRefusal(evidence: DesktopSupervision, blocked: boolean): string | null {
|
|
9
|
+
if (!blocked) return null;
|
|
10
|
+
const runtime = evidence.kind === "desktop" ? ` (pid ${evidence.runtimePid}, ${evidence.app})` : "";
|
|
11
|
+
return `OpenCodex Desktop supervises the running proxy${runtime}. `
|
|
12
|
+
+ "Quit OpenCodex, then rerun 'ocx service install' to move startup management to this CLI.";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertNoDesktopSupervision(
|
|
16
|
+
inspect: SupervisionInspector = inspectDesktopSupervision,
|
|
17
|
+
latch: SupervisionLatch = createSupervisionLatch(),
|
|
18
|
+
): void {
|
|
19
|
+
const evidence = inspect();
|
|
20
|
+
const refusal = desktopServiceRefusal(evidence, latch.observe(evidence));
|
|
21
|
+
if (refusal) throw new Error(refusal);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function desktopServiceCommandRefusal(
|
|
25
|
+
command: string,
|
|
26
|
+
inspect: SupervisionInspector = inspectDesktopSupervision,
|
|
27
|
+
latch: SupervisionLatch = createSupervisionLatch(),
|
|
28
|
+
): string | null {
|
|
29
|
+
if (!["install", "repair", "start", "restart"].includes(command)) return null;
|
|
30
|
+
const evidence = inspect();
|
|
31
|
+
return desktopServiceRefusal(evidence, latch.observe(evidence));
|
|
32
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { accessSync, constants, readFileSync,
|
|
2
|
+
import { accessSync, constants, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { readPid } from "../config/process-state";
|
|
6
6
|
import { resolveServiceOwnership, sameServiceOwnershipSubject, type ServiceOwnershipResolution } from "./state";
|
|
7
|
+
import { inspectDesktopSupervision, processIdentity, procfs, type ProcReader } from "./desktop-supervision.mjs";
|
|
7
8
|
|
|
8
9
|
export interface DesktopStartupDiagnostic {
|
|
9
10
|
/** Durable desktop claim; failed supervision does not release ownership. */
|
|
@@ -11,11 +12,13 @@ export interface DesktopStartupDiagnostic {
|
|
|
11
12
|
loginEnabled: boolean;
|
|
12
13
|
running: boolean;
|
|
13
14
|
viable: boolean;
|
|
15
|
+
/** Live parentage when no durable claim exists; never converts supervision into ownership. */
|
|
16
|
+
supervisor?: { supervisorPid: number; runtimePid: number; app: string };
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
/** A login item alone is insufficient: the same app must
|
|
19
|
+
/** A login item alone is insufficient: the same app must supervise this proxy. */
|
|
17
20
|
export function deriveDesktopStartup(facts: Omit<DesktopStartupDiagnostic, "viable">): DesktopStartupDiagnostic {
|
|
18
|
-
return { ...facts, viable: facts.owned && facts.loginEnabled && facts.running };
|
|
21
|
+
return { ...facts, viable: (facts.owned || facts.supervisor !== undefined) && facts.loginEnabled && facts.running };
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
function run(command: string, args: string[]): string {
|
|
@@ -28,25 +31,12 @@ interface DesktopStartupDeps {
|
|
|
28
31
|
uid?: number;
|
|
29
32
|
ownership?: () => ServiceOwnershipResolution;
|
|
30
33
|
readPid?: () => number | null;
|
|
34
|
+
readRuntimePortPid?: () => number | null;
|
|
31
35
|
run?: typeof run;
|
|
32
36
|
env?: NodeJS.ProcessEnv;
|
|
33
37
|
proc?: ProcReader;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/** Linux process identity from procfs: executable target and parent pid. */
|
|
37
|
-
interface ProcReader {
|
|
38
|
-
exe(pid: number): string;
|
|
39
|
-
parent(pid: number): number;
|
|
40
|
-
}
|
|
41
|
-
const procfs: ProcReader = {
|
|
42
|
-
exe: pid => readlinkSync(`/proc/${pid}/exe`),
|
|
43
|
-
// comm may contain spaces or parentheses, so fields are counted after the last ')'.
|
|
44
|
-
parent: pid => {
|
|
45
|
-
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
46
|
-
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
47
|
-
},
|
|
48
|
-
};
|
|
49
|
-
|
|
50
40
|
/** Cheap ownership-only snapshot: no launchd/process probes on the server request path. */
|
|
51
41
|
export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopStartupDiagnostic | undefined {
|
|
52
42
|
const platform = deps.platform ?? process.platform;
|
|
@@ -56,9 +46,47 @@ export function desktopStartupOwnership(deps: DesktopStartupDeps = {}): DesktopS
|
|
|
56
46
|
? deriveDesktopStartup({ owned: true, loginEnabled: false, running: false }) : undefined;
|
|
57
47
|
}
|
|
58
48
|
|
|
59
|
-
function
|
|
60
|
-
const
|
|
61
|
-
|
|
49
|
+
function macLoginRegistration(deps: DesktopStartupDeps) {
|
|
50
|
+
const execute = deps.run ?? run;
|
|
51
|
+
const path = join(deps.home ?? homedir(), "Library", "LaunchAgents", "OpenCodex.plist");
|
|
52
|
+
const plist = JSON.parse(execute("/usr/bin/plutil", ["-convert", "json", "-o", "-", path]));
|
|
53
|
+
const args = plist.ProgramArguments;
|
|
54
|
+
if (plist.Label !== "OpenCodex" || plist.RunAtLoad !== true || !Array.isArray(args)
|
|
55
|
+
|| args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
|
|
56
|
+
|| !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
|
|
57
|
+
|| (plist.Program !== undefined && plist.Program !== args[0])) return null;
|
|
58
|
+
const app = realpathSync(args[0]);
|
|
59
|
+
const proxy = realpathSync(join(dirname(app), "ocx"));
|
|
60
|
+
accessSync(app, constants.X_OK);
|
|
61
|
+
accessSync(proxy, constants.X_OK);
|
|
62
|
+
const domain = `gui/${deps.uid ?? process.getuid!()}`;
|
|
63
|
+
const disabled = execute("/bin/launchctl", ["print-disabled", domain]);
|
|
64
|
+
const loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]);
|
|
65
|
+
const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
|
|
66
|
+
const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
|
|
67
|
+
const loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled)
|
|
68
|
+
&& !/"OpenCodex"\s*=>\s*(?:disabled|true)/.test(disabled)
|
|
69
|
+
&& program !== undefined && realpathSync(program) === app
|
|
70
|
+
&& loadedPath !== undefined && realpathSync(loadedPath) === realpathSync(path);
|
|
71
|
+
return { app, proxy, loginEnabled };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: number, loginFor: (app: string) => boolean): DesktopStartupDiagnostic | undefined {
|
|
75
|
+
const evidence = inspectDesktopSupervision(deps);
|
|
76
|
+
if (evidence.kind !== "desktop") return undefined;
|
|
77
|
+
let loginEnabled = false;
|
|
78
|
+
try { loginEnabled = loginFor(evidence.app); }
|
|
79
|
+
catch { /* Login failure revokes restart viability, not the observed live supervisor. */ }
|
|
80
|
+
if ((deps.ownership ?? resolveServiceOwnership)().kind !== "none") return undefined;
|
|
81
|
+
// Login probes can outlive the runtime or its parent; only the same live chain gets credit.
|
|
82
|
+
const final = inspectDesktopSupervision({ ...deps, targetPid: evidence.runtimePid });
|
|
83
|
+
if (final.kind !== "desktop" || final.runtimePid !== evidence.runtimePid
|
|
84
|
+
|| final.supervisorPid !== evidence.supervisorPid || final.app !== evidence.app) return undefined;
|
|
85
|
+
// A new claim or revision during the last process probe invalidates the unowned projection.
|
|
86
|
+
const finalOwner = (deps.ownership ?? resolveServiceOwnership)();
|
|
87
|
+
if (finalOwner.kind !== "none" || finalOwner.revision !== ownerRevision) return undefined;
|
|
88
|
+
const { supervisorPid, runtimePid, app } = evidence;
|
|
89
|
+
return deriveDesktopStartup({ owned: false, loginEnabled, running: true, supervisor: { supervisorPid, runtimePid, app } });
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
/** Read-only macOS desktop ownership, login registration and live parent/child checks. */
|
|
@@ -66,6 +94,10 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
|
|
|
66
94
|
if ((deps.platform ?? process.platform) !== "darwin") return undefined;
|
|
67
95
|
const ownership = deps.ownership ?? resolveServiceOwnership;
|
|
68
96
|
const owner = ownership();
|
|
97
|
+
if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => {
|
|
98
|
+
const login = macLoginRegistration(deps);
|
|
99
|
+
return login !== null && login.app === app && login.loginEnabled;
|
|
100
|
+
});
|
|
69
101
|
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
|
|
70
102
|
const facts = { owned: true, loginEnabled: false, running: false };
|
|
71
103
|
const execute = deps.run ?? run;
|
|
@@ -74,26 +106,10 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto
|
|
|
74
106
|
const home = deps.home ?? homedir();
|
|
75
107
|
const id = readFileSync(join(home, "Library", "Application Support", "com.opencodex.desktop", "install-id"), "utf8").trim();
|
|
76
108
|
if (id !== owner.ownership.installId) return deriveDesktopStartup(facts);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|| args.length !== 2 || args[1] !== "--autostart" || typeof args[0] !== "string"
|
|
82
|
-
|| !args[0].endsWith("/Contents/MacOS/opencodex-desktop")
|
|
83
|
-
|| (plist.Program !== undefined && plist.Program !== args[0])) return deriveDesktopStartup(facts);
|
|
84
|
-
const app = realpathSync(args[0]);
|
|
85
|
-
const proxy = realpathSync(join(dirname(app), "ocx"));
|
|
86
|
-
accessSync(app, constants.X_OK);
|
|
87
|
-
accessSync(proxy, constants.X_OK);
|
|
88
|
-
const domain = `gui/${deps.uid ?? process.getuid!()}`;
|
|
89
|
-
const disabled = execute("/bin/launchctl", ["print-disabled", domain]);
|
|
90
|
-
const loaded = execute("/bin/launchctl", ["print", `${domain}/OpenCodex`]);
|
|
91
|
-
const program = /^\s*program = (.+)$/m.exec(loaded)?.[1];
|
|
92
|
-
const loadedPath = /^\s*path = (.+)$/m.exec(loaded)?.[1];
|
|
93
|
-
facts.loginEnabled = /^\s*disabled services = \{[\s\S]*\}\s*$/.test(disabled)
|
|
94
|
-
&& !/"OpenCodex"\s*=>\s*(?:disabled|true)/.test(disabled)
|
|
95
|
-
&& program !== undefined && realpathSync(program) === app
|
|
96
|
-
&& loadedPath !== undefined && realpathSync(loadedPath) === realpathSync(path);
|
|
109
|
+
const login = macLoginRegistration(deps);
|
|
110
|
+
if (!login) return deriveDesktopStartup(facts);
|
|
111
|
+
const { app, proxy } = login;
|
|
112
|
+
facts.loginEnabled = login.loginEnabled;
|
|
97
113
|
const pid = pidReader();
|
|
98
114
|
if (pid !== null) {
|
|
99
115
|
const child = processIdentity(pid, execute);
|
|
@@ -135,6 +151,16 @@ export function diagnoseLinuxDesktopStartup(deps: DesktopStartupDeps = {}): Desk
|
|
|
135
151
|
if ((deps.platform ?? process.platform) !== "linux") return undefined;
|
|
136
152
|
const ownership = deps.ownership ?? resolveServiceOwnership;
|
|
137
153
|
const owner = ownership();
|
|
154
|
+
if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => {
|
|
155
|
+
const home = deps.home ?? homedir();
|
|
156
|
+
const configured = (deps.env ?? process.env).XDG_CONFIG_HOME;
|
|
157
|
+
const config = configured && isAbsolute(configured) ? configured : join(home, ".config");
|
|
158
|
+
// auto-launch's HOME entry must remain inside the session's XDG autostart search path.
|
|
159
|
+
if (resolve(config) !== resolve(home, ".config")) return false;
|
|
160
|
+
const entry = readFileSync(join(home, ".config", "autostart", "OpenCodex.desktop"), "utf8");
|
|
161
|
+
const login = linuxLoginApp(entry);
|
|
162
|
+
return login !== null && realpathSync(login) === app;
|
|
163
|
+
});
|
|
138
164
|
if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined;
|
|
139
165
|
const facts = { owned: true, loginEnabled: false, running: false };
|
|
140
166
|
const proc = deps.proc ?? procfs;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type SupervisionEvidence =
|
|
2
|
+
| { kind: "desktop"; runtimePid: number; supervisorPid: number; app: string; proxy: string }
|
|
3
|
+
| { kind: "none" }
|
|
4
|
+
| { kind: "unknown"; reason: string; desktopSeen: boolean }
|
|
5
|
+
| { kind: "unsupported" };
|
|
6
|
+
|
|
7
|
+
export interface ProcReader {
|
|
8
|
+
exe(pid: number): string;
|
|
9
|
+
parent(pid: number): number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DesktopSupervisionDeps {
|
|
13
|
+
targetPid?: number;
|
|
14
|
+
platform?: NodeJS.Platform;
|
|
15
|
+
run?: (command: string, args: string[]) => string;
|
|
16
|
+
proc?: ProcReader;
|
|
17
|
+
readPid?: () => number | null;
|
|
18
|
+
readRuntimePortPid?: () => number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function desktopSupervisionPaths(): { pid: string; runtimePort: string };
|
|
22
|
+
export const procfs: ProcReader;
|
|
23
|
+
export function processIdentity(pid: number, execute?: DesktopSupervisionDeps["run"], onExecutable?: (path: string) => void):
|
|
24
|
+
{ parent: number; executable: string } | null;
|
|
25
|
+
export function inspectDesktopSupervision(deps?: DesktopSupervisionDeps): SupervisionEvidence;
|
|
26
|
+
export function createSupervisionLatch(): { observe(evidence: SupervisionEvidence): boolean };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Node >=18 also imports this module from bin/ocx.mjs; do not import TS or Bun APIs.
|
|
7
|
+
function run(command, args) {
|
|
8
|
+
return execFileSync(command, args, {
|
|
9
|
+
encoding: "utf8", timeout: 750, maxBuffer: 128 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
}).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Mirror src/config/paths.ts getConfigDir/expandUserPath and process-state.ts paths.
|
|
14
|
+
// Keep this Node-safe copy in sync; the focused startup test pins both implementations.
|
|
15
|
+
export function desktopSupervisionPaths() {
|
|
16
|
+
const raw = process.env.OPENCODEX_HOME?.trim();
|
|
17
|
+
const expanded = raw === "~" ? homedir()
|
|
18
|
+
: raw?.startsWith("~/") || raw?.startsWith("~\\") ? join(homedir(), raw.slice(2)) : raw;
|
|
19
|
+
const home = expanded ? resolve(expanded) : join(homedir(), ".opencodex");
|
|
20
|
+
return { pid: join(home, "ocx.pid"), runtimePort: join(home, "runtime-port.json") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validPid(pid) {
|
|
24
|
+
return Number.isSafeInteger(pid) && pid > 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readState(path, parse) {
|
|
28
|
+
try { return parse(readFileSync(path, "utf8")); }
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error?.code === "ENOENT") return null;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPidFile() {
|
|
36
|
+
return readState(desktopSupervisionPaths().pid, raw => {
|
|
37
|
+
const text = raw.trim();
|
|
38
|
+
if (!/^\d+$/.test(text) || !validPid(Number(text))) throw new Error("invalid pid");
|
|
39
|
+
return Number(text);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readRuntimePortPid() {
|
|
44
|
+
return readState(desktopSupervisionPaths().runtimePort, raw => {
|
|
45
|
+
// Only project pid: no credential validation, serialization or returned port state.
|
|
46
|
+
const pid = JSON.parse(raw)?.pid;
|
|
47
|
+
if (!validPid(pid)) throw new Error("invalid runtime pid");
|
|
48
|
+
return pid;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Linux identity fields count from the end of comm, which may contain spaces or ')'. */
|
|
53
|
+
export const procfs = {
|
|
54
|
+
exe: pid => readlinkSync(`/proc/${pid}/exe`),
|
|
55
|
+
parent: pid => {
|
|
56
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
57
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function processIdentity(pid, execute = run, onExecutable) {
|
|
62
|
+
const text = execute("/bin/ps", ["-p", String(pid), "-o", "ppid=,comm="]).trim();
|
|
63
|
+
const row = /^(\d+)\s+(.+)$/.exec(text);
|
|
64
|
+
if (!row) {
|
|
65
|
+
if (text) throw new Error("invalid process identity");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
onExecutable?.(row[2]);
|
|
69
|
+
return { parent: Number(row[1]), executable: realpathSync(row[2]) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Live supervision is evidence, never a durable ownership claim. */
|
|
73
|
+
export function inspectDesktopSupervision(deps = {}) {
|
|
74
|
+
const platform = deps.platform ?? process.platform;
|
|
75
|
+
if (platform !== "darwin" && platform !== "linux") return { kind: "unsupported" };
|
|
76
|
+
let desktopSeen = false;
|
|
77
|
+
const unknown = reason => ({ kind: "unknown", reason, desktopSeen });
|
|
78
|
+
const seen = executable => { if (basename(executable) === "opencodex-desktop") desktopSeen = true; };
|
|
79
|
+
const readPid = deps.readPid ?? readPidFile;
|
|
80
|
+
const readPortPid = deps.readRuntimePortPid ?? readRuntimePortPid;
|
|
81
|
+
const proc = deps.proc ?? procfs;
|
|
82
|
+
const execute = deps.run ?? run;
|
|
83
|
+
function identity(pid, parent = false) {
|
|
84
|
+
if (platform === "darwin") return processIdentity(pid, execute, parent ? seen : undefined);
|
|
85
|
+
const executable = proc.exe(pid);
|
|
86
|
+
if (parent) seen(executable);
|
|
87
|
+
const parentPid = proc.parent(pid);
|
|
88
|
+
if (!Number.isSafeInteger(parentPid) || parentPid < 0) throw new Error("invalid parent pid");
|
|
89
|
+
return { parent: parentPid, executable: realpathSync(executable) };
|
|
90
|
+
}
|
|
91
|
+
function capture() {
|
|
92
|
+
const pidFile = readPid();
|
|
93
|
+
const portPid = readPortPid();
|
|
94
|
+
const pids = [deps.targetPid, pidFile, portPid].filter(pid => pid !== undefined && pid !== null);
|
|
95
|
+
if (pids.some(pid => !validPid(pid))) return { error: "invalid-pid" };
|
|
96
|
+
if (pids.some(pid => pid !== pids[0])) return { error: "pid-mismatch" };
|
|
97
|
+
const runtimePid = pids[0] ?? null;
|
|
98
|
+
const child = runtimePid !== null ? identity(runtimePid) : null;
|
|
99
|
+
const parent = child && child.parent > 1 ? identity(child.parent, true) : null;
|
|
100
|
+
const snapshot = { pidFile, portPid, runtimePid, child, parent };
|
|
101
|
+
if (!runtimePid || !child || !parent || basename(parent.executable) !== "opencodex-desktop") {
|
|
102
|
+
return { ...snapshot, kind: "none" };
|
|
103
|
+
}
|
|
104
|
+
seen(parent.executable);
|
|
105
|
+
const app = parent.executable;
|
|
106
|
+
if (platform === "darwin" && (basename(dirname(app)) !== "MacOS"
|
|
107
|
+
|| basename(dirname(dirname(app))) !== "Contents")) return { error: "app-mismatch" };
|
|
108
|
+
const proxy = realpathSync(join(dirname(app), "ocx"));
|
|
109
|
+
if (child.executable !== proxy || (platform === "linux"
|
|
110
|
+
&& (dirname(proxy) !== dirname(app) || basename(proxy) !== "ocx"))) return { error: "proxy-mismatch" };
|
|
111
|
+
accessSync(app, constants.X_OK);
|
|
112
|
+
accessSync(proxy, constants.X_OK);
|
|
113
|
+
return { ...snapshot, kind: "desktop", app, proxy };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const first = capture();
|
|
117
|
+
if (first.error) return unknown(first.error);
|
|
118
|
+
const final = capture();
|
|
119
|
+
if (final.error) return unknown(final.error);
|
|
120
|
+
if (JSON.stringify(first) !== JSON.stringify(final)) return unknown("snapshot-changed");
|
|
121
|
+
return final.kind === "desktop"
|
|
122
|
+
? { kind: "desktop", runtimePid: final.runtimePid, supervisorPid: final.child.parent, app: final.app, proxy: final.proxy }
|
|
123
|
+
: { kind: "none" };
|
|
124
|
+
} catch {
|
|
125
|
+
// Error messages can contain paths or file contents; expose only the bounded reason.
|
|
126
|
+
return unknown("probe-failed");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One operation retains its Desktop veto through inconclusive later probes. */
|
|
131
|
+
export function createSupervisionLatch() {
|
|
132
|
+
let blocked = false;
|
|
133
|
+
return {
|
|
134
|
+
observe(evidence) {
|
|
135
|
+
if (evidence.kind === "none") blocked = false;
|
|
136
|
+
else if (evidence.kind === "desktop" || (evidence.kind === "unknown" && evidence.desktopSeen)) blocked = true;
|
|
137
|
+
return blocked;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -81,6 +81,7 @@ function probeVersionOnce(
|
|
|
81
81
|
windowsShim ? ["/c", shimCommand] : [...args, "--version"],
|
|
82
82
|
{
|
|
83
83
|
timeout: VERSION_PROBE_TIMEOUT_MS, encoding: "utf8", stdio: "pipe",
|
|
84
|
+
env: { ...deps.env, OCX_NO_DESKTOP_HANDOFF: "1" },
|
|
84
85
|
windowsHide: true, ...(windowsShim ? { windowsVerbatimArguments: true } : {}),
|
|
85
86
|
},
|
|
86
87
|
) as SpawnSyncReturns<string>;
|