@bitkyc08/opencodex 2.82.0 → 2.84.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-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/cursor/catalog.ts +2 -2
- package/src/adapters/cursor/effort-map.ts +1 -1
- package/src/adapters/devin/live-models.ts +2 -2
- package/src/adapters/google-truncation.ts +22 -0
- package/src/adapters/google.ts +7 -3
- package/src/adapters/ollama-native.ts +47 -15
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -0
- package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
- package/src/adapters/openai-responses/reasoning.ts +11 -1
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +26 -1
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +57 -4
- package/src/claude/desktop-picker.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +44 -12
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/resolve.ts +1 -1
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export/model-metadata.ts +16 -0
- package/src/clients/config-export/zcode-store.ts +46 -11
- package/src/clients/config-export/zcode.ts +3 -5
- package/src/clients/config-export.ts +47 -17
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/pool-mode-gate.ts +4 -5
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/auth-context.ts +8 -3
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +17 -3
- package/src/codex/catalog/provider-models.ts +25 -0
- package/src/codex/desktop-app/darwin.ts +1 -1
- package/src/codex/desktop-app/handoff.ts +1 -1
- package/src/codex/desktop-app/linux.ts +1 -1
- package/src/codex/desktop-app/lock.ts +1 -1
- package/src/codex/desktop-app/types.ts +1 -1
- package/src/codex/desktop-app/windows.ts +1 -1
- package/src/codex/desktop-app-restart.ts +1 -1
- package/src/codex/main-account.ts +6 -7
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +373 -217
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/inject.ts +245 -221
- package/src/grok/reset-coupon-ledger.ts +293 -57
- package/src/grok/reset-coupons.ts +15 -6
- package/src/integrations/cursor-detect.ts +1 -1
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/errors.ts +25 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +25 -16
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/meta-muse-device.ts +1 -1
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +3 -1
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-fast.ts +1 -1
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/devin-cli-authmode-migration.ts +1 -1
- package/src/providers/devin-provider-merge-migration.ts +1 -1
- package/src/providers/github-copilot-auto.ts +169 -0
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-presets.ts +1 -1
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/muse-subscription-usage.ts +1 -1
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +20 -20
- package/src/providers/registry/entries-extended.ts +4 -1
- package/src/providers/registry/model-seeds.ts +20 -3
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/providers/xai-fast-model.ts +1 -1
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/inbound-body-admission.ts +24 -7
- package/src/server/index/link-listener.ts +30 -17
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +21 -2
- package/src/server/index.ts +1 -1
- package/src/server/lifecycle.ts +33 -5
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +333 -57
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +92 -390
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +7 -5
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +4 -0
- package/src/server/relay.ts +18 -7
- package/src/server/request-log-failure-attribution.ts +3 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +243 -0
- package/src/server/request-log.ts +15 -118
- package/src/server/responses/adapter-continuation.ts +9 -1
- package/src/server/responses/adapter-dispatch.ts +293 -81
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +129 -50
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-opaque-recovery.ts +5 -5
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core.ts +6 -6
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/empty-completion-guard.ts +18 -2
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/jev-model-invoke.ts +1 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +130 -64
- package/src/server/responses/passthrough-execution.ts +3 -3
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +131 -13
- package/src/server/responses/run-turn-execution.ts +13 -5
- package/src/server/responses/sidecar-execution.ts +16 -2
- package/src/server/responses/spend-storage-error.ts +33 -0
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/types/request.ts +5 -0
- package/src/usage/expected-prices.ts +5 -1
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
- package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
- package/gui/dist/assets/index-D3FBCNiP.js +0 -94
package/src/link/ssh-runner.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
|
|
4
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
const TUNNEL_OUTPUT_EXIT_GRACE_MS = 250;
|
|
4
6
|
const HINT_MAX_CHARS = 160;
|
|
5
7
|
// Written as escapes on purpose: invisible and bidi controls must never sit literally in source.
|
|
6
8
|
const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g;
|
|
@@ -93,10 +95,21 @@ export class SshRunnerError extends Error {
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise<string> {
|
|
98
|
+
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true, exited?: Promise<number>, signal?: AbortSignal): Promise<string> {
|
|
97
99
|
const reader = stream.getReader();
|
|
98
100
|
const chunks: Uint8Array[] = [];
|
|
99
101
|
let total = 0;
|
|
102
|
+
let finished = false;
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
const cancel = (): void => { void reader.cancel().catch(() => {}); };
|
|
105
|
+
const onExit = (): void => {
|
|
106
|
+
if (!finished) timer = setTimeout(cancel, TUNNEL_OUTPUT_EXIT_GRACE_MS);
|
|
107
|
+
};
|
|
108
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
109
|
+
if (signal?.aborted) cancel();
|
|
110
|
+
// A descendant may inherit a pipe after ssh exits. Cancellation completes pending reads
|
|
111
|
+
// immediately; do not await the underlying source's possibly unbounded cancel promise.
|
|
112
|
+
void exited?.then(onExit, onExit);
|
|
100
113
|
try {
|
|
101
114
|
for (;;) {
|
|
102
115
|
const next = await reader.read();
|
|
@@ -104,11 +117,15 @@ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number,
|
|
|
104
117
|
total += next.value.byteLength;
|
|
105
118
|
if (total > maxBytes) {
|
|
106
119
|
kill?.();
|
|
120
|
+
cancel();
|
|
107
121
|
throw new SshRunnerError("output_limit", `ssh output exceeded ${maxBytes} bytes`);
|
|
108
122
|
}
|
|
109
123
|
chunks.push(next.value);
|
|
110
124
|
}
|
|
111
125
|
} finally {
|
|
126
|
+
finished = true;
|
|
127
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
128
|
+
signal?.removeEventListener("abort", cancel);
|
|
112
129
|
reader.releaseLock();
|
|
113
130
|
}
|
|
114
131
|
const bytes = new Uint8Array(total);
|
|
@@ -149,8 +166,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
149
166
|
} catch (error) {
|
|
150
167
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
151
168
|
}
|
|
152
|
-
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
153
|
-
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false);
|
|
169
|
+
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), true, child.exited);
|
|
170
|
+
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false, child.exited);
|
|
154
171
|
void stdout.catch(() => {});
|
|
155
172
|
void stderr.catch(() => {});
|
|
156
173
|
return {
|
|
@@ -165,9 +182,13 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
165
182
|
|
|
166
183
|
return {
|
|
167
184
|
async run(argv, options = {}) {
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
|
186
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
187
|
+
throw new SshRunnerError("timeout", "timeoutMs must be a positive integer at most 2147483647");
|
|
188
|
+
}
|
|
168
189
|
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_BYTES;
|
|
169
|
-
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0) {
|
|
170
|
-
throw new SshRunnerError("output_limit",
|
|
190
|
+
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0 || maxOutputBytes > MAX_OUTPUT_BYTES) {
|
|
191
|
+
throw new SshRunnerError("output_limit", `maxOutputBytes must be an integer between 0 and ${MAX_OUTPUT_BYTES}`);
|
|
171
192
|
}
|
|
172
193
|
let child: Bun.Subprocess;
|
|
173
194
|
try {
|
|
@@ -181,41 +202,50 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
181
202
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
182
203
|
}
|
|
183
204
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const input = child.stdin;
|
|
189
|
-
if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
|
|
190
|
-
await input.write(options.stdin);
|
|
191
|
-
await input.end();
|
|
192
|
-
} catch (error) {
|
|
193
|
-
child.kill("SIGTERM");
|
|
194
|
-
throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
|
199
|
-
let timedOut = false;
|
|
205
|
+
const cancellation = new AbortController();
|
|
206
|
+
let stdout: Promise<string> | undefined;
|
|
207
|
+
let stderr: Promise<string> | undefined;
|
|
200
208
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
201
209
|
const timeout = new Promise<never>((_, reject) => {
|
|
202
210
|
timer = setTimeout(() => {
|
|
203
|
-
timedOut = true;
|
|
204
|
-
defaultKill(child, "SIGTERM");
|
|
205
211
|
reject(new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`));
|
|
206
212
|
}, timeoutMs);
|
|
207
213
|
});
|
|
208
214
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
const outStream = outputStream(child.stdout), errStream = outputStream(child.stderr);
|
|
216
|
+
stdout = readOutput(outStream, maxOutputBytes, undefined, true, undefined, cancellation.signal);
|
|
217
|
+
stderr = readOutput(errStream, Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), undefined, false, undefined, cancellation.signal);
|
|
218
|
+
const stdin = async (): Promise<void> => {
|
|
219
|
+
if (options.stdin === undefined) return;
|
|
220
|
+
try {
|
|
221
|
+
const input = child.stdin;
|
|
222
|
+
if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
|
|
223
|
+
await input.write(options.stdin);
|
|
224
|
+
// A timed-out write may settle later; do not start another operation after failure.
|
|
225
|
+
if (cancellation.signal.aborted) return;
|
|
226
|
+
await input.end();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
// Observe all rejections immediately, including errors arriving after timeout. One
|
|
232
|
+
// deadline covers stdin, exit and EOF: an inherited output pipe is still unfinished work.
|
|
233
|
+
const [, code, out, err] = await Promise.race([
|
|
234
|
+
Promise.all([stdin(), child.exited, stdout, stderr]), timeout,
|
|
235
|
+
]);
|
|
212
236
|
return { code, stdout: out, stderr: err };
|
|
213
237
|
} catch (error) {
|
|
238
|
+
cancellation.abort();
|
|
239
|
+
// Only terminate this local SSH child. A kill failure must not hide the original error.
|
|
240
|
+
try { defaultKill(child, "SIGTERM"); } catch { /* Preserve the command failure when an already-gone child cannot be signalled. */ }
|
|
214
241
|
if (error instanceof SshRunnerError) throw error;
|
|
215
242
|
throw new SshRunnerError("spawn", "ssh command failed", { cause: error });
|
|
216
243
|
} finally {
|
|
217
244
|
if (timer !== undefined) clearTimeout(timer);
|
|
218
|
-
void
|
|
245
|
+
void child.exited.catch(() => {});
|
|
246
|
+
// reader.cancel closes pending reads even if the source's cancel promise never settles.
|
|
247
|
+
// Do not await stdin or process exit: neither is guaranteed to settle after SIGTERM.
|
|
248
|
+
await Promise.allSettled([stdout, stderr]);
|
|
219
249
|
}
|
|
220
250
|
},
|
|
221
251
|
spawnTunnel,
|
|
@@ -227,3 +257,4 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
227
257
|
}
|
|
228
258
|
|
|
229
259
|
export const SSH_OUTPUT_CAP_BYTES = DEFAULT_OUTPUT_BYTES;
|
|
260
|
+
export const SSH_MAX_OUTPUT_BYTES = MAX_OUTPUT_BYTES;
|
|
@@ -2,6 +2,7 @@ import type { LinkStore } from "./store";
|
|
|
2
2
|
import type { LinkTunnelStatus } from "./supervisor";
|
|
3
3
|
import type { TunnelState } from "./tunnel-state";
|
|
4
4
|
import { readCompensation, type CompensationStore } from "./compensation";
|
|
5
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
5
6
|
|
|
6
7
|
export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
|
|
7
8
|
|
|
@@ -25,6 +26,8 @@ export interface LinkStatusDto {
|
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
|
|
30
|
+
|
|
28
31
|
export interface LinkListenerStatusProjection {
|
|
29
32
|
state: "off" | "listening" | "failed";
|
|
30
33
|
port: number | null;
|
|
@@ -32,6 +35,7 @@ export interface LinkListenerStatusProjection {
|
|
|
32
35
|
|
|
33
36
|
export interface LinkStatusConfig {
|
|
34
37
|
runtimeRole?: "standalone" | "hub" | "client";
|
|
38
|
+
client?: Pick<OcxClientConnectionConfig, "transport" | "link" | "serverUrl" | "connectedAt">;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function asWireState(state: TunnelState | "client-owned"): LinkWireState {
|
|
@@ -60,7 +64,7 @@ function stateDetails(
|
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* Project private link state into the exact K16 wire DTO. `clientChild` lets a client-side caller
|
|
63
|
-
* report a child state this module cannot read itself (an invalid client sidecar); link code
|
|
67
|
+
* report a child state this module cannot read itself (including an invalid client sidecar); link code
|
|
64
68
|
* stays free of client imports, so the caller supplies it.
|
|
65
69
|
*/
|
|
66
70
|
export function projectLinkStatus(
|
|
@@ -70,6 +74,7 @@ export function projectLinkStatus(
|
|
|
70
74
|
config: LinkStatusConfig,
|
|
71
75
|
compensation: CompensationStore = readCompensation(),
|
|
72
76
|
clientChild: LinkStatusDto["child"] = null,
|
|
77
|
+
identity: ClientLinkIdentity | null = null,
|
|
73
78
|
): LinkStatusDto {
|
|
74
79
|
const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
|
|
75
80
|
const links = store.links.map(record => ({
|
|
@@ -79,18 +84,31 @@ export function projectLinkStatus(
|
|
|
79
84
|
...stateDetails(record, byId.get(record.id), compensation),
|
|
80
85
|
tunnelPort: record.tunnelPort,
|
|
81
86
|
}));
|
|
82
|
-
const childRecord = config.runtimeRole === "client"
|
|
83
|
-
|
|
87
|
+
const childRecord = config.runtimeRole === "client"
|
|
88
|
+
? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
|
|
89
|
+
: undefined;
|
|
90
|
+
const projectedClientChild = config.runtimeRole === "client" ? clientChild : null;
|
|
84
91
|
const childStatus = childRecord
|
|
85
92
|
? stateDetails(childRecord, byId.get(childRecord.id), compensation)
|
|
86
93
|
: undefined;
|
|
87
94
|
const role = config.runtimeRole === "client"
|
|
88
95
|
? "child"
|
|
89
96
|
: store.links.length > 0 ? "home" : "standalone";
|
|
97
|
+
// Home owns the record and reverse SSH process. The Child's persisted connection supplies
|
|
98
|
+
// identity and port, while an optional caller observation supplies live tunnel state.
|
|
99
|
+
const client = config.runtimeRole === "client" && config.client?.transport === "link" ? config.client : undefined;
|
|
100
|
+
const child = projectedClientChild ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus }
|
|
101
|
+
: client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
|
|
102
|
+
if (client?.link && child && identity) {
|
|
103
|
+
const active = links.find(link => link.id === client.link!.linkId);
|
|
104
|
+
const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
|
|
105
|
+
if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
|
|
106
|
+
else links.push(projected);
|
|
107
|
+
}
|
|
90
108
|
return {
|
|
91
109
|
role,
|
|
92
110
|
listener: { state: listenerStatus.state, port: listenerStatus.port },
|
|
93
111
|
links,
|
|
94
|
-
child
|
|
112
|
+
child,
|
|
95
113
|
};
|
|
96
114
|
}
|
package/src/link/supervisor.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { createSshRunner, type SshChild, type SshRunner } from "./ssh-runner";
|
|
|
7
7
|
import {
|
|
8
8
|
classifySshStderr,
|
|
9
9
|
dueForSpawn,
|
|
10
|
+
failedTunnel,
|
|
11
|
+
HOME_TUNNEL_RETRY_POLICY,
|
|
10
12
|
IDLE,
|
|
11
13
|
reduceTunnel,
|
|
12
14
|
type TunnelState,
|
|
@@ -33,6 +35,13 @@ export interface LinkSupervisor {
|
|
|
33
35
|
ensureStarted(): Promise<void>;
|
|
34
36
|
reload(): Promise<void>;
|
|
35
37
|
stopLink(linkId: string): Promise<void>;
|
|
38
|
+
/** Reserve a link against reconnects and automatic spawns for a removal transaction. */
|
|
39
|
+
holdLink?(linkId: string): () => void;
|
|
40
|
+
/**
|
|
41
|
+
* Restart one Home-initiated tunnel now: stop its child, forget the backoff or failed state,
|
|
42
|
+
* and spawn a fresh attempt. False when the link is not a Home-initiated link of this store.
|
|
43
|
+
*/
|
|
44
|
+
reconnect(linkId: string): Promise<boolean>;
|
|
36
45
|
status(): readonly LinkTunnelStatus[];
|
|
37
46
|
notifyAuthenticatedRequest?(apiKeyId: string): void;
|
|
38
47
|
stop(): Promise<void>;
|
|
@@ -67,6 +76,20 @@ export interface LinkSupervisorDeps {
|
|
|
67
76
|
|
|
68
77
|
const TIMER_MS = 1_000;
|
|
69
78
|
const SPAWN_GRACE_MS = 5_000;
|
|
79
|
+
/**
|
|
80
|
+
* A retry runs while the network may still be down, and ssh can wait out its whole ConnectTimeout
|
|
81
|
+
* (10 s for the TCP connect, the SSH handshake and key exchange, see ssh-argv.ts) before it exits.
|
|
82
|
+
* A retry is promoted only once it has outlived that, so an attempt that never reached the Child
|
|
83
|
+
* does not read connected and restart the outage clock. Like the first attempt's five seconds,
|
|
84
|
+
* liveness is a heuristic: authentication and the forward request come after ConnectTimeout, so
|
|
85
|
+
* one that stalls past the grace reads connected until ssh exits.
|
|
86
|
+
*/
|
|
87
|
+
const RETRY_GRACE_MS = 15_000;
|
|
88
|
+
|
|
89
|
+
/** A retry whose ssh child is still running: reconnecting, or failed under the retry policy. */
|
|
90
|
+
function retryInFlight(state: TunnelState): boolean {
|
|
91
|
+
return (state.kind === "reconnecting" || state.kind === "failed") && state.inFlight === true;
|
|
92
|
+
}
|
|
70
93
|
|
|
71
94
|
function sameArgv(left: readonly string[], right: readonly string[]): boolean {
|
|
72
95
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
@@ -129,10 +152,19 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
129
152
|
let started = false;
|
|
130
153
|
let stopping = false;
|
|
131
154
|
let lifecycleFlight: Promise<void> | undefined;
|
|
155
|
+
let lifecycleRevision = 0;
|
|
132
156
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
133
157
|
const states = new Map<string, TunnelState>();
|
|
134
|
-
const children = new Map<string, { child: SshChild; argv: readonly string[] }>();
|
|
158
|
+
const children = new Map<string, { child: SshChild; argv: readonly string[]; spawnedAt: number }>();
|
|
159
|
+
/** Per-link generation, advanced by spawns and explicit stop/removal reservations. */
|
|
160
|
+
const generations = new Map<string, number>();
|
|
161
|
+
const linkRevisions = new Map<string, number>();
|
|
162
|
+
/** Reconnects in flight, so overlapping requests for one link restart it once. */
|
|
163
|
+
const reconnects = new Map<string, Promise<boolean>>();
|
|
164
|
+
const holds = new Map<string, number>();
|
|
135
165
|
const orphanUnverified = new Set<string>();
|
|
166
|
+
/** The generation of the child whose exit is still being classified, per link. */
|
|
167
|
+
const exiting = new Map<string, number>();
|
|
136
168
|
const recordInstances = new Map<string, string>();
|
|
137
169
|
|
|
138
170
|
const reconcileApiKeys = (): void => {
|
|
@@ -187,14 +219,17 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
187
219
|
};
|
|
188
220
|
|
|
189
221
|
const setEvent = (linkId: string, event: Parameters<typeof reduceTunnel>[1]): TunnelState => {
|
|
190
|
-
const next = reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random);
|
|
222
|
+
const next = reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random, HOME_TUNNEL_RETRY_POLICY);
|
|
191
223
|
states.set(linkId, next);
|
|
192
224
|
return next;
|
|
193
225
|
};
|
|
194
226
|
|
|
195
227
|
const spawnFor = (record: LinkRecord): void => {
|
|
196
|
-
if (stopping || record.direction !== "hub-initiated" || children.has(record.id)) return;
|
|
197
|
-
|
|
228
|
+
if (stopping || holds.has(record.id) || record.direction !== "hub-initiated" || children.has(record.id)) return;
|
|
229
|
+
// Only a fresh link or a due attempt spawns. A reload must neither skip the backoff nor start
|
|
230
|
+
// a second child while an exited one is still reporting its stderr.
|
|
231
|
+
const prior = states.get(record.id);
|
|
232
|
+
if (prior && prior.kind !== "idle" && !dueForSpawn(prior, now())) return;
|
|
198
233
|
if (store.listenerPort === null) {
|
|
199
234
|
states.set(record.id, { kind: "failed", since: now(), reason: "forward" });
|
|
200
235
|
return;
|
|
@@ -209,7 +244,11 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
209
244
|
knownHostsFile: linkKnownHostsPath(),
|
|
210
245
|
});
|
|
211
246
|
const child = runner.spawnTunnel(argv);
|
|
212
|
-
|
|
247
|
+
const generation = (generations.get(record.id) ?? 0) + 1;
|
|
248
|
+
generations.set(record.id, generation);
|
|
249
|
+
// An older child's exit still being classified no longer speaks for this link.
|
|
250
|
+
exiting.delete(record.id);
|
|
251
|
+
children.set(record.id, { child, argv, spawnedAt: now() });
|
|
213
252
|
orphanUnverified.delete(record.id);
|
|
214
253
|
setEvent(record.id, { type: "spawn", now: now() });
|
|
215
254
|
writePidfile(pidfilePath(record.id), { version: 1, linkId: record.id, pid: child.pid, argv });
|
|
@@ -218,9 +257,17 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
218
257
|
children.delete(record.id);
|
|
219
258
|
conditionalRemovePidfile(record.id, child.pid);
|
|
220
259
|
if (stopping) return;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
260
|
+
exiting.set(record.id, generation);
|
|
261
|
+
try {
|
|
262
|
+
let stderr = "";
|
|
263
|
+
// An unreadable stderr (the runner rejects it past its size cap) still reports the exit.
|
|
264
|
+
try { stderr = child.stderr ? await child.stderr : ""; } catch { /* classified as unknown */ }
|
|
265
|
+
// A reconnect may have started a fresh child meanwhile; its own lifecycle owns the state.
|
|
266
|
+
if (generations.get(record.id) !== generation) return;
|
|
267
|
+
setEvent(record.id, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) });
|
|
268
|
+
} finally {
|
|
269
|
+
if (exiting.get(record.id) === generation) exiting.delete(record.id);
|
|
270
|
+
}
|
|
224
271
|
}).catch(() => {
|
|
225
272
|
if (children.get(record.id)?.child !== child) return;
|
|
226
273
|
children.delete(record.id);
|
|
@@ -228,23 +275,40 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
228
275
|
if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" });
|
|
229
276
|
});
|
|
230
277
|
} catch {
|
|
231
|
-
|
|
278
|
+
const at = now();
|
|
279
|
+
states.set(record.id, failedTunnel("forward", at, HOME_TUNNEL_RETRY_POLICY, prior?.kind === "failed" ? prior.since : at));
|
|
232
280
|
}
|
|
233
281
|
};
|
|
234
282
|
|
|
235
283
|
const tick = (): void => {
|
|
236
|
-
if (stopping) return;
|
|
284
|
+
if (stopping || lifecycleFlight) return;
|
|
237
285
|
const current = now();
|
|
238
286
|
for (const record of store.links) {
|
|
239
287
|
if (record.direction !== "hub-initiated") continue;
|
|
240
|
-
|
|
288
|
+
if (holds.has(record.id)) continue;
|
|
289
|
+
// The exit is still being classified. Failing the link on the outage clock now would make
|
|
290
|
+
// a later host-key or auth exit land on a failed state that ignores it.
|
|
291
|
+
if (exiting.has(record.id)) continue;
|
|
292
|
+
const before = states.get(record.id) ?? IDLE;
|
|
241
293
|
const child = children.get(record.id);
|
|
242
|
-
|
|
294
|
+
// Promotion is checked before the outage limit, so a child that has outlived its grace by the
|
|
295
|
+
// time this tick runs (at the limit, or late after a sleep) is promoted rather than killed.
|
|
296
|
+
// An idle Child may send no catalog request for hours; for a retry, staying up past the
|
|
297
|
+
// retry grace is the proof instead, as five seconds is for a first attempt.
|
|
298
|
+
if (child && ((before.kind === "connecting" && current - before.since >= SPAWN_GRACE_MS)
|
|
299
|
+
|| (retryInFlight(before) && current - child.spawnedAt >= RETRY_GRACE_MS))) {
|
|
300
|
+
setEvent(record.id, { type: "ready", now: current });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const next = setEvent(record.id, { type: "tick", now: current });
|
|
304
|
+
if (next.kind === "failed" && !next.inFlight && child && retryInFlight(before)) {
|
|
305
|
+
// A retry started just before the outage limit has not had its grace yet. It carries on as
|
|
306
|
+
// the failed state's retry instead of being killed with a link that may already be back.
|
|
307
|
+
states.set(record.id, { ...next, inFlight: true });
|
|
308
|
+
} else if (next.kind === "failed" && !next.inFlight && child) {
|
|
243
309
|
child.child.kill("SIGTERM");
|
|
244
310
|
children.delete(record.id);
|
|
245
311
|
conditionalRemovePidfile(record.id, child.child.pid);
|
|
246
|
-
} else if (next.kind === "connecting" && child && current - next.since >= SPAWN_GRACE_MS) {
|
|
247
|
-
setEvent(record.id, { type: "ready", now: current });
|
|
248
312
|
} else if (dueForSpawn(next, current)) {
|
|
249
313
|
spawnFor(record);
|
|
250
314
|
}
|
|
@@ -273,37 +337,45 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
273
337
|
syncTimer();
|
|
274
338
|
};
|
|
275
339
|
|
|
276
|
-
let queuedLifecycle: (() => void | Promise<void>) | undefined;
|
|
277
340
|
const runLifecycle = (operation: () => void | Promise<void>): Promise<void> => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
lifecycleFlight = (async () => {
|
|
283
|
-
let next: (() => void | Promise<void>) | undefined = operation;
|
|
284
|
-
while (next) {
|
|
285
|
-
await next();
|
|
286
|
-
next = queuedLifecycle;
|
|
287
|
-
queuedLifecycle = undefined;
|
|
288
|
-
}
|
|
289
|
-
})().finally(() => {
|
|
290
|
-
lifecycleFlight = undefined;
|
|
291
|
-
queuedLifecycle = undefined;
|
|
341
|
+
// Every caller keeps its own operation and completion; no later reload can replace a stop.
|
|
342
|
+
const flight = (lifecycleFlight ?? Promise.resolve()).catch(() => {}).then(operation).finally(() => {
|
|
343
|
+
if (lifecycleFlight === flight) lifecycleFlight = undefined;
|
|
292
344
|
});
|
|
293
|
-
|
|
345
|
+
lifecycleFlight = flight;
|
|
346
|
+
return flight;
|
|
294
347
|
};
|
|
295
348
|
|
|
296
|
-
const
|
|
349
|
+
const stopChild = async (linkId: string): Promise<void> => {
|
|
297
350
|
const current = children.get(linkId);
|
|
298
351
|
if (!current) {
|
|
299
352
|
states.set(linkId, IDLE);
|
|
300
353
|
return;
|
|
301
354
|
}
|
|
355
|
+
const generation = generations.get(linkId);
|
|
302
356
|
children.delete(linkId);
|
|
303
357
|
current.child.kill("SIGTERM");
|
|
304
358
|
await current.child.exited;
|
|
305
359
|
conditionalRemovePidfile(linkId, current.child.pid);
|
|
306
|
-
|
|
360
|
+
// A stop or removal reservation made while this child exited owns the next state.
|
|
361
|
+
if (generations.get(linkId) === generation) states.set(linkId, IDLE);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const invalidateLink = (linkId: string): void => {
|
|
365
|
+
linkRevisions.set(linkId, (linkRevisions.get(linkId) ?? 0) + 1);
|
|
366
|
+
generations.set(linkId, (generations.get(linkId) ?? 0) + 1);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const holdLink = (linkId: string): (() => void) => {
|
|
370
|
+
holds.set(linkId, (holds.get(linkId) ?? 0) + 1);
|
|
371
|
+
invalidateLink(linkId);
|
|
372
|
+
let released = false;
|
|
373
|
+
return () => {
|
|
374
|
+
if (released) return;
|
|
375
|
+
released = true;
|
|
376
|
+
const remaining = (holds.get(linkId) ?? 1) - 1;
|
|
377
|
+
if (remaining) holds.set(linkId, remaining); else holds.delete(linkId);
|
|
378
|
+
};
|
|
307
379
|
};
|
|
308
380
|
|
|
309
381
|
return {
|
|
@@ -315,6 +387,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
315
387
|
return runLifecycle(begin);
|
|
316
388
|
},
|
|
317
389
|
async reload() {
|
|
390
|
+
lifecycleRevision += 1;
|
|
318
391
|
await runLifecycle(async () => {
|
|
319
392
|
if (stopping) return;
|
|
320
393
|
if (!started) {
|
|
@@ -332,7 +405,7 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
332
405
|
store = nextStore;
|
|
333
406
|
for (const [linkId] of [...children]) {
|
|
334
407
|
if (nextInstances.has(linkId) && !changed.has(linkId)) continue;
|
|
335
|
-
await
|
|
408
|
+
await stopChild(linkId);
|
|
336
409
|
states.delete(linkId);
|
|
337
410
|
orphanUnverified.delete(linkId);
|
|
338
411
|
recordInstances.delete(linkId);
|
|
@@ -353,12 +426,42 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
353
426
|
syncTimer();
|
|
354
427
|
});
|
|
355
428
|
},
|
|
356
|
-
stopLink
|
|
429
|
+
stopLink(linkId: string) {
|
|
430
|
+
invalidateLink(linkId);
|
|
431
|
+
return runLifecycle(() => stopChild(linkId));
|
|
432
|
+
},
|
|
433
|
+
holdLink,
|
|
434
|
+
reconnect(linkId: string) {
|
|
435
|
+
const inFlight = reconnects.get(linkId);
|
|
436
|
+
if (inFlight) return inFlight;
|
|
437
|
+
const linkRevision = linkRevisions.get(linkId);
|
|
438
|
+
let restarted = false;
|
|
439
|
+
const flight = runLifecycle(async () => {
|
|
440
|
+
if (stopping || !started || holds.has(linkId) || linkRevisions.get(linkId) !== linkRevision) return;
|
|
441
|
+
const record = store.links.find(link => link.id === linkId && link.direction === "hub-initiated");
|
|
442
|
+
if (!record) return;
|
|
443
|
+
// A preceding reload may have spawned a new generation; it is the current one we drain.
|
|
444
|
+
const generation = generations.get(linkId);
|
|
445
|
+
const revision = lifecycleRevision;
|
|
446
|
+
const instance = recordInstance(record, store.listenerPort);
|
|
447
|
+
await stopChild(linkId);
|
|
448
|
+
const latest = readStore();
|
|
449
|
+
const fresh = latest.links.find(link => link.id === linkId && link.direction === "hub-initiated");
|
|
450
|
+
if (stopping || holds.has(linkId) || generations.get(linkId) !== generation
|
|
451
|
+
|| lifecycleRevision !== revision || !fresh || recordInstance(fresh, latest.listenerPort) !== instance) return;
|
|
452
|
+
spawnFor(fresh);
|
|
453
|
+
restarted = true;
|
|
454
|
+
}).then(() => restarted).finally(() => { reconnects.delete(linkId); });
|
|
455
|
+
reconnects.set(linkId, flight);
|
|
456
|
+
return flight;
|
|
457
|
+
},
|
|
357
458
|
notifyAuthenticatedRequest(apiKeyId: string) {
|
|
358
459
|
for (const record of store.links) {
|
|
359
460
|
if (record.direction === "hub-initiated" && record.apiKeyId === apiKeyId && children.has(record.id)) {
|
|
360
461
|
const state = states.get(record.id);
|
|
361
|
-
if (state?.kind === "connecting" || state?.kind === "reconnecting"
|
|
462
|
+
if (state?.kind === "connecting" || state?.kind === "reconnecting" || (state && retryInFlight(state))) {
|
|
463
|
+
setEvent(record.id, { type: "ready", now: now() });
|
|
464
|
+
}
|
|
362
465
|
}
|
|
363
466
|
}
|
|
364
467
|
},
|
|
@@ -383,20 +486,12 @@ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervi
|
|
|
383
486
|
clearTimer(timer);
|
|
384
487
|
timer = undefined;
|
|
385
488
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
await current.child.exited;
|
|
393
|
-
conditionalRemovePidfile(linkId, current.child.pid);
|
|
394
|
-
states.set(linkId, IDLE);
|
|
395
|
-
}));
|
|
396
|
-
for (const record of store.links) {
|
|
397
|
-
if (record.direction === "hub-initiated") states.set(record.id, IDLE);
|
|
398
|
-
}
|
|
399
|
-
if (lifecycleFlight) await lifecycleFlight;
|
|
489
|
+
await runLifecycle(async () => {
|
|
490
|
+
await Promise.all([...children.keys()].map(stopChild));
|
|
491
|
+
for (const record of store.links) {
|
|
492
|
+
if (record.direction === "hub-initiated") states.set(record.id, IDLE);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
400
495
|
},
|
|
401
496
|
};
|
|
402
497
|
}
|
package/src/link/tunnel-state.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* - reconnecting: a transient failure; requests through the link fail with 503 meanwhile, and a
|
|
7
7
|
* new attempt is due at `retryAt`.
|
|
8
8
|
* - failed: auth, host key and forward failures, and a link that stayed down for FAILED_AFTER_MS.
|
|
9
|
-
* Without a retry policy (the Home's `-R` supervisor
|
|
10
|
-
* own `-L` tunnel) a reason that has a delay is tried again at `retryAt`, and
|
|
11
|
-
* runs with `inFlight` while the state still reads failed; a reason without a
|
|
12
|
-
* terminal.
|
|
9
|
+
* Without a retry policy it needs the user. With one (the Home's `-R` supervisor and the Child's
|
|
10
|
+
* own `-L` tunnel both run with one) a reason that has a delay is tried again at `retryAt`, and
|
|
11
|
+
* the retry attempt runs with `inFlight` while the state still reads failed; a reason without a
|
|
12
|
+
* delay stays terminal.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
|
|
@@ -48,6 +48,13 @@ export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
|
|
|
48
48
|
retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 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 the session
|
|
54
|
+
* that just died heals about a minute after it clears, instead of waiting for a proxy restart.
|
|
55
|
+
*/
|
|
56
|
+
export const HOME_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = CLIENT_TUNNEL_RETRY_POLICY;
|
|
57
|
+
|
|
51
58
|
export const IDLE: TunnelState = { kind: "idle" };
|
|
52
59
|
|
|
53
60
|
/** Capped exponential backoff with ±20% jitter. `random` is injectable for tests. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect";
|
|
2
2
|
import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
|
|
3
3
|
import { credentialGeneration, type AuthStore } from "./store";
|
|
4
|
+
import { mergeOAuthPlan } from "./plan";
|
|
4
5
|
import { REFRESH_SKEW_MS } from "./refresh-policy";
|
|
5
6
|
import type { OAuthCredentials, ProviderAccountSet } from "./types";
|
|
6
7
|
import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
|
|
@@ -30,7 +31,7 @@ export async function newerClaudeCredentialForInstance(
|
|
|
30
31
|
const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access);
|
|
31
32
|
if (hasClaudeCredentialContinuity(stored, disk)) {
|
|
32
33
|
return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
|
|
33
|
-
...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined,
|
|
34
|
+
...stored, ...disk, plan: mergeOAuthPlan(disk, stored), anthropicIdentity: disk.access === stored.access ? before : undefined,
|
|
34
35
|
} };
|
|
35
36
|
}
|
|
36
37
|
// Independent observations share a deadline through the caller signal; neither extends the other.
|
|
@@ -42,7 +43,7 @@ export async function newerClaudeCredentialForInstance(
|
|
|
42
43
|
if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration };
|
|
43
44
|
if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration };
|
|
44
45
|
return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: {
|
|
45
|
-
...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity,
|
|
46
|
+
...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, plan: newProof?.plan ?? null,
|
|
46
47
|
} };
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -75,5 +76,5 @@ export function captureAnthropicCredentialOwnerForInstance(instance: AnthropicIn
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
function identityMetadata(credential: OAuthCredentials): string {
|
|
78
|
-
return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]);
|
|
79
|
+
return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity, credential.plan]);
|
|
79
80
|
}
|
|
@@ -4,12 +4,13 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
5
5
|
import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity";
|
|
6
6
|
import type { OAuthCredentials } from "./types";
|
|
7
|
+
import { normalizeOAuthPlan } from "./plan";
|
|
7
8
|
|
|
8
9
|
const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
9
10
|
const PROFILE_TIMEOUT_MS = 10_000;
|
|
10
11
|
const PROFILE_MAX_BYTES = 65_536;
|
|
11
12
|
export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
|
|
12
|
-
export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>;
|
|
13
|
+
export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<(AnthropicIdentity & { plan?: string }) | undefined>;
|
|
13
14
|
|
|
14
15
|
export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined {
|
|
15
16
|
if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128
|
|
@@ -48,8 +49,18 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async
|
|
|
48
49
|
const data: unknown = JSON.parse(body.text);
|
|
49
50
|
if (!data || typeof data !== "object" || !("account" in data)) return undefined;
|
|
50
51
|
const account = data.account;
|
|
51
|
-
|
|
52
|
+
const identity = account && typeof account === "object" && "uuid" in account
|
|
52
53
|
? bindAnthropicIdentity(access, account.uuid) : undefined;
|
|
54
|
+
if (!identity) return undefined;
|
|
55
|
+
const organization = "organization" in data ? data.organization : undefined;
|
|
56
|
+
const org = organization && typeof organization === "object"
|
|
57
|
+
? organization as Record<string, unknown> : undefined;
|
|
58
|
+
const strip = (value: unknown, prefix: string) => {
|
|
59
|
+
const label = normalizeOAuthPlan(value);
|
|
60
|
+
return label ? normalizeOAuthPlan(label.startsWith(prefix) ? label.slice(prefix.length) : label) : undefined;
|
|
61
|
+
};
|
|
62
|
+
const plan = strip(org?.rate_limit_tier, "default_claude_") ?? strip(org?.organization_type, "claude_");
|
|
63
|
+
return { ...identity, ...(plan ? { plan } : {}) };
|
|
53
64
|
} catch {
|
|
54
65
|
// Unavailable identity is an explicit unresolved result, never an ownership guess.
|
|
55
66
|
return undefined;
|