@bitkyc08/opencodex 2.90.0 → 2.92.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 +37 -3
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +92 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +47 -8
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +53 -3
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +123 -35
- package/src/cli/provider.ts +46 -3
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +84 -9
- package/src/client/link-relay.ts +98 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +103 -3
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +155 -25
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/resolve.ts +12 -0
- package/src/combos/types.ts +19 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +375 -195
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/diagnostics.ts +193 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +63 -2
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +16 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +6 -2
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/mistral-transport.ts +37 -4
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +27 -22
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +7 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +22 -2
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +20 -7
- package/src/server/index/optional-listeners.ts +11 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +16 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +12 -4
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +125 -32
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +19 -6
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/definition.ts +20 -8
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +13 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/index-TmaHa54u.js +0 -94
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
package/src/client/connect.ts
CHANGED
|
@@ -78,9 +78,14 @@ import { overlayHomeCodexSettings } from "../link/codex-settings";
|
|
|
78
78
|
import {
|
|
79
79
|
clearHomeCodexSettingsRecord,
|
|
80
80
|
fetchHomeCodexSettings,
|
|
81
|
+
homeCodexSettingsReceipt,
|
|
82
|
+
readHomeCodexSettingsRecord,
|
|
81
83
|
resolveHomeCodexSettings,
|
|
82
84
|
recordHomeCodexSettings,
|
|
85
|
+
withLinkApply,
|
|
86
|
+
withdrawChangingHomeCodexSettingsRecord,
|
|
83
87
|
writeHomeCodexSettingsRecord,
|
|
88
|
+
type HomeCodexSettingsRecord,
|
|
84
89
|
type HomeCodexSettingsSource,
|
|
85
90
|
type LinkHomeDeps,
|
|
86
91
|
} from "./link-home";
|
|
@@ -122,6 +127,8 @@ export interface ClientConnectDeps {
|
|
|
122
127
|
catalogCompatibility?: CatalogCompatibilityDeps;
|
|
123
128
|
/** Link mode: how the Home's Codex settings are read (#6957). */
|
|
124
129
|
linkHome?: LinkHomeDeps;
|
|
130
|
+
/** Link mode: the apply lock's path (OCX-6); defaults to `<configDir>/link/apply-lock.sqlite`. */
|
|
131
|
+
linkApplyLockPath?: string;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
export interface RotateClientOptions {
|
|
@@ -549,9 +556,22 @@ function assertConnectingState(expectedTokenFingerprint?: string): void {
|
|
|
549
556
|
}
|
|
550
557
|
|
|
551
558
|
/** Enroll a client key, keeping its pending ownership visible until commit or rollback. */
|
|
559
|
+
/**
|
|
560
|
+
* Connect this machine to a hub or, with `transport: "link"`, to a Remote Link Home. A link
|
|
561
|
+
* connect holds the apply lock throughout (OCX-6), because it applies the Home's Codex settings
|
|
562
|
+
* and records that apply exactly as `ocx sync` does.
|
|
563
|
+
*/
|
|
552
564
|
export async function connectClient(
|
|
553
565
|
options: ConnectOptions,
|
|
554
566
|
deps: ClientConnectDeps = {},
|
|
567
|
+
): Promise<OcxClientConnectionConfig> {
|
|
568
|
+
if ((options.transport ?? "hub") !== "link") return connectClientUnlocked(options, deps);
|
|
569
|
+
return withLinkApply(() => connectClientUnlocked(options, deps), deps.linkApplyLockPath);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function connectClientUnlocked(
|
|
573
|
+
options: ConnectOptions,
|
|
574
|
+
deps: ClientConnectDeps = {},
|
|
555
575
|
): Promise<OcxClientConnectionConfig> {
|
|
556
576
|
deps.signal?.throwIfAborted();
|
|
557
577
|
const rawFetch = deps.fetchImpl ?? fetch;
|
|
@@ -755,11 +775,17 @@ export async function connectClient(
|
|
|
755
775
|
commitClientConnection(connection);
|
|
756
776
|
committed = true;
|
|
757
777
|
}), deps.lifecycleLockDeps);
|
|
758
|
-
// Recorded only when the injection that applied it ran (not with --no-sync).
|
|
778
|
+
// Recorded only when the injection that applied it ran (not with --no-sync). The connection is
|
|
779
|
+
// already committed, so a failed record write must not reach the rollback below: it is a
|
|
780
|
+
// warning, and `ocx status` reports no recorded apply until `ocx sync` writes one.
|
|
759
781
|
if (homeSettings?.kind === "fresh" && linkMetadata && injectionCommitted) {
|
|
760
782
|
try {
|
|
761
|
-
writeHomeCodexSettingsRecord(
|
|
762
|
-
|
|
783
|
+
writeHomeCodexSettingsRecord(homeCodexSettingsReceipt(
|
|
784
|
+
linkMetadata.linkId, homeSettings.document, now, (deps.now ?? (() => new Date()))().toISOString(), null,
|
|
785
|
+
));
|
|
786
|
+
} catch {
|
|
787
|
+
console.warn("Warning: the Home's Codex settings were applied, but the apply record was not written; run 'ocx sync' to record it.");
|
|
788
|
+
}
|
|
763
789
|
}
|
|
764
790
|
return connection;
|
|
765
791
|
} catch (error) {
|
|
@@ -804,10 +830,34 @@ export async function connectClient(
|
|
|
804
830
|
}
|
|
805
831
|
}
|
|
806
832
|
|
|
833
|
+
export interface ConnectedSyncResult {
|
|
834
|
+
catalogWritten: boolean;
|
|
835
|
+
cacheSynced: boolean;
|
|
836
|
+
injected: boolean;
|
|
837
|
+
stale: boolean;
|
|
838
|
+
homeSettings?: HomeCodexSettingsSource;
|
|
839
|
+
/** The apply record this sync committed or kept (link Child with Home settings only). */
|
|
840
|
+
appliedRecord?: HomeCodexSettingsRecord;
|
|
841
|
+
/** The Home's settings were injected but their record could not be written (OCX-6). */
|
|
842
|
+
applyRecordFailed?: true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Refresh the catalog and Codex config from the hub or Home. Every sync holds the apply lock
|
|
847
|
+
* (OCX-6), so a sync that finds a link connection inside, whatever an earlier unlocked read said,
|
|
848
|
+
* never interleaves with another apply, and its record names what its own injection wrote.
|
|
849
|
+
*/
|
|
807
850
|
export async function syncConnectedClient(
|
|
851
|
+
options: { restartCodex?: boolean } = {},
|
|
852
|
+
deps: ClientConnectDeps = {},
|
|
853
|
+
): Promise<ConnectedSyncResult> {
|
|
854
|
+
return withLinkApply(() => syncConnectedClientUnlocked(options, deps), deps.linkApplyLockPath);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function syncConnectedClientUnlocked(
|
|
808
858
|
_options: { restartCodex?: boolean } = {},
|
|
809
859
|
deps: ClientConnectDeps = {},
|
|
810
|
-
): Promise<
|
|
860
|
+
): Promise<ConnectedSyncResult> {
|
|
811
861
|
if (siblingOfLivePort() !== null) throw new Error(siblingSkipMessage());
|
|
812
862
|
const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
813
863
|
assertNoClientDisconnectPending();
|
|
@@ -853,12 +903,18 @@ export async function syncConnectedClient(
|
|
|
853
903
|
};
|
|
854
904
|
let injected = false;
|
|
855
905
|
let homeSettings: HomeCodexSettingsSource | undefined;
|
|
906
|
+
let appliedRecord: HomeCodexSettingsRecord | undefined;
|
|
907
|
+
let applyRecordFailed = false;
|
|
856
908
|
if (next.selectedClients.includes("codex")) {
|
|
857
909
|
let config = loadConfig();
|
|
858
910
|
// #6957: a link Child applies the Home's OpenCodex-owned Codex settings with the catalog.
|
|
859
911
|
if (isLinkConnection(next)) {
|
|
860
912
|
homeSettings = await resolveHomeCodexSettings(next, initial.token.token, deps.linkHome);
|
|
861
913
|
if (homeSettings.source !== "local") config = overlayHomeCodexSettings(config, homeSettings.document.settings);
|
|
914
|
+
// About to write different settings: until their record commits, report no applied digest
|
|
915
|
+
// rather than the previous one (OCX-6). A failed injection then reads "unknown", never a
|
|
916
|
+
// digest of values Codex no longer has or never got.
|
|
917
|
+
if (homeSettings.source === "home" && next.link) withdrawChangingHomeCodexSettingsRecord(next.link.linkId, homeSettings.document);
|
|
862
918
|
}
|
|
863
919
|
const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
|
|
864
920
|
routingTarget: routingTarget(next.serverUrl, isLinkConnection(next) ? config.port : undefined, config), catalogPath: DEFAULT_CATALOG_PATH,
|
|
@@ -866,14 +922,34 @@ export async function syncConnectedClient(
|
|
|
866
922
|
});
|
|
867
923
|
if (!result.success || result.status === "skipped") throw new Error(result.message);
|
|
868
924
|
injected = true;
|
|
869
|
-
if (homeSettings && next.link)
|
|
925
|
+
if (homeSettings && next.link) {
|
|
926
|
+
const linkId = next.link.linkId;
|
|
927
|
+
const settings = homeSettings;
|
|
928
|
+
try {
|
|
929
|
+
appliedRecord = withClientLifecycleSync(() => {
|
|
930
|
+
beforeClientWrite();
|
|
931
|
+
return recordHomeCodexSettings(linkId, settings, deps.now) ?? readHomeCodexSettingsRecord(linkId) ?? undefined;
|
|
932
|
+
}, deps.lifecycleLockDeps);
|
|
933
|
+
} catch (error) {
|
|
934
|
+
// An ownership change is not a disk problem: report it as itself.
|
|
935
|
+
if (error instanceof Error && (error.message === "client_token_changed" || error.message === "client_connection_changed")) throw error;
|
|
936
|
+
// The injection committed, so the cache refresh below still runs; the caller reports this.
|
|
937
|
+
try { clearHomeCodexSettingsRecord(); } catch { /* status already reads no recorded apply or reports the write */ }
|
|
938
|
+
applyRecordFailed = true;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
870
941
|
}
|
|
871
942
|
const cacheSynced = withClientLifecycleSync(() => {
|
|
872
943
|
beforeClientWrite();
|
|
873
944
|
// Cache invalidation acquires K itself (N -> K -> C); never call it while C is held.
|
|
874
945
|
return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
|
|
875
946
|
}, deps.lifecycleLockDeps);
|
|
876
|
-
return {
|
|
947
|
+
return {
|
|
948
|
+
catalogWritten: downloaded !== undefined, cacheSynced, injected, stale,
|
|
949
|
+
...(homeSettings ? { homeSettings } : {}),
|
|
950
|
+
...(appliedRecord ? { appliedRecord } : {}),
|
|
951
|
+
...(applyRecordFailed ? { applyRecordFailed } : {}),
|
|
952
|
+
};
|
|
877
953
|
}
|
|
878
954
|
|
|
879
955
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCX-11: the stable answer a Remote Link Child or Hub client gives when its Home cannot be
|
|
3
|
+
* reached. The relay keeps its status, `error` string and retry semantics; `code` and
|
|
4
|
+
* `message` are added so callers (Codex, GOAT) read one contract. The client runtime has no
|
|
5
|
+
* provider dispatch, so nothing is ever served from this device's local providers. Whether GOAT
|
|
6
|
+
* may move the work to them is the Home's per-device `fallback.devices.<id>.localFallback`.
|
|
7
|
+
*/
|
|
8
|
+
export const HOME_UNREACHABLE_CODE = "home_unreachable";
|
|
9
|
+
|
|
10
|
+
export const HOME_UNREACHABLE_MESSAGE = "The Home is unreachable, and OpenCodex did not serve this request "
|
|
11
|
+
+ "from this device's local providers. Retry once the Home is back. Allowing local fallback for this "
|
|
12
|
+
+ "device is set on the Home: ocx fallback local on --device <id>.";
|
|
13
|
+
|
|
14
|
+
export function homeUnreachableBody(error: string): { error: string; code: string; message: string } {
|
|
15
|
+
return { error, code: HOME_UNREACHABLE_CODE, message: HOME_UNREACHABLE_MESSAGE };
|
|
16
|
+
}
|
package/src/client/hub-relay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stripMachineAuthHeaders } from "./machine-auth";
|
|
2
|
+
import { homeUnreachableBody } from "./home-unreachable";
|
|
2
3
|
|
|
3
4
|
export interface HubRelayTarget {
|
|
4
5
|
managementUrl: string;
|
|
@@ -73,6 +74,11 @@ function relayError(status: number, error: string): Response {
|
|
|
73
74
|
return Response.json({ error }, { status });
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/** The Hub did not answer (OCX-11 `home_unreachable`); the 502 status is kept. */
|
|
78
|
+
function hubUnreachable(): Response {
|
|
79
|
+
return Response.json(homeUnreachableBody("hub relay unavailable"), { status: 502 });
|
|
80
|
+
}
|
|
81
|
+
|
|
76
82
|
function canonicalOrigin(value: string): string | null {
|
|
77
83
|
try {
|
|
78
84
|
const url = new URL(value);
|
|
@@ -275,12 +281,12 @@ export async function relayHubManagementRequest(
|
|
|
275
281
|
});
|
|
276
282
|
} catch {
|
|
277
283
|
cleanup();
|
|
278
|
-
return
|
|
284
|
+
return hubUnreachable();
|
|
279
285
|
}
|
|
280
286
|
if (signal.aborted) {
|
|
281
287
|
cleanup();
|
|
282
288
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
283
|
-
return
|
|
289
|
+
return hubUnreachable();
|
|
284
290
|
}
|
|
285
291
|
if (upstream.status >= 300 && upstream.status < 400) {
|
|
286
292
|
cleanup();
|
package/src/client/link-home.ts
CHANGED
|
@@ -13,9 +13,11 @@ import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
|
13
13
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
14
14
|
import { hardenSecretDir } from "../lib/windows-secret-acl";
|
|
15
15
|
import {
|
|
16
|
+
codexSettingsDigest,
|
|
16
17
|
parseHomeCodexSettingsDocument,
|
|
17
18
|
type HomeCodexSettingsDocument,
|
|
18
19
|
} from "../link/codex-settings";
|
|
20
|
+
import { LINK_CHILD_PARITY_PATH, type LinkChildParityReport } from "../link/child-parity";
|
|
19
21
|
import {
|
|
20
22
|
LINK_CODEX_SETTINGS_PATH,
|
|
21
23
|
LINK_MANAGEMENT_PREFIX,
|
|
@@ -23,10 +25,17 @@ import {
|
|
|
23
25
|
} from "../link/management-routes";
|
|
24
26
|
import { linkDir } from "../link/paths";
|
|
25
27
|
import type { OcxClientConnectionConfig } from "../types";
|
|
28
|
+
import { withClientLifecycle } from "./lifecycle-lock";
|
|
26
29
|
import { fetchBoundLinkRelay } from "./link-relay-transport";
|
|
27
30
|
import { readClientLinkState, type ClientLinkState } from "./link-state";
|
|
28
31
|
|
|
29
32
|
const RECORD_MAX_BYTES = 64 * 1024;
|
|
33
|
+
|
|
34
|
+
function isIsoTimestamp(value: unknown): value is string {
|
|
35
|
+
if (typeof value !== "string" || value.length !== 24) return false;
|
|
36
|
+
const parsed = Date.parse(value);
|
|
37
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
38
|
+
}
|
|
30
39
|
const SETTINGS_MAX_BYTES = 64 * 1024;
|
|
31
40
|
const READ_TIMEOUT_MS = 10_000;
|
|
32
41
|
|
|
@@ -42,6 +51,11 @@ export type LinkHomeConnection = Pick<OcxClientConnectionConfig, "transport" | "
|
|
|
42
51
|
export interface HomeCodexSettingsRecord {
|
|
43
52
|
linkId: string;
|
|
44
53
|
fetchedAt: string;
|
|
54
|
+
/**
|
|
55
|
+
* When this Child first applied these settings (OCX-6, P5). A re-apply of the same digest for
|
|
56
|
+
* the same link keeps it, so an idempotent `ocx sync` never makes a fresh runtime look stale.
|
|
57
|
+
*/
|
|
58
|
+
appliedAt: string;
|
|
45
59
|
document: HomeCodexSettingsDocument;
|
|
46
60
|
}
|
|
47
61
|
|
|
@@ -69,7 +83,9 @@ export function readHomeCodexSettingsRecord(linkId: string, path = homeCodexSett
|
|
|
69
83
|
const record = raw as Record<string, unknown>;
|
|
70
84
|
if (record.linkId !== linkId || typeof record.fetchedAt !== "string" || Number.isNaN(Date.parse(record.fetchedAt))) return null;
|
|
71
85
|
const document = parseHomeCodexSettingsDocument(record.document);
|
|
72
|
-
|
|
86
|
+
// Records written before OCX-6 carry no apply time; their read time is the closest one.
|
|
87
|
+
const appliedAt = isIsoTimestamp(record.appliedAt) ? record.appliedAt : record.fetchedAt;
|
|
88
|
+
return document ? { linkId, fetchedAt: record.fetchedAt, appliedAt, document } : null;
|
|
73
89
|
} catch {
|
|
74
90
|
return null;
|
|
75
91
|
}
|
|
@@ -89,6 +105,59 @@ export function clearHomeCodexSettingsRecord(path = homeCodexSettingsPath()): vo
|
|
|
89
105
|
try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
|
|
90
106
|
}
|
|
91
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The record an apply of `document` produces: the apply time is `appliedAt` unless the previous
|
|
110
|
+
* record is for the same link and the same settings digest, in which case it is kept.
|
|
111
|
+
*/
|
|
112
|
+
export function homeCodexSettingsReceipt(
|
|
113
|
+
linkId: string,
|
|
114
|
+
document: HomeCodexSettingsDocument,
|
|
115
|
+
fetchedAt: string,
|
|
116
|
+
appliedAt: string,
|
|
117
|
+
previous: HomeCodexSettingsRecord | null = readHomeCodexSettingsRecord(linkId),
|
|
118
|
+
): HomeCodexSettingsRecord {
|
|
119
|
+
const unchanged = previous !== null && previous.linkId === linkId
|
|
120
|
+
&& codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings);
|
|
121
|
+
return { linkId, fetchedAt, appliedAt: unchanged ? previous.appliedAt : appliedAt, document };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function linkApplyLockPath(): string {
|
|
125
|
+
return join(linkDir(), "apply-lock.sqlite");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Before an apply writes settings that differ from the recorded ones, withdraw the record, so a
|
|
130
|
+
* failed injection or record write reads as "no recorded apply" instead of the previous digest
|
|
131
|
+
* (OCX-6). Re-applying the same settings keeps it. Returns whether the record was withdrawn.
|
|
132
|
+
*/
|
|
133
|
+
export function withdrawChangingHomeCodexSettingsRecord(linkId: string, document: HomeCodexSettingsDocument): boolean {
|
|
134
|
+
const previous = readHomeCodexSettingsRecord(linkId);
|
|
135
|
+
if (!previous || codexSettingsDigest(previous.document.settings) === codexSettingsDigest(document.settings)) return false;
|
|
136
|
+
clearHomeCodexSettingsRecord();
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Serialize applies of the Home's settings on this Child, from the Home read through the Codex
|
|
142
|
+
* injection to the record write, so a receipt always names the settings the injection wrote.
|
|
143
|
+
* Outermost in the client lock order (before N, L and C); a second apply fails fast.
|
|
144
|
+
*/
|
|
145
|
+
export async function withLinkApply<T>(work: () => Promise<T>, lockPath: string = linkApplyLockPath()): Promise<T> {
|
|
146
|
+
const dir = linkDir();
|
|
147
|
+
assertNotRealHomeUnderTest(join(dir, ".."));
|
|
148
|
+
// Only a refusal of the apply lock itself means "another apply"; an error from inside `work`
|
|
149
|
+
// (including a busy ordinary lifecycle lock) passes through as itself.
|
|
150
|
+
let entered = false;
|
|
151
|
+
try {
|
|
152
|
+
return await withClientLifecycle(() => { entered = true; return work(); }, { lockPath });
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!entered && error instanceof Error && error.message === "client_lifecycle_busy") {
|
|
155
|
+
throw new Error("another Home settings apply is in progress on this machine; retry when it finishes");
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
92
161
|
/**
|
|
93
162
|
* The Home's name as this Child knows it: the SSH alias of a Child-initiated link, else the
|
|
94
163
|
* host name the Home published with its Codex settings, else null ("its Home").
|
|
@@ -239,10 +308,54 @@ export async function resolveHomeCodexSettings(
|
|
|
239
308
|
: { source: "local", reason: fetched.reason };
|
|
240
309
|
}
|
|
241
310
|
|
|
242
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Record settings a successful injection applied and return the committed record; null when the
|
|
313
|
+
* source was not a fresh Home read. A write failure throws: the caller reports it, because
|
|
314
|
+
* `ocx status` and the Home read this record as the proof of what was applied.
|
|
315
|
+
*/
|
|
316
|
+
export function recordHomeCodexSettings(
|
|
317
|
+
linkId: string,
|
|
318
|
+
settings: HomeCodexSettingsSource,
|
|
319
|
+
now: () => Date = () => new Date(),
|
|
320
|
+
): HomeCodexSettingsRecord | null {
|
|
321
|
+
if (settings.source !== "home") return null;
|
|
322
|
+
const record = homeCodexSettingsReceipt(linkId, settings.document, settings.fetchedAt, now().toISOString());
|
|
323
|
+
writeHomeCodexSettingsRecord(record);
|
|
324
|
+
return record;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Tell the Home what this Child last applied and when its runtime started (OCX-6). Resolves to
|
|
329
|
+
* `"accepted"`, `"unsupported"` for a Home that predates the route, or `"failed"`.
|
|
330
|
+
*/
|
|
331
|
+
export async function sendLinkChildParityReport(
|
|
332
|
+
connection: LinkHomeConnection,
|
|
333
|
+
admissionKey: string,
|
|
334
|
+
report: LinkChildParityReport,
|
|
335
|
+
deps: LinkHomeDeps = {},
|
|
336
|
+
): Promise<"accepted" | "unsupported" | "failed"> {
|
|
337
|
+
let response: Response;
|
|
245
338
|
try {
|
|
246
|
-
|
|
247
|
-
|
|
339
|
+
const target = linkTarget(connection, admissionKey);
|
|
340
|
+
const destination = `http://127.0.0.1:${target.tunnelPort}${LINK_CHILD_PARITY_PATH}`;
|
|
341
|
+
const body = JSON.stringify(report);
|
|
342
|
+
const request: RequestInit = {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: {
|
|
345
|
+
accept: "application/json",
|
|
346
|
+
authorization: `Bearer ${admissionKey}`,
|
|
347
|
+
"content-type": "application/json",
|
|
348
|
+
"content-length": String(Buffer.byteLength(body)),
|
|
349
|
+
},
|
|
350
|
+
body,
|
|
351
|
+
redirect: "manual",
|
|
352
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? READ_TIMEOUT_MS),
|
|
353
|
+
};
|
|
354
|
+
response = deps.fetchImpl ? await deps.fetchImpl(destination, request) : await fetchBoundLinkRelay(target, destination, request);
|
|
355
|
+
} catch {
|
|
356
|
+
return "failed";
|
|
357
|
+
}
|
|
358
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
359
|
+
if (response.status === 404) return "unsupported";
|
|
360
|
+
return response.status === 204 ? "accepted" : "failed";
|
|
248
361
|
}
|
|
@@ -76,7 +76,7 @@ function liftRequestIdleTimer(req: Request, server: Pick<Server<unknown>, "timeo
|
|
|
76
76
|
* fetch, so a rebinding or cross-site browser page cannot reach the Home through this port.
|
|
77
77
|
* - Without the committed link key nothing is fetched, and the caller gets
|
|
78
78
|
* `503 link_credential_unavailable`.
|
|
79
|
-
* - `/readyz` stays local
|
|
79
|
+
* - `/readyz` stays local and is never relayed; the listener answers it from `link-readiness.ts`.
|
|
80
80
|
* - A relayed request is exempt from the listener's idle limit, like a standalone data route.
|
|
81
81
|
*/
|
|
82
82
|
export function handleLinkIngress(
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Child half of the OCX-6 report: tell the Home, over the link, when this Child last applied
|
|
3
|
+
* the Home's Codex settings, which digest it applied and when this runtime started.
|
|
4
|
+
*
|
|
5
|
+
* Runs in a linked client runtime. The first report goes out shortly after start (the tunnel may
|
|
6
|
+
* still be coming up, so failures back off: 2, 5, 15, 30 then 60 seconds), and then once a minute,
|
|
7
|
+
* so a Home that restarted and forgot the report learns it again within a minute. A Home that
|
|
8
|
+
* predates the route (404) is not asked again. Timers are unref'd and never keep the process alive.
|
|
9
|
+
*/
|
|
10
|
+
import { codexSettingsDigest } from "../link/codex-settings";
|
|
11
|
+
import type { LinkChildParityReport } from "../link/child-parity";
|
|
12
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
13
|
+
import { readHomeCodexSettingsRecord, sendLinkChildParityReport, type HomeCodexSettingsRecord } from "./link-home";
|
|
14
|
+
|
|
15
|
+
export const LINK_PARITY_RETRY_MS = [2_000, 5_000, 15_000, 30_000, 60_000] as const;
|
|
16
|
+
export const LINK_PARITY_PERIOD_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
/** Process-wide, so a reporter rebuilt by a restart rollback keeps counting up (see `sequence`). */
|
|
19
|
+
let reportSequence = 0;
|
|
20
|
+
function nextReportSequence(): number {
|
|
21
|
+
reportSequence += 1;
|
|
22
|
+
return reportSequence;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LinkParityReporter {
|
|
26
|
+
start(): void;
|
|
27
|
+
stop(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LinkParityReporterDeps {
|
|
31
|
+
connection: OcxClientConnectionConfig;
|
|
32
|
+
linkKey: () => string | null;
|
|
33
|
+
/** ISO start time of this runtime. */
|
|
34
|
+
runtimeStartedAt: string;
|
|
35
|
+
readRecord?: (linkId: string) => HomeCodexSettingsRecord | null;
|
|
36
|
+
send?: typeof sendLinkChildParityReport;
|
|
37
|
+
setTimer?: (callback: () => void, ms: number) => unknown;
|
|
38
|
+
clearTimer?: (timer: unknown) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The report for this Child's current record; null without link metadata. */
|
|
42
|
+
export function linkChildParityReport(
|
|
43
|
+
connection: OcxClientConnectionConfig,
|
|
44
|
+
runtimeStartedAt: string,
|
|
45
|
+
record: HomeCodexSettingsRecord | null,
|
|
46
|
+
sequence: number = nextReportSequence(),
|
|
47
|
+
): LinkChildParityReport | null {
|
|
48
|
+
const linkId = connection.link?.linkId;
|
|
49
|
+
const started = Date.parse(runtimeStartedAt);
|
|
50
|
+
if (!linkId || !Number.isFinite(started)) return null;
|
|
51
|
+
const applied = record ? Date.parse(record.appliedAt) : Number.NaN;
|
|
52
|
+
return Number.isFinite(applied)
|
|
53
|
+
? { linkId, lastApplyAt: applied, runtimeStartedAt: started, appliedCodexSettingsDigest: codexSettingsDigest(record!.document.settings), sequence }
|
|
54
|
+
: { linkId, lastApplyAt: null, runtimeStartedAt: started, appliedCodexSettingsDigest: null, sequence };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createLinkParityReporter(deps: LinkParityReporterDeps): LinkParityReporter {
|
|
58
|
+
const readRecord = deps.readRecord ?? readHomeCodexSettingsRecord;
|
|
59
|
+
const send = deps.send ?? sendLinkChildParityReport;
|
|
60
|
+
const setTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
|
|
61
|
+
const timer = setTimeout(callback, ms);
|
|
62
|
+
if (typeof timer === "object" && "unref" in timer) timer.unref();
|
|
63
|
+
return timer;
|
|
64
|
+
});
|
|
65
|
+
const clearTimer = deps.clearTimer ?? (timer => clearTimeout(timer as ReturnType<typeof setTimeout>));
|
|
66
|
+
let timer: unknown;
|
|
67
|
+
let stopped = true;
|
|
68
|
+
let failures = 0;
|
|
69
|
+
|
|
70
|
+
const schedule = (ms: number) => {
|
|
71
|
+
if (stopped) return;
|
|
72
|
+
timer = setTimer(() => { void tick(); }, ms);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tick = async (): Promise<void> => {
|
|
76
|
+
timer = undefined;
|
|
77
|
+
if (stopped) return;
|
|
78
|
+
const key = deps.linkKey();
|
|
79
|
+
const linkId = deps.connection.link?.linkId;
|
|
80
|
+
let record: HomeCodexSettingsRecord | null = null;
|
|
81
|
+
try { record = linkId ? readRecord(linkId) : null; } catch { record = null; }
|
|
82
|
+
const report = linkChildParityReport(deps.connection, deps.runtimeStartedAt, record);
|
|
83
|
+
const outcome = key && report ? await send(deps.connection, key, report).catch(() => "failed" as const) : "failed";
|
|
84
|
+
if (outcome === "unsupported") { stopped = true; return; }
|
|
85
|
+
if (outcome === "accepted") {
|
|
86
|
+
failures = 0;
|
|
87
|
+
schedule(LINK_PARITY_PERIOD_MS);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
failures = Math.min(failures + 1, LINK_PARITY_RETRY_MS.length - 1);
|
|
91
|
+
schedule(LINK_PARITY_RETRY_MS[failures]!);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
start() {
|
|
96
|
+
if (!stopped) return;
|
|
97
|
+
stopped = false;
|
|
98
|
+
failures = 0;
|
|
99
|
+
schedule(LINK_PARITY_RETRY_MS[0]);
|
|
100
|
+
},
|
|
101
|
+
stop() {
|
|
102
|
+
stopped = true;
|
|
103
|
+
if (timer !== undefined) clearTimer(timer);
|
|
104
|
+
timer = undefined;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a Remote Link Child can serve relayed requests right now, for its `GET /readyz`.
|
|
3
|
+
*
|
|
4
|
+
* `/healthz` keeps meaning "this process answers". A Child whose tunnel is down still answers it,
|
|
5
|
+
* yet every relayed turn fails with 503, so readiness is where that shows: not ready, with one
|
|
6
|
+
* fixed reason word. Nothing here carries ssh output, host names, ports or keys.
|
|
7
|
+
*
|
|
8
|
+
* - A Child-initiated link reads its own tunnel supervisor; no network traffic.
|
|
9
|
+
* - A Home-initiated link has no local tunnel to inspect (the Home runs `ssh -R`), so it runs
|
|
10
|
+
* the credential-free relay preflight through the tunnel port. One preflight runs at a time, a
|
|
11
|
+
* verdict is reused for `LINK_READINESS_TTL_MS`, and a request waits at most
|
|
12
|
+
* `LINK_READINESS_WAIT_MS` for a running preflight before it answers `link_unverified`. An
|
|
13
|
+
* unauthenticated caller can therefore cause at most one preflight per TTL.
|
|
14
|
+
*/
|
|
15
|
+
import { LINK_RELAY_AUTH_TIMEOUT_MS } from "../link/relay-auth";
|
|
16
|
+
import { isLinkPort } from "../link/ports";
|
|
17
|
+
import { probeLinkRelayPreflight, type BoundLinkRelayTarget, type LinkRelayPreflightResult } from "./link-relay-transport";
|
|
18
|
+
import type { ClientLinkSupervisorStatus } from "./link-tunnel";
|
|
19
|
+
|
|
20
|
+
export type LinkReadinessReason =
|
|
21
|
+
| "tunnel_idle" | "tunnel_connecting" | "tunnel_failed" | "tunnel_unavailable" | "sidecar_invalid"
|
|
22
|
+
| "home_unreachable" | "home_unauthorized" | "link_auth_failed" | "link_credential_unavailable"
|
|
23
|
+
| "link_unverified";
|
|
24
|
+
|
|
25
|
+
export type LinkReadiness = { ready: true } | { ready: false; reason: LinkReadinessReason };
|
|
26
|
+
|
|
27
|
+
export const LINK_READINESS_TTL_MS = 5_000;
|
|
28
|
+
export const LINK_READINESS_WAIT_MS = 500;
|
|
29
|
+
|
|
30
|
+
const READY: LinkReadiness = { ready: true };
|
|
31
|
+
const notReady = (reason: LinkReadinessReason): LinkReadiness => ({ ready: false, reason });
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A Child-initiated link from its supervisor. The keyed probe the supervisor runs every 30 seconds
|
|
35
|
+
* while connected is display-only for the tunnel, but a Home that stopped answering or refuses the
|
|
36
|
+
* key cannot serve a turn, so readiness follows it. A Home whose own startup readiness is pending
|
|
37
|
+
* still serves relayed requests (the probe's `home_not_ready`), so that reads ready.
|
|
38
|
+
*/
|
|
39
|
+
export function clientInitiatedLinkReadiness(status: ClientLinkSupervisorStatus): LinkReadiness {
|
|
40
|
+
if (status.kind === "failed") return notReady("sidecar_invalid");
|
|
41
|
+
if (status.kind === "stopped") return notReady("tunnel_idle");
|
|
42
|
+
const { state, probe } = status;
|
|
43
|
+
if (state.kind === "connected") {
|
|
44
|
+
if (probe === "home_unreachable") return notReady("home_unreachable");
|
|
45
|
+
if (probe === "unauthorized") return notReady("home_unauthorized");
|
|
46
|
+
return READY;
|
|
47
|
+
}
|
|
48
|
+
if (state.kind === "connecting" || state.kind === "reconnecting") return notReady("tunnel_connecting");
|
|
49
|
+
if (state.kind === "failed") return notReady(state.inFlight ? "tunnel_connecting" : "tunnel_failed");
|
|
50
|
+
return notReady("tunnel_idle");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fromPreflight(result: LinkRelayPreflightResult): LinkReadiness {
|
|
54
|
+
if (result === "ok") return READY;
|
|
55
|
+
return notReady(result === "unreachable" ? "home_unreachable" : "link_auth_failed");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface HomeInitiatedLinkReadinessDeps {
|
|
59
|
+
target: Omit<BoundLinkRelayTarget, "admissionKey">;
|
|
60
|
+
linkKey: () => string | null;
|
|
61
|
+
probe?: (target: BoundLinkRelayTarget, options: { timeoutMs: number }) => Promise<LinkRelayPreflightResult>;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
/** Resolves after `ms`; the wait for a running preflight. */
|
|
64
|
+
delay?: (ms: number) => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The readiness of a Home-initiated link, from a shared, rate-limited relay preflight. */
|
|
68
|
+
export function createHomeInitiatedLinkReadiness(deps: HomeInitiatedLinkReadinessDeps): () => Promise<LinkReadiness> {
|
|
69
|
+
const probe = deps.probe ?? probeLinkRelayPreflight;
|
|
70
|
+
const now = deps.now ?? Date.now;
|
|
71
|
+
const delay = deps.delay ?? (ms => new Promise<void>(resolve => {
|
|
72
|
+
const timer = setTimeout(resolve, ms);
|
|
73
|
+
(timer as { unref?: () => void }).unref?.();
|
|
74
|
+
}));
|
|
75
|
+
let last: { at: number; verdict: LinkReadiness } | undefined;
|
|
76
|
+
let flight: Promise<LinkReadiness> | undefined;
|
|
77
|
+
const run = (admissionKey: string): Promise<LinkReadiness> => {
|
|
78
|
+
flight ??= probe({ ...deps.target, admissionKey }, { timeoutMs: LINK_RELAY_AUTH_TIMEOUT_MS })
|
|
79
|
+
.then(fromPreflight, () => notReady("home_unreachable"))
|
|
80
|
+
.then(verdict => {
|
|
81
|
+
last = { at: now(), verdict };
|
|
82
|
+
return verdict;
|
|
83
|
+
})
|
|
84
|
+
.finally(() => { flight = undefined; });
|
|
85
|
+
return flight;
|
|
86
|
+
};
|
|
87
|
+
return async () => {
|
|
88
|
+
if (!isLinkPort(deps.target.tunnelPort)) return notReady("tunnel_unavailable");
|
|
89
|
+
let admissionKey: string | null;
|
|
90
|
+
try { admissionKey = deps.linkKey(); } catch { admissionKey = null; }
|
|
91
|
+
if (!admissionKey) return notReady("link_credential_unavailable");
|
|
92
|
+
const at = now();
|
|
93
|
+
if (last && at >= last.at && at - last.at < LINK_READINESS_TTL_MS) return last.verdict;
|
|
94
|
+
const unverified = notReady("link_unverified");
|
|
95
|
+
return await Promise.race([run(admissionKey), delay(LINK_READINESS_WAIT_MS).then(() => unverified)]);
|
|
96
|
+
};
|
|
97
|
+
}
|