@bitkyc08/opencodex 2.90.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
- package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
- package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/claude-cli/usage-admission.ts +14 -0
- package/src/adapters/openai-responses/passthrough.ts +2 -1
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +6 -0
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- 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/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 +84 -9
- package/src/cli/provider.ts +44 -2
- 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/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-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/auth-context.ts +10 -6
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- 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/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +205 -97
- package/src/lib/authorization-scheme.ts +95 -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/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- 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/mistral-transport.ts +37 -4
- package/src/providers/quota/antigravity.ts +2 -8
- 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/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 +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- 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 +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +19 -2
- package/src/server/live.ts +5 -0
- 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 +12 -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/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/compact.ts +2 -2
- package/src/server/responses/core-auth.ts +11 -1
- package/src/server/responses/core-combo.ts +72 -1
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/main-hard-lock-routing.ts +54 -2
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/request-prepare.ts +9 -1
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { realpathSync, statSync, type BigIntStats } from "node:fs";
|
|
2
2
|
import { readClientConnectionState } from "../client/state";
|
|
3
3
|
import { readConfigDiagnostics } from "../config/diagnostics";
|
|
4
4
|
import { getConfigDir } from "../config/paths";
|
|
@@ -6,23 +6,34 @@ import { currentCodexHome } from "../service/state";
|
|
|
6
6
|
import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
|
|
7
7
|
import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* `fileId` is the exact identity ("dev:ino" from a bigint stat). `dev` and `ino` are doubles and
|
|
11
|
+
* cannot be: an NTFS file id carries the MFT sequence in its high bits, so above 2^53 a replacement
|
|
12
|
+
* directory with a nearby MFT index can round to the same `ino` as the one it replaced.
|
|
13
|
+
*/
|
|
14
|
+
export interface UpdateRestartDirectory { path: string; dev: number; ino: number; fileId: string }
|
|
15
|
+
|
|
9
16
|
export interface UpdateRestartHome {
|
|
10
|
-
config:
|
|
11
|
-
codex:
|
|
17
|
+
config: UpdateRestartDirectory;
|
|
18
|
+
codex: UpdateRestartDirectory;
|
|
12
19
|
revision: number;
|
|
13
20
|
serviceRecord: UpdateRestartServiceRecord;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
|
|
23
|
+
export type UpdateRestartDirectoryStat = Pick<BigIntStats, "dev" | "ino" | "isDirectory">;
|
|
24
|
+
|
|
25
|
+
function physicalDirectory(path: string, stat: (path: string) => UpdateRestartDirectoryStat): UpdateRestartDirectory {
|
|
17
26
|
const canonical = realpathSync.native(path);
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
20
|
-
return { path: canonical, dev:
|
|
27
|
+
const observed = stat(canonical);
|
|
28
|
+
if (!observed.isDirectory()) throw new Error("update_restart_home_unverified");
|
|
29
|
+
return { path: canonical, dev: Number(observed.dev), ino: Number(observed.ino), fileId: `${observed.dev}:${observed.ino}` };
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export interface UpdateRestartHomeDeps {
|
|
24
33
|
record?: UpdateRestartServiceRecordDeps;
|
|
25
34
|
supervision?: UpdateRestartSupervisionDeps;
|
|
35
|
+
/** Test seam for the directory identity source; production reads a bigint stat. */
|
|
36
|
+
statDirectory?: (path: string) => UpdateRestartDirectoryStat;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
/** Capture eligibility from the same bytes that supply the frozen fingerprint. */
|
|
@@ -32,7 +43,8 @@ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateR
|
|
|
32
43
|
if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
|
|
33
44
|
throw new Error("update_restart_owner_unverified");
|
|
34
45
|
}
|
|
35
|
-
|
|
46
|
+
const stat = deps.statDirectory ?? (path => statSync(path, { bigint: true }));
|
|
47
|
+
return { config: physicalDirectory(getConfigDir(), stat), codex: physicalDirectory(currentCodexHome(), stat), revision: owner.revision, serviceRecord };
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,9 @@ import { Readable } from "node:stream";
|
|
|
4
4
|
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
5
5
|
import { serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
6
6
|
import { clearableDeadline } from "../lib/abort";
|
|
7
|
+
import { packageVersion } from "../lib/package-version";
|
|
8
|
+
import { linkPeerReportHeaders } from "../link/peer-report";
|
|
9
|
+
import { buildPeerFeatures } from "../remote/features";
|
|
7
10
|
import {
|
|
8
11
|
LINK_RELAY_AUTH_PATH, LINK_RELAY_AUTH_TIMEOUT_MS, LINK_RELAY_AUTH_VERSION,
|
|
9
12
|
LINK_RELAY_SESSION_HEADER, linkRelayChallenge, linkRelayProofMatches,
|
|
@@ -148,6 +151,8 @@ async function responseOnConnection(incoming: IncomingMessage, agent: SingleConn
|
|
|
148
151
|
}, { highWaterMark: 0 }), { status, headers });
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
let peerReport: Record<string, string> | undefined;
|
|
155
|
+
|
|
151
156
|
/** Authenticate and stream one request on the SAME socket; never fall back to an ordinary fetch. */
|
|
152
157
|
export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destination: string, init: RequestInit): Promise<Response> {
|
|
153
158
|
if (!target.linkId || !target.apiKeyId) throw new LinkRelayAuthenticationError();
|
|
@@ -160,7 +165,9 @@ export async function fetchBoundLinkRelay(target: BoundLinkRelayTarget, destinat
|
|
|
160
165
|
const agent = new SingleConnectionAgent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
|
|
161
166
|
const deadline = clearableDeadline(LINK_RELAY_AUTH_TIMEOUT_MS, init.signal ?? undefined);
|
|
162
167
|
try {
|
|
163
|
-
|
|
168
|
+
// The challenge also carries this Child's version and features for the Home's link status.
|
|
169
|
+
peerReport ??= linkPeerReportHeaders(packageVersion("0.0.0"), buildPeerFeatures());
|
|
170
|
+
const response = await requestOnConnection(probe, { method: "GET", signal: deadline.signal, headers: peerReport }, agent);
|
|
164
171
|
const socket = agent.socket;
|
|
165
172
|
const proof = response.headers["x-opencodex-link-proof"];
|
|
166
173
|
if (response.statusCode !== 204 || typeof proof !== "string" || !linkRelayProofMatches(proof, challenge.expected)) {
|
package/src/client/link-relay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchBoundLinkRelay, LinkRelayAuthenticationError } from "./link-relay-transport";
|
|
2
|
+
import { homeUnreachableBody } from "./home-unreachable";
|
|
2
3
|
import {
|
|
3
4
|
boundedRelayResponseStream,
|
|
4
5
|
filterRelayHeaders,
|
|
@@ -112,6 +113,29 @@ function jsonError(status: number, error: string, retry = false): Response {
|
|
|
112
113
|
return Response.json({ error }, { status, headers });
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/** A retryable 503 for a Home this Child cannot reach (OCX-11 `home_unreachable`). */
|
|
117
|
+
function homeUnreachable(error: string): Response {
|
|
118
|
+
return Response.json(homeUnreachableBody(error), {
|
|
119
|
+
status: 503,
|
|
120
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The peer answered but the connection-bound relay authentication failed. That is not a Home
|
|
126
|
+
* outage (an unrecognized protocol needs an upgrade or a re-link), so it must never read as
|
|
127
|
+
* `home_unreachable`, which GOAT's device-local fallback keys on. The status and Retry-After it
|
|
128
|
+
* carried before OCX-11 are kept: a lost bound socket can succeed on a fresh connection.
|
|
129
|
+
*/
|
|
130
|
+
export const LINK_RELAY_AUTH_FAILED_CODE = "link_relay_auth_failed";
|
|
131
|
+
|
|
132
|
+
function relayAuthenticationFailed(error: string): Response {
|
|
133
|
+
return Response.json({ error, code: LINK_RELAY_AUTH_FAILED_CODE }, {
|
|
134
|
+
status: 503,
|
|
135
|
+
headers: { "Retry-After": String(LINK_RELAY_RETRY_AFTER_SECONDS) },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
export function linkRelayDestination(url: URL, target: Pick<LinkRelayTarget, "tunnelPort">): string {
|
|
116
140
|
if (!isLinkPort(target.tunnelPort)) {
|
|
117
141
|
throw new RangeError("invalid link tunnel port");
|
|
@@ -369,7 +393,7 @@ export async function relayLinkDataRequest(
|
|
|
369
393
|
const tunnelUnavailable = async (): Promise<Response> => {
|
|
370
394
|
cleanup();
|
|
371
395
|
try { await body?.cancel(); } catch { /* best effort */ }
|
|
372
|
-
return
|
|
396
|
+
return homeUnreachable("link tunnel unavailable");
|
|
373
397
|
};
|
|
374
398
|
const readyForFetch = async (): Promise<boolean> => {
|
|
375
399
|
if (!tunnel) return false;
|
|
@@ -393,7 +417,7 @@ export async function relayLinkDataRequest(
|
|
|
393
417
|
if (error instanceof LinkRelayAuthenticationError) {
|
|
394
418
|
cleanup();
|
|
395
419
|
try { await body?.cancel(); } catch { /* an upload might already be drained */ }
|
|
396
|
-
return
|
|
420
|
+
return relayAuthenticationFailed(error.message);
|
|
397
421
|
}
|
|
398
422
|
if (bodyOverflow) {
|
|
399
423
|
cleanup();
|
|
@@ -408,7 +432,7 @@ export async function relayLinkDataRequest(
|
|
|
408
432
|
if (relayAbort.signal.aborted) {
|
|
409
433
|
cleanup();
|
|
410
434
|
try { await upstream.body?.cancel(); } catch { /* best effort */ }
|
|
411
|
-
return
|
|
435
|
+
return homeUnreachable("link tunnel unavailable");
|
|
412
436
|
}
|
|
413
437
|
|
|
414
438
|
const sse = isSse(upstream.headers);
|