@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/link/supervisor.ts
CHANGED
|
@@ -4,13 +4,16 @@ import { atomicWriteFile } from "../config/atomic-write";
|
|
|
4
4
|
import { linkDir, linkKnownHostsPath, linkStorePath } from "./paths";
|
|
5
5
|
import { buildTunnelArgv } from "./ssh-argv";
|
|
6
6
|
import { createSshRunner, type SshChild, type SshRunner } from "./ssh-runner";
|
|
7
|
+
import type { LinkDiagnosticSink } from "./diagnostics";
|
|
8
|
+
import { createTunnelHistoryBook, exitCause, type TunnelCause, type TunnelHistory } from "./tunnel-history";
|
|
7
9
|
import {
|
|
8
10
|
classifySshStderr,
|
|
9
11
|
dueForSpawn,
|
|
10
|
-
|
|
12
|
+
failedSpawnTunnel,
|
|
11
13
|
HOME_TUNNEL_RETRY_POLICY,
|
|
12
14
|
IDLE,
|
|
13
15
|
reduceTunnel,
|
|
16
|
+
type TunnelFailure,
|
|
14
17
|
type TunnelState,
|
|
15
18
|
} from "./tunnel-state";
|
|
16
19
|
import { emptyLinkStore, readLinkStore, type LinkRecord, type LinkStore } from "./store";
|
|
@@ -22,6 +25,8 @@ export type LinkTunnelStatus =
|
|
|
22
25
|
state: TunnelState;
|
|
23
26
|
pid: number | null;
|
|
24
27
|
orphan?: "orphan-unverified";
|
|
28
|
+
/** Attempts, last change and last failure cause of this tunnel (`tunnel-history.ts`). */
|
|
29
|
+
history?: TunnelHistory;
|
|
25
30
|
}
|
|
26
31
|
| {
|
|
27
32
|
linkId: string;
|
|
@@ -72,6 +77,10 @@ export interface LinkSupervisorDeps {
|
|
|
72
77
|
apiKeys?: () => readonly { id: string; name: string }[];
|
|
73
78
|
revokeApiKey?: (id: string) => boolean;
|
|
74
79
|
warn?: (message: string) => void;
|
|
80
|
+
/** Where tunnel state changes are recorded; production passes the bounded diagnostics file. */
|
|
81
|
+
diagnostic?: LinkDiagnosticSink;
|
|
82
|
+
/** Bounded drain of `diagnostic` at stop. */
|
|
83
|
+
flushDiagnostics?: () => Promise<void>;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const TIMER_MS = 1_000;
|
|
@@ -166,6 +175,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
166
175
|
/** The generation of the child whose exit is still being classified, per link. */
|
|
167
176
|
const exiting = new Map<string, number>();
|
|
168
177
|
const recordInstances = new Map<string, string>();
|
|
178
|
+
const history = createTunnelHistoryBook({
|
|
179
|
+
diagnostic: deps.diagnostic,
|
|
180
|
+
tunnelPort: linkId => store.links.find(link => link.id === linkId)?.tunnelPort,
|
|
181
|
+
});
|
|
169
182
|
|
|
170
183
|
const reconcileApiKeys = (): void => {
|
|
171
184
|
const keys = deps.apiKeys?.() ?? [];
|
|
@@ -218,12 +231,22 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
218
231
|
}
|
|
219
232
|
};
|
|
220
233
|
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
/** Every state change goes through here, so the history sees each transition and its cause. */
|
|
235
|
+
const put = (linkId: string, next: TunnelState, cause: TunnelCause | null = null): TunnelState => {
|
|
236
|
+
const before = states.get(linkId) ?? IDLE;
|
|
223
237
|
states.set(linkId, next);
|
|
238
|
+
history.observe(linkId, before, next, cause, now());
|
|
224
239
|
return next;
|
|
225
240
|
};
|
|
226
241
|
|
|
242
|
+
const setEvent = (linkId: string, event: Parameters<typeof reduceTunnel>[1], cause: TunnelCause | null = null): TunnelState =>
|
|
243
|
+
put(linkId, reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random, HOME_TUNNEL_RETRY_POLICY), cause);
|
|
244
|
+
|
|
245
|
+
/** Fail a link whose attempt could not start; a due retry that fails this way counts as one. */
|
|
246
|
+
const failAttempt = (linkId: string, prior: TunnelState | undefined, reason: TunnelFailure, cause: TunnelCause): void => {
|
|
247
|
+
put(linkId, failedSpawnTunnel(prior, reason, now(), HOME_TUNNEL_RETRY_POLICY), cause);
|
|
248
|
+
};
|
|
249
|
+
|
|
227
250
|
const spawnFor = (record: LinkRecord): void => {
|
|
228
251
|
if (stopping || holds.has(record.id) || record.direction !== "hub-initiated" || children.has(record.id)) return;
|
|
229
252
|
// Only a fresh link or a due attempt spawns. A reload must neither skip the backoff nor start
|
|
@@ -231,7 +254,8 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
231
254
|
const prior = states.get(record.id);
|
|
232
255
|
if (prior && prior.kind !== "idle" && !dueForSpawn(prior, now())) return;
|
|
233
256
|
if (store.listenerPort === null) {
|
|
234
|
-
|
|
257
|
+
// Retried like any forward failure; a reload that brings the listener back starts it fresh.
|
|
258
|
+
failAttempt(record.id, prior, "forward", "listener_unavailable");
|
|
235
259
|
return;
|
|
236
260
|
}
|
|
237
261
|
let argv: string[];
|
|
@@ -250,6 +274,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
250
274
|
exiting.delete(record.id);
|
|
251
275
|
children.set(record.id, { child, argv, spawnedAt: now() });
|
|
252
276
|
orphanUnverified.delete(record.id);
|
|
277
|
+
history.spawned(record.id);
|
|
253
278
|
setEvent(record.id, { type: "spawn", now: now() });
|
|
254
279
|
writePidfile(pidfilePath(record.id), { version: 1, linkId: record.id, pid: child.pid, argv });
|
|
255
280
|
void child.exited.then(async () => {
|
|
@@ -264,7 +289,8 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
264
289
|
try { stderr = child.stderr ? await child.stderr : ""; } catch { /* classified as unknown */ }
|
|
265
290
|
// A reconnect may have started a fresh child meanwhile; its own lifecycle owns the state.
|
|
266
291
|
if (generations.get(record.id) !== generation) return;
|
|
267
|
-
|
|
292
|
+
const stderrClass = classifySshStderr(stderr);
|
|
293
|
+
setEvent(record.id, { type: "exit", now: now(), stderrClass }, exitCause(stderrClass));
|
|
268
294
|
} finally {
|
|
269
295
|
if (exiting.get(record.id) === generation) exiting.delete(record.id);
|
|
270
296
|
}
|
|
@@ -272,11 +298,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
272
298
|
if (children.get(record.id)?.child !== child) return;
|
|
273
299
|
children.delete(record.id);
|
|
274
300
|
conditionalRemovePidfile(record.id, child.pid);
|
|
275
|
-
if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" });
|
|
301
|
+
if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" }, "network");
|
|
276
302
|
});
|
|
277
303
|
} catch {
|
|
278
|
-
|
|
279
|
-
states.set(record.id, failedTunnel("forward", at, HOME_TUNNEL_RETRY_POLICY, prior?.kind === "failed" ? prior.since : at));
|
|
304
|
+
failAttempt(record.id, prior, "forward", "spawn_failed");
|
|
280
305
|
}
|
|
281
306
|
};
|
|
282
307
|
|
|
@@ -300,7 +325,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
300
325
|
setEvent(record.id, { type: "ready", now: current });
|
|
301
326
|
continue;
|
|
302
327
|
}
|
|
303
|
-
const next = setEvent(record.id, { type: "tick", now: current });
|
|
328
|
+
const next = setEvent(record.id, { type: "tick", now: current }, "timeout");
|
|
304
329
|
if (next.kind === "failed" && !next.inFlight && child && retryInFlight(before)) {
|
|
305
330
|
// A retry started just before the outage limit has not had its grace yet. It carries on as
|
|
306
331
|
// the failed state's retry instead of being killed with a link that may already be back.
|
|
@@ -349,7 +374,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
349
374
|
const stopChild = async (linkId: string): Promise<void> => {
|
|
350
375
|
const current = children.get(linkId);
|
|
351
376
|
if (!current) {
|
|
352
|
-
|
|
377
|
+
put(linkId, IDLE);
|
|
353
378
|
return;
|
|
354
379
|
}
|
|
355
380
|
const generation = generations.get(linkId);
|
|
@@ -358,7 +383,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
358
383
|
await current.child.exited;
|
|
359
384
|
conditionalRemovePidfile(linkId, current.child.pid);
|
|
360
385
|
// A stop or removal reservation made while this child exited owns the next state.
|
|
361
|
-
if (generations.get(linkId) === generation)
|
|
386
|
+
if (generations.get(linkId) === generation) put(linkId, IDLE);
|
|
362
387
|
};
|
|
363
388
|
|
|
364
389
|
const invalidateLink = (linkId: string): void => {
|
|
@@ -407,6 +432,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
407
432
|
if (nextInstances.has(linkId) && !changed.has(linkId)) continue;
|
|
408
433
|
await stopChild(linkId);
|
|
409
434
|
states.delete(linkId);
|
|
435
|
+
history.forget(linkId);
|
|
410
436
|
orphanUnverified.delete(linkId);
|
|
411
437
|
recordInstances.delete(linkId);
|
|
412
438
|
if (stopping) return;
|
|
@@ -414,9 +440,13 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
414
440
|
for (const record of nextStore.links) {
|
|
415
441
|
if (changed.has(record.id)) {
|
|
416
442
|
states.delete(record.id);
|
|
443
|
+
history.forget(record.id);
|
|
417
444
|
orphanUnverified.delete(record.id);
|
|
418
445
|
}
|
|
419
446
|
}
|
|
447
|
+
for (const linkId of [...states.keys()]) {
|
|
448
|
+
if (!nextInstances.has(linkId)) history.forget(linkId);
|
|
449
|
+
}
|
|
420
450
|
recordInstances.clear();
|
|
421
451
|
for (const [linkId, instance] of nextInstances) recordInstances.set(linkId, instance);
|
|
422
452
|
for (const record of nextStore.links) {
|
|
@@ -471,12 +501,14 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
471
501
|
return { linkId: record.id, direction: record.direction, state: "client-owned", pid: null };
|
|
472
502
|
}
|
|
473
503
|
const child = children.get(record.id);
|
|
504
|
+
const recorded = history.get(record.id);
|
|
474
505
|
return {
|
|
475
506
|
linkId: record.id,
|
|
476
507
|
direction: record.direction,
|
|
477
508
|
state: states.get(record.id) ?? IDLE,
|
|
478
509
|
pid: child?.child.pid ?? null,
|
|
479
510
|
...(orphanUnverified.has(record.id) ? { orphan: "orphan-unverified" as const } : {}),
|
|
511
|
+
...(recorded ? { history: recorded } : {}),
|
|
480
512
|
};
|
|
481
513
|
});
|
|
482
514
|
},
|
|
@@ -489,9 +521,10 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
489
521
|
await runLifecycle(async () => {
|
|
490
522
|
await Promise.all([...children.keys()].map(stopChild));
|
|
491
523
|
for (const record of store.links) {
|
|
492
|
-
if (record.direction === "hub-initiated")
|
|
524
|
+
if (record.direction === "hub-initiated") put(record.id, IDLE);
|
|
493
525
|
}
|
|
494
526
|
});
|
|
527
|
+
try { await deps.flushDiagnostics?.(); } catch { /* best effort */ }
|
|
495
528
|
},
|
|
496
529
|
};
|
|
497
530
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Home remembers about one `-R` tunnel besides its current state: how many ssh attempts
|
|
3
|
+
* the current outage has cost, the last state change and the last failure with its cause. It lives
|
|
4
|
+
* in memory next to the supervisor's state, appears in `GET /api/link/status` as `tunnel`, and
|
|
5
|
+
* each change it records also goes to the bounded diagnostics file (`diagnostics.ts`), so a link
|
|
6
|
+
* that failed for an hour leaves evidence of why after the fact.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here carries ssh output, argv, host names or keys: a cause is one of a fixed set of
|
|
9
|
+
* words, and times are numbers.
|
|
10
|
+
*/
|
|
11
|
+
import type { LinkDiagnosticSink } from "./diagnostics";
|
|
12
|
+
import type { StderrClass, TunnelState } from "./tunnel-state";
|
|
13
|
+
|
|
14
|
+
export type TunnelCause =
|
|
15
|
+
| "auth" | "hostkey" | "forward" | "remote_forward_conflict" | "timeout" | "network" | "unknown"
|
|
16
|
+
| "spawn_failed" | "listener_unavailable";
|
|
17
|
+
|
|
18
|
+
export const TUNNEL_CAUSES: readonly TunnelCause[] = [
|
|
19
|
+
"auth", "hostkey", "forward", "remote_forward_conflict", "timeout", "network", "unknown",
|
|
20
|
+
"spawn_failed", "listener_unavailable",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export interface TunnelTransitionRecord {
|
|
24
|
+
from: TunnelState["kind"];
|
|
25
|
+
to: TunnelState["kind"];
|
|
26
|
+
at: number;
|
|
27
|
+
cause: TunnelCause | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TunnelHistory {
|
|
31
|
+
/** ssh attempts started since the link was last connected (0 while connected). */
|
|
32
|
+
attempt: number;
|
|
33
|
+
lastTransition: TunnelTransitionRecord | null;
|
|
34
|
+
lastFailure: { cause: TunnelCause; at: number } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function exitCause(stderrClass: StderrClass): TunnelCause {
|
|
38
|
+
return stderrClass === "remote_forward" ? "remote_forward_conflict" : stderrClass;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A retry of a failed link ended without connecting: one that ran while the state read failed, or
|
|
43
|
+
* one that could not even start (the retry count still grew).
|
|
44
|
+
*/
|
|
45
|
+
function retryFailedAgain(before: TunnelState, after: TunnelState): boolean {
|
|
46
|
+
if (before.kind !== "failed" || after.kind !== "failed" || after.inFlight === true) return false;
|
|
47
|
+
return before.inFlight === true || (after.retries ?? 0) > (before.retries ?? 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TunnelHistoryBook {
|
|
51
|
+
/** Count one ssh attempt for the link. */
|
|
52
|
+
spawned(linkId: string): void;
|
|
53
|
+
/** Record a state change; `cause` names why the link left a working state, when known. */
|
|
54
|
+
observe(linkId: string, before: TunnelState, after: TunnelState, cause: TunnelCause | null, at: number): void;
|
|
55
|
+
get(linkId: string): TunnelHistory | undefined;
|
|
56
|
+
forget(linkId: string): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createTunnelHistoryBook(
|
|
60
|
+
options: { diagnostic?: LinkDiagnosticSink; tunnelPort?: (linkId: string) => number | undefined } = {},
|
|
61
|
+
): TunnelHistoryBook {
|
|
62
|
+
const histories = new Map<string, TunnelHistory>();
|
|
63
|
+
const entry = (linkId: string): TunnelHistory => {
|
|
64
|
+
let history = histories.get(linkId);
|
|
65
|
+
if (!history) {
|
|
66
|
+
history = { attempt: 0, lastTransition: null, lastFailure: null };
|
|
67
|
+
histories.set(linkId, history);
|
|
68
|
+
}
|
|
69
|
+
return history;
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
spawned(linkId) {
|
|
73
|
+
entry(linkId).attempt += 1;
|
|
74
|
+
},
|
|
75
|
+
observe(linkId, before, after, cause, at) {
|
|
76
|
+
const refailed = retryFailedAgain(before, after);
|
|
77
|
+
if (before.kind === after.kind && !refailed) return;
|
|
78
|
+
const history = entry(linkId);
|
|
79
|
+
if (after.kind === "connected" || after.kind === "idle") history.attempt = 0;
|
|
80
|
+
if (cause && (after.kind === "failed" || after.kind === "reconnecting")) history.lastFailure = { cause, at };
|
|
81
|
+
history.lastTransition = { from: before.kind, to: after.kind, at, cause };
|
|
82
|
+
const retryAt = after.kind === "failed" || after.kind === "reconnecting" ? after.retryAt : undefined;
|
|
83
|
+
const tunnelPort = options.tunnelPort?.(linkId);
|
|
84
|
+
try {
|
|
85
|
+
options.diagnostic?.({
|
|
86
|
+
event: "tunnel_transition",
|
|
87
|
+
outcome: cause ?? (after.kind === "connected" ? "connected" : after.kind === "idle" ? "stopped" : "spawned"),
|
|
88
|
+
state: after.kind,
|
|
89
|
+
previousState: before.kind,
|
|
90
|
+
attempt: history.attempt,
|
|
91
|
+
...(tunnelPort !== undefined ? { tunnelPort } : {}),
|
|
92
|
+
...(retryAt !== undefined ? { retryAfterMs: Math.max(0, retryAt - at) } : {}),
|
|
93
|
+
});
|
|
94
|
+
} catch { /* Diagnostics never change the tunnel lifecycle. */ }
|
|
95
|
+
},
|
|
96
|
+
get(linkId) {
|
|
97
|
+
const history = histories.get(linkId);
|
|
98
|
+
if (!history) return undefined;
|
|
99
|
+
return {
|
|
100
|
+
attempt: history.attempt,
|
|
101
|
+
lastTransition: history.lastTransition && { ...history.lastTransition },
|
|
102
|
+
lastFailure: history.lastFailure && { ...history.lastFailure },
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
forget(linkId) {
|
|
106
|
+
histories.delete(linkId);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/link/tunnel-state.ts
CHANGED
|
@@ -9,18 +9,35 @@
|
|
|
9
9
|
* Without a retry policy it needs the user. With one (the Home's `-R` supervisor and the Child's
|
|
10
10
|
* own `-L` tunnel both run with one) a reason that has a delay is tried again at `retryAt`, and
|
|
11
11
|
* the retry attempt runs with `inFlight` while the state still reads failed; a reason without a
|
|
12
|
-
* delay stays terminal.
|
|
12
|
+
* delay stays terminal. Each retry that fails again doubles the next delay, up to the policy's
|
|
13
|
+
* `maxRetryDelayMs`; `retries` counts those failed retries (absent before the first one). A due
|
|
14
|
+
* retry whose ssh could not even be started counts too (`failedSpawnTunnel`).
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `remote_forward` is the Home's `-R` request refused by the peer's sshd: the port is still held,
|
|
20
|
+
* usually by an earlier session the peer has not dropped yet. It fails the link as `forward` and
|
|
21
|
+
* keeps the finer cause in `detail`.
|
|
22
|
+
*/
|
|
23
|
+
export type StderrClass = "auth" | "hostkey" | "forward" | "remote_forward" | "network" | "unknown";
|
|
24
|
+
export type TunnelFailureDetail = "remote_forward_conflict";
|
|
17
25
|
|
|
18
26
|
export type TunnelState =
|
|
19
27
|
| { kind: "idle" }
|
|
20
28
|
| { kind: "connecting"; since: number }
|
|
21
29
|
| { kind: "connected"; since: number }
|
|
22
30
|
| { kind: "reconnecting"; since: number; attempt: number; retryAt: number; inFlight: boolean }
|
|
23
|
-
| {
|
|
31
|
+
| {
|
|
32
|
+
kind: "failed";
|
|
33
|
+
since: number;
|
|
34
|
+
reason: TunnelFailure;
|
|
35
|
+
retryAt?: number;
|
|
36
|
+
inFlight?: boolean;
|
|
37
|
+
/** Failed retries since the link failed; absent until the first retry fails. */
|
|
38
|
+
retries?: number;
|
|
39
|
+
detail?: TunnelFailureDetail;
|
|
40
|
+
};
|
|
24
41
|
|
|
25
42
|
export type TunnelEvent =
|
|
26
43
|
| { type: "spawn"; now: number }
|
|
@@ -29,9 +46,13 @@ export type TunnelEvent =
|
|
|
29
46
|
| { type: "tick"; now: number }
|
|
30
47
|
| { type: "stop" };
|
|
31
48
|
|
|
32
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Opt-in retry of a failed tunnel: the first delay before each failure reason is tried again, and
|
|
51
|
+
* the cap the doubling delay of later retries stops at.
|
|
52
|
+
*/
|
|
33
53
|
export interface TunnelRetryPolicy {
|
|
34
54
|
retryFailedAfterMs: Partial<Record<TunnelFailure, number>>;
|
|
55
|
+
maxRetryDelayMs?: number;
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
export const FAILED_AFTER_MS = 5 * 60_000;
|
|
@@ -39,19 +60,20 @@ export const BASE_DELAY_MS = 1_000;
|
|
|
39
60
|
export const MAX_DELAY_MS = 30_000;
|
|
40
61
|
|
|
41
62
|
/**
|
|
42
|
-
* The client-owned tunnel's policy. Timeout and forward failures
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* security signal and is never retried.
|
|
63
|
+
* The client-owned tunnel's policy. Timeout and forward failures are first retried after a minute,
|
|
64
|
+
* and each retry that fails again doubles the delay up to five minutes (60, 120, 240, then every
|
|
65
|
+
* 300 seconds). Auth starts at the five-minute cap, so no more than 12 attempts reach the Home's
|
|
66
|
+
* sshd in an hour. A changed host key is a security signal and is never retried.
|
|
46
67
|
*/
|
|
47
68
|
export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
|
|
48
69
|
retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
|
|
70
|
+
maxRetryDelayMs: 5 * 60_000,
|
|
49
71
|
};
|
|
50
72
|
|
|
51
73
|
/**
|
|
52
74
|
* The Home's `-R` supervisor dials the Child's sshd on the same cadence and limits. A sleep, an
|
|
53
|
-
* outage longer than FAILED_AFTER_MS, or a forward the Child's sshd still holds for
|
|
54
|
-
*
|
|
75
|
+
* outage longer than FAILED_AFTER_MS, or a forward the Child's sshd still holds for a session that
|
|
76
|
+
* died heals at most five minutes after it clears, instead of waiting for a proxy restart.
|
|
55
77
|
*/
|
|
56
78
|
export const HOME_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = CLIENT_TUNNEL_RETRY_POLICY;
|
|
57
79
|
|
|
@@ -65,23 +87,59 @@ export function nextDelayMs(attempt: number, random: () => number = Math.random)
|
|
|
65
87
|
return Math.round(Math.min(MAX_DELAY_MS, base * jitter));
|
|
66
88
|
}
|
|
67
89
|
|
|
90
|
+
/** The delay before the next retry of `reason` after `retries` failed retries, or undefined. */
|
|
91
|
+
export function failedRetryDelayMs(reason: TunnelFailure, retries: number, policy?: TunnelRetryPolicy): number | undefined {
|
|
92
|
+
const base = policy?.retryFailedAfterMs[reason];
|
|
93
|
+
if (base === undefined) return undefined;
|
|
94
|
+
const cap = Math.max(base, policy?.maxRetryDelayMs ?? base);
|
|
95
|
+
return Math.min(cap, base * 2 ** Math.max(0, Math.min(retries, 16)));
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
/** A failed state; under a policy that retries `reason` it carries the next attempt's time. */
|
|
69
99
|
export function failedTunnel(
|
|
70
100
|
reason: TunnelFailure,
|
|
71
101
|
now: number,
|
|
72
102
|
policy?: TunnelRetryPolicy,
|
|
73
103
|
since: number = now,
|
|
104
|
+
options: { retries?: number; detail?: TunnelFailureDetail } = {},
|
|
74
105
|
): TunnelState {
|
|
75
|
-
const
|
|
106
|
+
const retries = options.retries ?? 0;
|
|
107
|
+
const delay = failedRetryDelayMs(reason, retries, policy);
|
|
108
|
+
const extra = {
|
|
109
|
+
...(retries > 0 ? { retries } : {}),
|
|
110
|
+
...(options.detail ? { detail: options.detail } : {}),
|
|
111
|
+
};
|
|
76
112
|
return delay === undefined
|
|
77
|
-
? { kind: "failed", since, reason }
|
|
78
|
-
: { kind: "failed", since, reason, retryAt: now + delay, inFlight: false };
|
|
113
|
+
? { kind: "failed", since, reason, ...extra }
|
|
114
|
+
: { kind: "failed", since, reason, retryAt: now + delay, inFlight: false, ...extra };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The state after an attempt whose ssh could not be started (ssh missing, a pidfile that cannot be
|
|
119
|
+
* written, no listener port). On a due retry of a failed link it counts as one more failed retry
|
|
120
|
+
* from the original moment and keeps the reason it was retrying: an attempt that reached no peer
|
|
121
|
+
* says nothing new about the link, so the backoff keeps its step and an auth failure keeps its
|
|
122
|
+
* five-minute cadence. Any other state fails fresh with `reason`.
|
|
123
|
+
*/
|
|
124
|
+
export function failedSpawnTunnel(
|
|
125
|
+
prior: TunnelState | undefined,
|
|
126
|
+
reason: TunnelFailure,
|
|
127
|
+
now: number,
|
|
128
|
+
policy?: TunnelRetryPolicy,
|
|
129
|
+
): TunnelState {
|
|
130
|
+
if (prior?.kind !== "failed") return failedTunnel(reason, now, policy);
|
|
131
|
+
return failedTunnel(prior.reason, now, policy, prior.since, { retries: (prior.retries ?? 0) + 1, detail: prior.detail });
|
|
79
132
|
}
|
|
80
133
|
|
|
81
134
|
function failureOf(cls: StderrClass): TunnelFailure | null {
|
|
135
|
+
if (cls === "remote_forward") return "forward";
|
|
82
136
|
return cls === "auth" || cls === "hostkey" || cls === "forward" ? cls : null;
|
|
83
137
|
}
|
|
84
138
|
|
|
139
|
+
function detailOf(cls: StderrClass): TunnelFailureDetail | undefined {
|
|
140
|
+
return cls === "remote_forward" ? "remote_forward_conflict" : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
85
143
|
export function reduceTunnel(
|
|
86
144
|
state: TunnelState,
|
|
87
145
|
event: TunnelEvent,
|
|
@@ -104,11 +162,15 @@ export function reduceTunnel(
|
|
|
104
162
|
if (state.kind === "failed") {
|
|
105
163
|
if (!state.inFlight) return state;
|
|
106
164
|
// A retry of a failed link failed again. It stays failed from the original moment; a
|
|
107
|
-
// transient exit keeps the slow cadence (timeout) instead of restarting fast backoff
|
|
108
|
-
|
|
165
|
+
// transient exit keeps the slow cadence (timeout) instead of restarting fast backoff, and
|
|
166
|
+
// the next delay doubles up to the policy's cap.
|
|
167
|
+
return failedTunnel(failureOf(event.stderrClass) ?? "timeout", event.now, policy, state.since, {
|
|
168
|
+
retries: (state.retries ?? 0) + 1,
|
|
169
|
+
detail: detailOf(event.stderrClass),
|
|
170
|
+
});
|
|
109
171
|
}
|
|
110
172
|
const failure = failureOf(event.stderrClass);
|
|
111
|
-
if (failure) return failedTunnel(failure, event.now, policy);
|
|
173
|
+
if (failure) return failedTunnel(failure, event.now, policy, event.now, { detail: detailOf(event.stderrClass) });
|
|
112
174
|
const since = state.kind === "reconnecting" || state.kind === "connecting" ? state.since : event.now;
|
|
113
175
|
if (event.now - since >= FAILED_AFTER_MS) return failedTunnel("timeout", event.now, policy);
|
|
114
176
|
const attempt = state.kind === "reconnecting" ? state.attempt + 1 : 1;
|
|
@@ -137,7 +199,8 @@ export function classifySshStderr(stderr: string): StderrClass {
|
|
|
137
199
|
|| text.includes("no ecdsa host key is known") || text.includes("no ed25519 host key is known")
|
|
138
200
|
|| text.includes("no rsa host key is known") || text.includes("host key is known for")) return "hostkey";
|
|
139
201
|
if (text.includes("permission denied") || text.includes("too many authentication failures")) return "auth";
|
|
140
|
-
if (text.includes("remote port forwarding failed")
|
|
202
|
+
if (text.includes("remote port forwarding failed")) return "remote_forward";
|
|
203
|
+
if (text.includes("port forwarding failed")
|
|
141
204
|
|| text.includes("address already in use") || text.includes("cannot listen to port")
|
|
142
205
|
|| text.includes("could not request local forwarding")) return "forward";
|
|
143
206
|
if (text.includes("connection refused") || text.includes("timed out") || text.includes("could not resolve")
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync,
|
|
2
2
|
realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
5
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
5
6
|
import { remoteError, REMOTE_LIMITS } from "./remote-contract";
|
|
6
7
|
|
|
8
|
+
/** macOS reports access ACLs separately from otherwise-private POSIX mode bits. */
|
|
9
|
+
export function macAclOutputIsPrivate(output: string): boolean {
|
|
10
|
+
const lines = output.trimEnd().split("\n");
|
|
11
|
+
return lines.length === 1 && /^[d-][rwxStTs-]{9}[.@]?\s/.test(lines[0]!);
|
|
12
|
+
}
|
|
13
|
+
function checkMacAcl(path: string): void {
|
|
14
|
+
if (process.platform !== "darwin") return;
|
|
15
|
+
const result = spawnSync("/bin/ls", ["-lde", path], {
|
|
16
|
+
encoding: "utf8", timeout: 1_000, shell: false, windowsHide: true,
|
|
17
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
18
|
+
});
|
|
19
|
+
if (result.error || result.status !== 0 || result.stderr || !macAclOutputIsPrivate(result.stdout)) throw new Error();
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/** Check the entire path ancestry before using the private messaging subtree. */
|
|
8
23
|
export function checkRemoteDirectory(path: string): void {
|
|
9
24
|
checkDirectory(path, true);
|
|
@@ -17,6 +32,7 @@ function checkDirectory(path: string, privateLeaf: boolean): void {
|
|
|
17
32
|
const final = lstatSync(path);
|
|
18
33
|
if (!final.isDirectory() || final.isSymbolicLink()
|
|
19
34
|
|| (privateLeaf && (final.uid !== process.getuid!() || (final.mode & 0o077)))) throw new Error();
|
|
35
|
+
if (privateLeaf) checkMacAcl(path);
|
|
20
36
|
// Root-controlled OS aliases (/tmp and /var on macOS) are not user-controlled redirects.
|
|
21
37
|
for (let entry = path; ; entry = dirname(entry)) {
|
|
22
38
|
const stat = lstatSync(entry);
|
|
@@ -81,6 +97,7 @@ export function readRemoteFile(path: string): string | null {
|
|
|
81
97
|
const stat = fstatSync(fd);
|
|
82
98
|
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid!() || (stat.mode & 0o077)
|
|
83
99
|
|| stat.size > REMOTE_LIMITS.storeBytes) throw new Error();
|
|
100
|
+
checkMacAcl(path);
|
|
84
101
|
const bytes = Buffer.alloc(REMOTE_LIMITS.storeBytes + 1);
|
|
85
102
|
let size = 0;
|
|
86
103
|
while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
|
|
@@ -98,8 +115,14 @@ export function writeRemoteFile(path: string, text: string, sync = fsyncSync): v
|
|
|
98
115
|
const temporary = join(dirname(path), `.write-${crypto.randomUUID()}`);
|
|
99
116
|
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
100
117
|
try {
|
|
101
|
-
try {
|
|
118
|
+
try {
|
|
119
|
+
try { checkMacAcl(temporary); }
|
|
120
|
+
catch { throw remoteError("unsafe_remote_storage", "Cannot safely publish messaging state: temporary file ACL privacy could not be confirmed."); }
|
|
121
|
+
writeFileSync(fd, text, "utf8"); sync(fd);
|
|
122
|
+
} finally { closeSync(fd); }
|
|
102
123
|
renameSync(temporary, path);
|
|
124
|
+
try { checkMacAcl(path); }
|
|
125
|
+
catch { throw remoteError("remote_storage_unknown", "Messaging state was replaced, but its ACL privacy and crash durability could not be confirmed; inspect state and permissions before explicit recovery."); }
|
|
103
126
|
syncRemoteDirectory(dirname(path), sync);
|
|
104
127
|
} finally { if (remotePathExists(temporary)) unlinkSync(temporary); }
|
|
105
128
|
}
|
|
@@ -260,9 +260,12 @@ function accountListsModel(providerName: string, accountId: string, model: strin
|
|
|
260
260
|
/**
|
|
261
261
|
* Model evidence only ranks accounts already eligible and below any configured cap.
|
|
262
262
|
*
|
|
263
|
-
* Kiro and Antigravity both gate models per account
|
|
264
|
-
*
|
|
265
|
-
*
|
|
263
|
+
* Kiro and Antigravity both gate models per account, but only Antigravity's roster is a gate
|
|
264
|
+
* (#7500). An account whose discovered roster lists the model wins; an account with no fresh
|
|
265
|
+
* roster is unknown and stays usable after it; an account whose fresh roster omits the model is
|
|
266
|
+
* never returned, so a field of only such accounts answers EMPTY rather than falling back to them.
|
|
267
|
+
* Callers treat empty as "no account to move to", and the transport refuses the request through
|
|
268
|
+
* {@link routeAntigravityModel}. Kiro listings remain a preference only.
|
|
266
269
|
*/
|
|
267
270
|
export function preferKiroModelSupport(
|
|
268
271
|
providerName: string, ids: string[], model?: string | null, config?: OcxConfig,
|
|
@@ -271,8 +274,7 @@ export function preferKiroModelSupport(
|
|
|
271
274
|
if (providerName === "google-antigravity") {
|
|
272
275
|
const positive = ids.filter(id => accountListsModel(providerName, id, model) === true);
|
|
273
276
|
if (positive.length) return positive;
|
|
274
|
-
|
|
275
|
-
return notListedAgainst.length ? notListedAgainst : ids;
|
|
277
|
+
return ids.filter(id => accountListsModel(providerName, id, model) !== false);
|
|
276
278
|
}
|
|
277
279
|
if (providerName !== "kiro") return ids;
|
|
278
280
|
const cap = config?.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
|
|
@@ -282,6 +284,83 @@ export function preferKiroModelSupport(
|
|
|
282
284
|
return positive.length ? positive : ids;
|
|
283
285
|
}
|
|
284
286
|
|
|
287
|
+
/** Where an Antigravity request may be sent, judged from per-account model discovery (#7500). */
|
|
288
|
+
export type AntigravityModelRoute =
|
|
289
|
+
| { readonly kind: "keep" }
|
|
290
|
+
| { readonly kind: "reroute"; readonly accountId: string }
|
|
291
|
+
| { readonly kind: "refuse"; readonly message: string };
|
|
292
|
+
|
|
293
|
+
const KEEP_ANTIGRAVITY_ROUTE: AntigravityModelRoute = { kind: "keep" };
|
|
294
|
+
|
|
295
|
+
/** Client-facing refusal when no usable account's discovery lists the model. Mirrored in docs-site. */
|
|
296
|
+
export function antigravityModelUnlistedMessage(model: string): string {
|
|
297
|
+
return `No usable Google Antigravity account lists the model "${model}" in its model discovery. `
|
|
298
|
+
+ "Choose a model your accounts list, or add an account that has access to it.";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Client-facing refusal when the chosen account omits the model but a sibling may serve it. */
|
|
302
|
+
export function antigravitySelectedAccountUnlistedMessage(model: string): string {
|
|
303
|
+
return `The selected Google Antigravity account does not list the model "${model}" in its model discovery. `
|
|
304
|
+
+ "Select an account that lists it, or enable oauthAccountFailover so OpenCodex can choose one.";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Fail-closed model routing for Antigravity (#7500).
|
|
309
|
+
*
|
|
310
|
+
* `plannedAccountId` is the account the request would otherwise be sent with. It is kept unless
|
|
311
|
+
* its own fresh roster omits the model: absence of a roster is unknown, never a denial, because
|
|
312
|
+
* rosters live in memory, are refreshed only for the account active during discovery, and expire
|
|
313
|
+
* after six hours, so a restart must not take every account out of service.
|
|
314
|
+
*
|
|
315
|
+
* A planned account with a proven omission is replaced by a usable sibling (not paused, not
|
|
316
|
+
* flagged for reauth) that lists the model, then by one whose roster is unknown; siblings out
|
|
317
|
+
* of cooldown win over cooled ones within each tier. Moving a request before upstream refused it
|
|
318
|
+
* is a proactive choice, so a replacement needs {@link isProactivePreferenceEnabled}, exactly as
|
|
319
|
+
* Kiro's known-refusal exclusion does. Otherwise, or when no sibling qualifies, the request is
|
|
320
|
+
* refused locally instead of being sent to an account known not to serve the model.
|
|
321
|
+
*/
|
|
322
|
+
export function routeAntigravityModel(
|
|
323
|
+
config: OcxConfig,
|
|
324
|
+
providerName: string,
|
|
325
|
+
plannedAccountId: string | null | undefined,
|
|
326
|
+
model: string | null | undefined,
|
|
327
|
+
now = Date.now(),
|
|
328
|
+
): AntigravityModelRoute {
|
|
329
|
+
if (providerName !== "google-antigravity" || !model || !plannedAccountId
|
|
330
|
+
|| accountListsModel(providerName, plannedAccountId, model) !== false) return KEEP_ANTIGRAVITY_ROUTE;
|
|
331
|
+
const serving = (getAccountSet(providerName)?.accounts ?? [])
|
|
332
|
+
.filter(account => account.id !== plannedAccountId && account.paused !== true && account.needsReauth !== true)
|
|
333
|
+
.map(account => account.id)
|
|
334
|
+
.filter(id => accountListsModel(providerName, id, model) !== false);
|
|
335
|
+
if (serving.length === 0) return { kind: "refuse", message: antigravityModelUnlistedMessage(model) };
|
|
336
|
+
if (!isProactivePreferenceEnabled(config, providerName, now)) {
|
|
337
|
+
return { kind: "refuse", message: antigravitySelectedAccountUnlistedMessage(model) };
|
|
338
|
+
}
|
|
339
|
+
const ready = new Set(eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, model)));
|
|
340
|
+
const rank = (id: string) => (ready.has(id) ? 0 : 2) + (accountListsModel(providerName, id, model) === true ? 0 : 1);
|
|
341
|
+
const best = serving.reduce((left, right) => rank(right) < rank(left) ? right : left);
|
|
342
|
+
return { kind: "reroute", accountId: best };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Client-facing refusal when failover is on and a sibling may serve the model, but the request
|
|
347
|
+
* still resolved to an omitting account (the sibling could not be resolved or admitted).
|
|
348
|
+
*/
|
|
349
|
+
export function antigravityReplacementUnavailableMessage(model: string): string {
|
|
350
|
+
return `The selected Google Antigravity account does not list the model "${model}" in its model discovery, `
|
|
351
|
+
+ "and no account that may serve it could be used for this request. Retry the request.";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The refusal for an account that already resolved, or null when it may serve the model. */
|
|
355
|
+
export function antigravityModelRefusal(
|
|
356
|
+
config: OcxConfig, providerName: string, accountId: string, model: string | null | undefined, now = Date.now(),
|
|
357
|
+
): string | null {
|
|
358
|
+
const verdict = routeAntigravityModel(config, providerName, accountId, model, now);
|
|
359
|
+
if (verdict.kind === "keep") return null;
|
|
360
|
+
// A reroute verdict exists only with failover on, so advising to enable it would be wrong.
|
|
361
|
+
return verdict.kind === "refuse" ? verdict.message : antigravityReplacementUnavailableMessage(model!);
|
|
362
|
+
}
|
|
363
|
+
|
|
285
364
|
function kiroModelListingHasRoom(config: OcxConfig, accountId: string, model: string): boolean {
|
|
286
365
|
if (kiroAccountSupportsModel(accountId, model) !== true) return false;
|
|
287
366
|
const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
|
|
@@ -320,7 +399,11 @@ export function hasEligibleGenericOAuthFailoverTarget(
|
|
|
320
399
|
const set = getAccountSet(providerName);
|
|
321
400
|
if (!set || set.accounts.length < 2) return false;
|
|
322
401
|
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
323
|
-
|
|
402
|
+
// Same model filter rotation applies, so an Antigravity sibling whose roster omits the
|
|
403
|
+
// model is not advertised as a target rotation would then refuse to pick.
|
|
404
|
+
const siblings = eligibleIdsIn(set, providerName, now, family).filter(id => id !== failedAccountId);
|
|
405
|
+
return (providerName === "google-antigravity"
|
|
406
|
+
? preferKiroModelSupport(providerName, siblings, requestedModelId) : siblings).length > 0;
|
|
324
407
|
}
|
|
325
408
|
|
|
326
409
|
/** Select a live Antigravity sibling using activation captured before the refused send. */
|
|
@@ -344,7 +427,8 @@ export function rotateAntigravityAccountOnAuthRefusal(
|
|
|
344
427
|
});
|
|
345
428
|
sweepExpiredOnWrite(now);
|
|
346
429
|
}
|
|
347
|
-
const eligible = new Set(
|
|
430
|
+
const eligible = new Set(preferKiroModelSupport("google-antigravity",
|
|
431
|
+
eligibleIdsIn(set, "google-antigravity", now, family).filter(id => id !== failedAccountId), requestedModelId));
|
|
348
432
|
const order = set.accounts.map(row => row.id);
|
|
349
433
|
const start = order.indexOf(failedAccountId);
|
|
350
434
|
const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
|