@bitkyc08/opencodex 2.67.0 → 2.68.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 +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
package/src/images/loop.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
|
23
23
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
24
24
|
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
|
|
25
25
|
import { rateLimitRetryDelayMs } from "../providers/key-failover";
|
|
26
|
+
import { releaseProviderRequestSlot, sendTrackingRequestSlot, type ProviderRequestSlot } from "../providers/request-pacing";
|
|
26
27
|
import {
|
|
27
28
|
createTranslatorBudget,
|
|
28
29
|
isTranslatorBudgetExceededError,
|
|
@@ -309,7 +310,7 @@ export interface ImageBridgeDeps {
|
|
|
309
310
|
/** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */
|
|
310
311
|
fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof globalThis.fetch;
|
|
311
312
|
/** Reserve the routed provider's next request-start slot before each adapter dispatch. */
|
|
312
|
-
waitForRequestSlot?: (signal?: AbortSignal) => Promise<void>;
|
|
313
|
+
waitForRequestSlot?: (signal?: AbortSignal) => Promise<ProviderRequestSlot | void>;
|
|
313
314
|
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
|
|
314
315
|
onUsage?: (usage: OcxUsage | undefined) => void;
|
|
315
316
|
/**
|
|
@@ -334,6 +335,7 @@ export interface ImageBridgeDeps {
|
|
|
334
335
|
retryAfterHeader: string | null,
|
|
335
336
|
responseHeaders?: Headers,
|
|
336
337
|
retryParsed?: OcxParsedRequest,
|
|
338
|
+
originalResponse?: Response,
|
|
337
339
|
) =>
|
|
338
340
|
| { adapter: ProviderAdapter; recoveryKind: AttemptRecoveryKind }
|
|
339
341
|
| null
|
|
@@ -466,7 +468,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
466
468
|
// expose buildRequest/fetchResponse/parseStream to the bridge, so collect their events through
|
|
467
469
|
// an AdapterEventQueue and pass the bounded collection directly to the common scanner.
|
|
468
470
|
if (adapter.runTurn) {
|
|
469
|
-
await deps.waitForRequestSlot?.(signal);
|
|
471
|
+
const pacingSlot = (await deps.waitForRequestSlot?.(signal)) || undefined;
|
|
470
472
|
const queue = createAdapterEventQueue({
|
|
471
473
|
onBacklogExceeded: () => internalAbort.abort("runTurn backlog exceeded"),
|
|
472
474
|
});
|
|
@@ -514,6 +516,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
514
516
|
headers: deps.forwardHeaders ? new Headers(deps.forwardHeaders) : new Headers(),
|
|
515
517
|
abortSignal: signal,
|
|
516
518
|
translatorBudget,
|
|
519
|
+
pacingSlot,
|
|
517
520
|
}, emit).then(closeOnAbort).catch(err => {
|
|
518
521
|
if (accepting) {
|
|
519
522
|
collectionError = err;
|
|
@@ -528,6 +531,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
528
531
|
if (event.type !== "heartbeat") events.push(event);
|
|
529
532
|
}
|
|
530
533
|
} finally {
|
|
534
|
+
releaseProviderRequestSlot(pacingSlot);
|
|
531
535
|
accepting = false;
|
|
532
536
|
idle.cancel();
|
|
533
537
|
signal.removeEventListener("abort", closeOnAbort);
|
|
@@ -561,10 +565,11 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
561
565
|
}
|
|
562
566
|
|
|
563
567
|
let headerDeadline = clearableDeadline(connectTimeoutMs, signal);
|
|
564
|
-
const paceThenResetHeaderDeadline = async (): Promise<
|
|
568
|
+
const paceThenResetHeaderDeadline = async (): Promise<ProviderRequestSlot | undefined> => {
|
|
565
569
|
headerDeadline.clear();
|
|
566
|
-
await deps.waitForRequestSlot?.(signal);
|
|
570
|
+
const slot = (await deps.waitForRequestSlot?.(signal)) || undefined;
|
|
567
571
|
headerDeadline = clearableDeadline(connectTimeoutMs, signal);
|
|
572
|
+
return slot;
|
|
568
573
|
};
|
|
569
574
|
try {
|
|
570
575
|
/**
|
|
@@ -597,35 +602,43 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
597
602
|
let response: Response;
|
|
598
603
|
try {
|
|
599
604
|
if (requestAdapter.fetchResponse) {
|
|
600
|
-
await paceThenResetHeaderDeadline();
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
605
|
+
const slot = await paceThenResetHeaderDeadline();
|
|
606
|
+
try {
|
|
607
|
+
deps.onAttemptSend?.(recovery);
|
|
608
|
+
response = await sendTrackingRequestSlot(slot, () => requestAdapter.fetchResponse!(request, {
|
|
609
|
+
abortSignal: headerDeadline.signal,
|
|
610
|
+
timeoutMs: connectTimeoutMs,
|
|
611
|
+
returnRawErrors: true,
|
|
612
|
+
stream: true,
|
|
613
|
+
executor: requestFetch,
|
|
614
|
+
}));
|
|
615
|
+
} finally {
|
|
616
|
+
releaseProviderRequestSlot(slot);
|
|
617
|
+
}
|
|
609
618
|
} else {
|
|
610
619
|
response = await fetchWithResetRetry(
|
|
611
620
|
async (retryRecovery) => {
|
|
612
|
-
await paceThenResetHeaderDeadline();
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
621
|
+
const slot = await paceThenResetHeaderDeadline();
|
|
622
|
+
try {
|
|
623
|
+
// Record every helper-driven send (the callback runs for the first attempt and
|
|
624
|
+
// each connection-reset replay); preserve the caller's recovery kind
|
|
625
|
+
// (rate-limit-429 / key-429) when the retry layer supplies none.
|
|
626
|
+
deps.onAttemptSend?.(retryRecovery ?? recovery);
|
|
627
|
+
const h = new Headers(request.headers);
|
|
628
|
+
if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
|
|
629
|
+
// Same reset-recovery parity as the web-search loop: the replay needs
|
|
630
|
+
// `keepalive: false` to abandon the pooled socket, because Bun has ignored the
|
|
631
|
+
// hop-by-hop header alone (oven-sh/bun#20492).
|
|
632
|
+
return await sendTrackingRequestSlot(slot, () => requestFetch(request.url, applyUpstreamRecoveryInit({
|
|
633
|
+
method: request.method,
|
|
634
|
+
redirect: "manual",
|
|
635
|
+
headers: h,
|
|
636
|
+
body: request.body,
|
|
637
|
+
signal: headerDeadline.signal,
|
|
638
|
+
}, retryRecovery)));
|
|
639
|
+
} finally {
|
|
640
|
+
releaseProviderRequestSlot(slot);
|
|
641
|
+
}
|
|
629
642
|
},
|
|
630
643
|
{ replaySafe: true, abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
|
|
631
644
|
);
|
|
@@ -671,8 +684,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
671
684
|
prepared = await fetchOnce(adapter, "rate-limit-429");
|
|
672
685
|
}
|
|
673
686
|
// 429 key-failover parity with web-search / normal routed path.
|
|
674
|
-
while (prepared.response.status === 429
|
|
675
|
-
|
|
687
|
+
while ((prepared.response.status === 429
|
|
688
|
+
|| (iterParsed._kiroAuthContext && (prepared.response.status === 400 || prepared.response.status === 403))) && deps.on429) {
|
|
689
|
+
const rotated = await deps.on429(prepared.response.headers.get("retry-after"), prepared.response.headers,
|
|
690
|
+
iterParsed, prepared.response);
|
|
676
691
|
if (!rotated) break;
|
|
677
692
|
try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
678
693
|
adapter = rotated.adapter;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siblingOfLivePort } from "../codex/sibling-start";
|
|
1
2
|
import { redactSecretString } from "../lib/redact";
|
|
2
3
|
import type { ExportModel } from "../clients/config-export";
|
|
3
4
|
import type { IntegrationClientId } from "./registry";
|
|
@@ -12,6 +13,9 @@ export async function refreshOwnedCatalogIntegrations(
|
|
|
12
13
|
input: Omit<OwnedIntegrationRefreshInput, "clientId">,
|
|
13
14
|
clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast", "omo"],
|
|
14
15
|
): Promise<OwnedIntegrationRefreshOutcome[]> {
|
|
16
|
+
// Client files are shared with the live proxy a sibling instance runs beside; their entries
|
|
17
|
+
// point at the owner's port, and refreshing them here would re-point them at this one.
|
|
18
|
+
if (siblingOfLivePort() !== null) return [];
|
|
15
19
|
let models: Promise<readonly ExportModel[]> | undefined;
|
|
16
20
|
const loadModels = () => models ??= Promise.resolve().then(() =>
|
|
17
21
|
typeof input.models === "function" ? input.models() : input.models);
|
|
@@ -274,6 +274,7 @@ function childEnd(
|
|
|
274
274
|
const nextSpaces = leadingSpaces(lines[ahead]!.body);
|
|
275
275
|
if (nextSpaces === null) return null;
|
|
276
276
|
if (nextSpaces <= indent) return index;
|
|
277
|
+
index = ahead - 1;
|
|
277
278
|
continue;
|
|
278
279
|
}
|
|
279
280
|
if (quote === null && isComment(body)) return spaces <= indent ? index : null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project a Messages request body onto the content a settled route actually serializes.
|
|
3
|
+
*
|
|
4
|
+
* `estimateClaudeRequestTokens` measures the caller's body as JSON. That is exactly right for the
|
|
5
|
+
* Anthropic-native wire, where a replayed `thinking` block — signature included — is forwarded
|
|
6
|
+
* verbatim. A routed wire may serialize far less, and on the Chat wire it does: replayed thinking
|
|
7
|
+
* becomes an optional `reasoning_content` string, the signature is never sent at all
|
|
8
|
+
* (`src/adapters/openai-chat/messages.ts` has no `signature` reference), and the text is dropped
|
|
9
|
+
* outright unless the model is on the provider's `preserveReasoningContentModels` list.
|
|
10
|
+
*
|
|
11
|
+
* The gap that opens is not a rounding error. On a captured 260-message Claude Code turn, replayed
|
|
12
|
+
* thinking blocks were 78.8% of the body's JSON characters and 56.7% of those characters were
|
|
13
|
+
* base64 signatures — bytes that are not prompt text under any tokenizer. Counting them made the
|
|
14
|
+
* published `message_start.usage.input_tokens` 3.28x the count the upstream actually reported
|
|
15
|
+
* (#4857 + the Paseo context meter it feeds), well past the >2x drift bound this estimator is held
|
|
16
|
+
* to (devlog 260711_claude_inbound 040 §3).
|
|
17
|
+
*
|
|
18
|
+
* The projection is therefore applied to the ESTIMATE only. The body the caller sent is never
|
|
19
|
+
* rewritten; this is a measurement that describes the route, not a transformation of the request.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Which replayed reasoning blocks and fields a wire serializes. */
|
|
23
|
+
export interface ClaudeThinkingProjection {
|
|
24
|
+
/** Serialize `thinking.thinking`, the model's own replayed text. */
|
|
25
|
+
text: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Serialize `thinking.signature`, the provider's base64 replay token. Real prompt size for
|
|
28
|
+
* wires that carry it; pure overhead for wires that do not.
|
|
29
|
+
*/
|
|
30
|
+
signature: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Serialize a `redacted_thinking` block, whose `data` is an opaque provider blob. It rides
|
|
33
|
+
* alongside `thinking` rather than inside it: a wire can reconstruct the model's reasoning
|
|
34
|
+
* without carrying the encrypted form, and the Chat wire does exactly that.
|
|
35
|
+
*/
|
|
36
|
+
redacted: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The Anthropic-native wire forwards a replayed thinking block verbatim, signature included, so
|
|
41
|
+
* nothing is projected away. This is the default: an unknown route keeps the measured body.
|
|
42
|
+
*/
|
|
43
|
+
export const CLAUDE_NATIVE_THINKING: ClaudeThinkingProjection = { text: true, signature: true, redacted: true };
|
|
44
|
+
|
|
45
|
+
interface ProjectableBody {
|
|
46
|
+
system?: unknown;
|
|
47
|
+
messages?: unknown;
|
|
48
|
+
tools?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One content block as the given wire would carry it.
|
|
53
|
+
*
|
|
54
|
+
* `undefined` means the wire sends nothing for it, and the caller drops the entry. A `thinking`
|
|
55
|
+
* block that keeps its text but not its signature is returned as a copy rather than edited: the
|
|
56
|
+
* block object is shared with the outbound request builder, which must stay untouched.
|
|
57
|
+
*/
|
|
58
|
+
function projectBlock(block: unknown, thinking: ClaudeThinkingProjection): unknown {
|
|
59
|
+
if (!block || typeof block !== "object") return block;
|
|
60
|
+
if (!("type" in block)) return block;
|
|
61
|
+
if (block.type === "redacted_thinking") return thinking.redacted ? block : undefined;
|
|
62
|
+
if (block.type !== "thinking") return block;
|
|
63
|
+
if (!thinking.text) return undefined;
|
|
64
|
+
if (thinking.signature || !("signature" in block)) return block;
|
|
65
|
+
// Copy without the signature: the caller's block object is shared with the outbound request
|
|
66
|
+
// builder, so it must never be edited in place.
|
|
67
|
+
const { signature: _dropped, ...rest } = block;
|
|
68
|
+
return rest;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A copy of `raw` whose message content carries only the replayed thinking this route serializes.
|
|
73
|
+
*
|
|
74
|
+
* Pure and idempotent; the input is never mutated. Blocks are dropped only inside array-valued
|
|
75
|
+
* message `content`, which is the sole protocol position a replayed thinking block occupies.
|
|
76
|
+
* A message whose content array empties out is left as an empty array rather than deleted: the
|
|
77
|
+
* adapter's own "nothing left to send" rule is keyed on text, tool calls and reasoning together,
|
|
78
|
+
* and re-deriving it here would be a second copy of that rule to keep in sync.
|
|
79
|
+
*/
|
|
80
|
+
export function projectClaudeRequest(
|
|
81
|
+
raw: ProjectableBody,
|
|
82
|
+
thinking: ClaudeThinkingProjection,
|
|
83
|
+
): ProjectableBody {
|
|
84
|
+
if (thinking.text && thinking.signature && thinking.redacted) return raw;
|
|
85
|
+
if (!Array.isArray(raw.messages)) return raw;
|
|
86
|
+
const messages = raw.messages as unknown[];
|
|
87
|
+
return {
|
|
88
|
+
...raw,
|
|
89
|
+
messages: messages.map(message => {
|
|
90
|
+
if (!message || typeof message !== "object") return message;
|
|
91
|
+
if (!("content" in message) || !Array.isArray(message.content)) return message;
|
|
92
|
+
const content = message.content as unknown[];
|
|
93
|
+
return {
|
|
94
|
+
...message,
|
|
95
|
+
content: content
|
|
96
|
+
.map(block => projectBlock(block, thinking))
|
|
97
|
+
.filter(block => block !== undefined),
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isLocalAttestationSecret } from "./local-management-attestation";
|
|
3
|
+
import { LOCAL_MANAGEMENT_CAPABILITY_TTL_MS } from "./local-management-capability";
|
|
4
|
+
|
|
5
|
+
export const LOCAL_ACCOUNT_SWITCH_PATHS = [
|
|
6
|
+
"/api/codex-auth/active",
|
|
7
|
+
"/api/oauth/accounts/active",
|
|
8
|
+
"/api/providers/keys/active",
|
|
9
|
+
] as const;
|
|
10
|
+
export const LOCAL_ACCOUNT_SWITCH_BODY_HEADER = "x-opencodex-account-switch-sha256";
|
|
11
|
+
const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
|
|
13
|
+
export function isLocalAccountSwitchPath(path: string): boolean {
|
|
14
|
+
return LOCAL_ACCOUNT_SWITCH_PATHS.some(candidate => candidate === path);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Digest the exact bytes on the wire, before parsing or reserialization. */
|
|
18
|
+
export function accountSwitchBodyDigest(body: Uint8Array): string {
|
|
19
|
+
return createHash("sha256").update(body).digest("base64url");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createLocalAccountSwitchCapability(
|
|
23
|
+
secret: string, nonce: string, method: string, path: string,
|
|
24
|
+
pid: number, port: number, expiresAt: number, bodyDigest: string,
|
|
25
|
+
): string | null {
|
|
26
|
+
if (!isLocalAttestationSecret(secret) || !BASE64URL_256.test(nonce)) return null;
|
|
27
|
+
if (method !== "PUT" || !isLocalAccountSwitchPath(path)) return null;
|
|
28
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
29
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
30
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= 0) return null;
|
|
31
|
+
if (!BASE64URL_256.test(bodyDigest)) return null;
|
|
32
|
+
const payload = `opencodex-local-account-switch-v1\n${nonce}\n${method}\n${path}\n${pid}\n${port}\n${expiresAt}\n${bodyDigest}`;
|
|
33
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function verifyLocalAccountSwitchCapability(
|
|
37
|
+
secret: string, nonce: string | null, method: string, path: string,
|
|
38
|
+
pid: number, port: number, expiresAt: number, bodyDigest: string | null,
|
|
39
|
+
capability: string | null, now = Date.now(),
|
|
40
|
+
): boolean {
|
|
41
|
+
if (!nonce || !bodyDigest || !capability || !BASE64URL_256.test(capability)) return false;
|
|
42
|
+
if (!Number.isSafeInteger(now) || expiresAt <= now
|
|
43
|
+
|| expiresAt > now + LOCAL_MANAGEMENT_CAPABILITY_TTL_MS) return false;
|
|
44
|
+
const expected = createLocalAccountSwitchCapability(
|
|
45
|
+
secret, nonce, method, path, pid, port, expiresAt, bodyDigest,
|
|
46
|
+
);
|
|
47
|
+
return expected !== null && timingSafeEqual(Buffer.from(expected), Buffer.from(capability));
|
|
48
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify an upstream provider destination as local or public.
|
|
3
|
+
*
|
|
4
|
+
* A *local* upstream is operator-trusted infrastructure on the same machine or LAN — LM Studio,
|
|
5
|
+
* Ollama, a self-hosted vLLM, a local gateway. Those models are frequently CPU-bound and their
|
|
6
|
+
* time-to-first-token or thinking phases can go silent for minutes, so a stall budget applied
|
|
7
|
+
* there would kill healthy turns. The stall resolver (`src/stall-timeout.ts`) therefore treats an
|
|
8
|
+
* unset budget on a local upstream as *disabled* while public upstreams keep the 300 s default.
|
|
9
|
+
*
|
|
10
|
+
* The destination is the provider's `baseUrl` (or the built request URL) — where the proxy
|
|
11
|
+
* actually dials upstream. This is a different question from
|
|
12
|
+
* `src/lib/local-destinations.ts`, which answers where local clients dial *this hub*; do not
|
|
13
|
+
* conflate them.
|
|
14
|
+
*
|
|
15
|
+
* Conservative on purpose: only clear local evidence (loopback, private, link-local, or a
|
|
16
|
+
* `.local`/`.lan` name) earns the disabled budget. Anything else — a public DNS name, an
|
|
17
|
+
* unreachable host, a bare domain — stays public and keeps its stall budget, because being local
|
|
18
|
+
* is what switches OFF a safety clock.
|
|
19
|
+
*/
|
|
20
|
+
import { isIP } from "node:net";
|
|
21
|
+
|
|
22
|
+
/** True when the provider destination is local infrastructure and its stall budget should default to disabled. */
|
|
23
|
+
export function isLocalUpstream(destination: string | null | undefined): boolean {
|
|
24
|
+
if (!destination) return false;
|
|
25
|
+
// A bare IP literal must be classified before URL parsing: `new URL("fe80::1")` reads the first
|
|
26
|
+
// hextet as a scheme and yields an empty host, and `new URL("::1")` throws — either way the host
|
|
27
|
+
// would be lost. Zone ids (`fe80::1%eth0`) are stripped for the family check.
|
|
28
|
+
const trimmed = destination.trim();
|
|
29
|
+
const literalFamily = isIP(trimmed.split("%")[0]);
|
|
30
|
+
if (literalFamily === 4) return isLocalIpv4(trimmed);
|
|
31
|
+
if (literalFamily === 6) return isLocalIpv6(trimmed.split("%")[0]);
|
|
32
|
+
|
|
33
|
+
const host = hostnameOf(destination);
|
|
34
|
+
if (!host) return false;
|
|
35
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
36
|
+
const normalized = bare.toLowerCase().split("%")[0].replace(/\.$/, "");
|
|
37
|
+
if (normalized === "") return false;
|
|
38
|
+
if (normalized === "localhost") return true;
|
|
39
|
+
if (normalized.endsWith(".local") || normalized.endsWith(".lan")) return true;
|
|
40
|
+
const family = isIP(normalized);
|
|
41
|
+
if (family === 4) return isLocalIpv4(normalized);
|
|
42
|
+
if (family === 6) return isLocalIpv6(normalized);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract a hostname from a full URL or a schemeless `host:port` / bare host.
|
|
48
|
+
*
|
|
49
|
+
* `new URL` handles the scheme-bearing forms (it keeps IPv6 brackets in `hostname`); the fallback
|
|
50
|
+
* recovers the host from a schemeless string by dropping a port or bracketed IPv6 suffix.
|
|
51
|
+
*/
|
|
52
|
+
function hostnameOf(destination: string): string | null {
|
|
53
|
+
const trimmed = destination.trim();
|
|
54
|
+
if (!trimmed) return null;
|
|
55
|
+
// Only trust `new URL` when it produces a non-empty host. `new URL("localhost:1234")` succeeds
|
|
56
|
+
// but reads `localhost` as a scheme and leaves the host empty, so fall through to the schemeless
|
|
57
|
+
// parse in that case rather than returning null.
|
|
58
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(trimmed).hostname;
|
|
61
|
+
if (parsed) return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
// Not an absolute URL after all: fall through to the schemeless forms.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let host = trimmed;
|
|
67
|
+
if (host.startsWith("[")) {
|
|
68
|
+
const end = host.indexOf("]");
|
|
69
|
+
host = end > 0 ? host.slice(1, end) : host.slice(1);
|
|
70
|
+
} else {
|
|
71
|
+
const colon = host.indexOf(":");
|
|
72
|
+
if (colon >= 0) host = host.slice(0, colon);
|
|
73
|
+
}
|
|
74
|
+
return host || null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 0.0.0.0/8, 10/8, 127/8 (loopback), 169.254/16 (link-local), 172.16/12, 192.168/16. */
|
|
78
|
+
function isLocalIpv4(host: string): boolean {
|
|
79
|
+
const parts = host.split(".");
|
|
80
|
+
if (parts.length !== 4) return false;
|
|
81
|
+
const octets: number[] = [];
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
if (!/^\d{1,3}$/.test(part)) return false;
|
|
84
|
+
const value = Number(part);
|
|
85
|
+
if (value > 255) return false;
|
|
86
|
+
octets.push(value);
|
|
87
|
+
}
|
|
88
|
+
const [a, b] = octets;
|
|
89
|
+
if (a === 0) return true;
|
|
90
|
+
if (a === 10) return true;
|
|
91
|
+
if (a === 127) return true;
|
|
92
|
+
if (a === 169 && b === 254) return true;
|
|
93
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
94
|
+
if (a === 192 && b === 168) return true;
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** ::1 (loopback), `::ffff:a.b.c.d` (mapped), fe80::/10 (link-local), fc00::/7 (unique-local). */
|
|
99
|
+
function isLocalIpv6(host: string): boolean {
|
|
100
|
+
const lower = host.toLowerCase();
|
|
101
|
+
const groups = ipv6Groups(lower);
|
|
102
|
+
if (!groups) return false;
|
|
103
|
+
// Loopback in any spelling: every group zero except a trailing one.
|
|
104
|
+
if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true;
|
|
105
|
+
const mapped = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
106
|
+
if (mapped) return isLocalIpv4(mapped[1]);
|
|
107
|
+
const first = groups[0];
|
|
108
|
+
if ((first & 0xffc0) === 0xfe80) return true;
|
|
109
|
+
if ((first & 0xfe00) === 0xfc00) return true;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Expand a (possibly `::`-compressed) IPv6 literal into eight 16-bit groups, or null if malformed. */
|
|
114
|
+
function ipv6Groups(host: string): number[] | null {
|
|
115
|
+
const marker = host.indexOf("::");
|
|
116
|
+
const compressed = marker >= 0;
|
|
117
|
+
const head = compressed ? host.slice(0, marker) : host;
|
|
118
|
+
const tail = compressed ? host.slice(marker + 2) : "";
|
|
119
|
+
const headGroups = head === "" ? [] : head.split(":");
|
|
120
|
+
let tailGroups = tail === "" ? [] : tail.split(":");
|
|
121
|
+
// A trailing embedded IPv4 (e.g. `64:ff9b::1.2.3.4`) counts as two groups.
|
|
122
|
+
const last = tailGroups[tailGroups.length - 1];
|
|
123
|
+
if (last !== undefined && last.includes(".")) {
|
|
124
|
+
const v4 = last.split(".");
|
|
125
|
+
if (v4.length === 4 && v4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) {
|
|
126
|
+
const [o0, o1, o2, o3] = v4.map(Number);
|
|
127
|
+
tailGroups = [...tailGroups.slice(0, -1), ((o0 << 8) | o1).toString(16), ((o2 << 8) | o3).toString(16)];
|
|
128
|
+
} else return null;
|
|
129
|
+
}
|
|
130
|
+
let groups: string[];
|
|
131
|
+
if (compressed) {
|
|
132
|
+
// `::` fills the gap *between* head and tail with zero groups, not at either end.
|
|
133
|
+
const fill = 8 - headGroups.length - tailGroups.length;
|
|
134
|
+
if (fill < 1) return null;
|
|
135
|
+
groups = [...headGroups, ...Array<string>(fill).fill("0"), ...tailGroups];
|
|
136
|
+
} else {
|
|
137
|
+
groups = [...headGroups, ...tailGroups];
|
|
138
|
+
if (groups.length !== 8) return null;
|
|
139
|
+
}
|
|
140
|
+
const numbers = groups.map((group) => (isHextet(group) ? parseInt(group, 16) : Number.NaN));
|
|
141
|
+
return numbers.some((group) => Number.isNaN(group)) ? null : numbers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isHextet(token: string): boolean {
|
|
145
|
+
return /^[0-9a-f]{1,4}$/i.test(token);
|
|
146
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
2
4
|
import { isStandaloneBinary } from "./standalone";
|
|
3
5
|
|
|
4
6
|
export interface PackageTreeObservation {
|
|
@@ -63,9 +65,19 @@ const packageManifestUrl = new URL("../../package.json", import.meta.url);
|
|
|
63
65
|
|
|
64
66
|
const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
65
67
|
|
|
68
|
+
/** Directory of the package this process was loaded from, as the module loader spells it. */
|
|
69
|
+
export function runningPackageRoot(): string {
|
|
70
|
+
return dirname(fileURLToPath(packageManifestUrl));
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
function readInstalledManifestVersion(): string | undefined {
|
|
74
|
+
return readPackageManifestVersionAt(fileURLToPath(packageManifestUrl));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The `version` of the manifest at `manifestPath`, or undefined when unreadable or malformed. */
|
|
78
|
+
export function readPackageManifestVersionAt(manifestPath: string): string | undefined {
|
|
67
79
|
try {
|
|
68
|
-
const version = (JSON.parse(readFileSync(
|
|
80
|
+
const version = (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: unknown }).version;
|
|
69
81
|
return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version)
|
|
70
82
|
? version
|
|
71
83
|
: undefined;
|
|
@@ -75,8 +87,13 @@ function readInstalledManifestVersion(): string | undefined {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
function observePackageManifest(): PackageTreeObservation | null {
|
|
90
|
+
return observePackageManifestAt(fileURLToPath(packageManifestUrl));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Identity of the manifest at `manifestPath`, or null when it cannot be stat'd. */
|
|
94
|
+
export function observePackageManifestAt(manifestPath: string): PackageTreeObservation | null {
|
|
78
95
|
try {
|
|
79
|
-
const stat = statSync(
|
|
96
|
+
const stat = statSync(manifestPath, { bigint: true });
|
|
80
97
|
return {
|
|
81
98
|
device: stat.dev,
|
|
82
99
|
inode: stat.ino,
|
|
@@ -99,7 +116,12 @@ function observePackageManifest(): PackageTreeObservation | null {
|
|
|
99
116
|
}
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
|
|
119
|
+
/** The manifest path inside a package root. */
|
|
120
|
+
export function packageManifestPathIn(root: string): string {
|
|
121
|
+
return join(root, "package.json");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean {
|
|
103
125
|
return left.device === right.device
|
|
104
126
|
&& left.inode === right.inode
|
|
105
127
|
&& left.contentTimeNs === right.contentTimeNs
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
packageManifestPathIn,
|
|
3
|
+
readPackageManifestVersionAt,
|
|
4
|
+
sameObservation,
|
|
5
|
+
type PackageTreeObservation,
|
|
6
|
+
} from "./package-tree-integrity";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The package a service launcher would start right now: its canonical root and the identity of
|
|
10
|
+
* the manifest inside it. Null when the launcher, its target or the manifest cannot be resolved.
|
|
11
|
+
*/
|
|
12
|
+
export interface LauncherTarget {
|
|
13
|
+
readonly root: string;
|
|
14
|
+
readonly manifest: PackageTreeObservation;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ResolveLauncherTarget = () => LauncherTarget | null;
|
|
18
|
+
|
|
19
|
+
export interface PackageTreeRetargetOptions {
|
|
20
|
+
/** How often the launcher is re-resolved. Detection must not depend on inbound requests. */
|
|
21
|
+
pollIntervalMs?: number;
|
|
22
|
+
/** How long one complete target identity must hold before the restart is requested. */
|
|
23
|
+
settleMs?: number;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
/** Test seam; production uses an unref'd timer and returns its cancellation. */
|
|
26
|
+
schedule?: (callback: () => void, delayMs: number) => () => void;
|
|
27
|
+
/** Test seam for the settled target's version; production reads its package manifest. */
|
|
28
|
+
readVersion?: (root: string) => string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PackageTreeRetargetWatch {
|
|
32
|
+
/**
|
|
33
|
+
* Version of the package the launcher now starts, once that target has held for the full
|
|
34
|
+
* settle interval and still resolves to the same identity. A fenced `/healthz` reports it
|
|
35
|
+
* when the running tree itself has gone, so `ocx restart` compares against what the
|
|
36
|
+
* supervisor will actually start.
|
|
37
|
+
*/
|
|
38
|
+
settledVersion(): string | undefined;
|
|
39
|
+
/** Stops polling and invalidates any pending restart request. */
|
|
40
|
+
dispose(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
44
|
+
const DEFAULT_SETTLE_MS = 5_000;
|
|
45
|
+
|
|
46
|
+
function sameTarget(left: LauncherTarget, right: LauncherTarget): boolean {
|
|
47
|
+
return left.root === right.root && sameObservation(left.manifest, right.manifest);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Watches a version-manager launcher whose target can move to a different package tree while the
|
|
52
|
+
* running tree stays intact.
|
|
53
|
+
*
|
|
54
|
+
* The package-tree integrity fence only sees its own manifest. A manager that installs every
|
|
55
|
+
* version into its own directory and then repoints a floating link (mise's `latest`) never
|
|
56
|
+
* touches that manifest, so the fence neither restarts onto the new version nor recovers when
|
|
57
|
+
* the old version is later pruned from under it. This watch compares what the launcher resolves
|
|
58
|
+
* to with the running root instead.
|
|
59
|
+
*
|
|
60
|
+
* `onRetargeted` runs once, after one complete target identity (canonical root plus manifest
|
|
61
|
+
* identity) has held for `settleMs`. Any change of either part, an unresolvable launcher, or a
|
|
62
|
+
* return to the running root restarts the wait. A throwing handler is retried after another full
|
|
63
|
+
* interval. After the supervisor restarts through the launcher, the new process's running root
|
|
64
|
+
* is the target, so it never requests a second restart.
|
|
65
|
+
*/
|
|
66
|
+
export function createPackageTreeRetargetWatch(
|
|
67
|
+
runningRoot: string,
|
|
68
|
+
resolveTarget: ResolveLauncherTarget,
|
|
69
|
+
onRetargeted: () => void,
|
|
70
|
+
options: PackageTreeRetargetOptions = {},
|
|
71
|
+
): PackageTreeRetargetWatch {
|
|
72
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
73
|
+
const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
74
|
+
const now = options.now ?? Date.now;
|
|
75
|
+
const readVersion = options.readVersion ?? (root => readPackageManifestVersionAt(packageManifestPathIn(root)));
|
|
76
|
+
const schedule = options.schedule ?? ((callback, delayMs) => {
|
|
77
|
+
const timer = setTimeout(callback, delayMs);
|
|
78
|
+
timer.unref?.();
|
|
79
|
+
return () => clearTimeout(timer);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let stopped = false;
|
|
83
|
+
let cancelPoll: (() => void) | null = null;
|
|
84
|
+
let candidate: LauncherTarget | null = null;
|
|
85
|
+
let candidateSince = 0;
|
|
86
|
+
let settled: LauncherTarget | null = null;
|
|
87
|
+
|
|
88
|
+
const poll = (): void => {
|
|
89
|
+
cancelPoll = null;
|
|
90
|
+
if (stopped) return;
|
|
91
|
+
const target = resolveTarget();
|
|
92
|
+
if (target === null || target.root === runningRoot) {
|
|
93
|
+
candidate = null;
|
|
94
|
+
settled = null;
|
|
95
|
+
} else if (candidate === null || !sameTarget(candidate, target)) {
|
|
96
|
+
candidate = target;
|
|
97
|
+
candidateSince = now();
|
|
98
|
+
settled = null;
|
|
99
|
+
} else if (now() - candidateSince >= settleMs) {
|
|
100
|
+
settled = target;
|
|
101
|
+
try {
|
|
102
|
+
onRetargeted();
|
|
103
|
+
stopped = true;
|
|
104
|
+
return;
|
|
105
|
+
} catch {
|
|
106
|
+
// Restart admission refused (for example, the service home changed owner). Keep the
|
|
107
|
+
// settled identity and ask again only after another full interval.
|
|
108
|
+
candidateSince = now();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
cancelPoll = schedule(poll, pollIntervalMs);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
cancelPoll = schedule(poll, pollIntervalMs);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
settledVersion: () => {
|
|
118
|
+
if (settled === null) return undefined;
|
|
119
|
+
const current = resolveTarget();
|
|
120
|
+
if (current === null || !sameTarget(settled, current)) return undefined;
|
|
121
|
+
return readVersion(settled.root);
|
|
122
|
+
},
|
|
123
|
+
dispose: () => {
|
|
124
|
+
stopped = true;
|
|
125
|
+
const cancel = cancelPoll;
|
|
126
|
+
cancelPoll = null;
|
|
127
|
+
cancel?.();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|