@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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.
Files changed (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -0,0 +1,139 @@
1
+ import { chmodSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+ import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
5
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
6
+ import { hardenSecretDir } from "../lib/windows-secret-acl";
7
+ import { isLinkPort } from "./ports";
8
+ import { assertSshAlias } from "./ssh-argv";
9
+
10
+ /**
11
+ * Persisted machine links. The file names hosts, ports, host-key fingerprints and data-key ids.
12
+ * It never holds a key: keys live in the running proxy's `apiKeys`, and admission reads them there.
13
+ */
14
+ export type LinkDirection = "hub-initiated" | "client-initiated";
15
+
16
+ export interface LinkRecord {
17
+ id: string;
18
+ alias: string;
19
+ direction: LinkDirection;
20
+ /**
21
+ * `ssh-keygen -lf` fingerprint the user confirmed for the client host, e.g. "SHA256:…".
22
+ * Null only for client-initiated links: the hub never opens SSH to that client.
23
+ */
24
+ hostKeyFingerprint: string | null;
25
+ /** Port the tunnel listens on at the client machine's 127.0.0.1. */
26
+ tunnelPort: number;
27
+ /** Id of the data key issued for this link. */
28
+ apiKeyId: string;
29
+ createdAt: string;
30
+ }
31
+
32
+ export interface LinkStore {
33
+ version: 1;
34
+ /** Port of the link listener at this machine's 127.0.0.1, fixed once chosen. */
35
+ listenerPort: number | null;
36
+ links: LinkRecord[];
37
+ }
38
+
39
+ export class LinkStoreError extends Error {
40
+ constructor(message: string) {
41
+ super(message);
42
+ this.name = "LinkStoreError";
43
+ }
44
+ }
45
+
46
+ export function emptyLinkStore(): LinkStore {
47
+ return { version: 1, listenerPort: null, links: [] };
48
+ }
49
+
50
+ export function newLinkId(): string {
51
+ return `lnk_${randomBytes(8).toString("hex")}`;
52
+ }
53
+
54
+ const RECORD_KEYS = new Set(["id", "alias", "direction", "hostKeyFingerprint", "tunnelPort", "apiKeyId", "createdAt"]);
55
+ const STORE_KEYS = new Set(["version", "listenerPort", "links"]);
56
+ const API_KEY_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
57
+ const FINGERPRINT = /^[A-Z0-9]+:[A-Za-z0-9+/=]{16,128}$/;
58
+
59
+ /** A trust-boundary file: an unknown field is an error, not something to drop silently. */
60
+ function assertOnlyKeys(raw: Record<string, unknown>, allowed: Set<string>, where: string): void {
61
+ for (const key of Object.keys(raw)) {
62
+ if (!allowed.has(key)) throw new LinkStoreError(`${where} has an unknown field ${JSON.stringify(key)}`);
63
+ }
64
+ }
65
+
66
+ const isListenerPort = (value: unknown): value is number =>
67
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
68
+
69
+ function parseRecord(value: unknown, index: number): LinkRecord {
70
+ const fail = (field: string): never => { throw new LinkStoreError(`links[${index}].${field} is invalid`); };
71
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new LinkStoreError(`links[${index}] is not an object`);
72
+ const raw = value as Record<string, unknown>;
73
+ assertOnlyKeys(raw, RECORD_KEYS, `links[${index}]`);
74
+ if (typeof raw.id !== "string" || !/^lnk_[0-9a-f]{16}$/.test(raw.id)) fail("id");
75
+ if (typeof raw.alias !== "string") fail("alias");
76
+ try { assertSshAlias(raw.alias as string); } catch { fail("alias"); }
77
+ if (raw.direction !== "hub-initiated" && raw.direction !== "client-initiated") fail("direction");
78
+ const fingerprint = raw.hostKeyFingerprint;
79
+ if (fingerprint === null ? raw.direction !== "client-initiated"
80
+ : typeof fingerprint !== "string" || !FINGERPRINT.test(fingerprint)) fail("hostKeyFingerprint");
81
+ if (!isLinkPort(raw.tunnelPort)) fail("tunnelPort");
82
+ if (typeof raw.apiKeyId !== "string" || !API_KEY_ID.test(raw.apiKeyId)) fail("apiKeyId");
83
+ if (typeof raw.createdAt !== "string" || Number.isNaN(Date.parse(raw.createdAt))) fail("createdAt");
84
+ return {
85
+ id: raw.id as string,
86
+ alias: raw.alias as string,
87
+ direction: raw.direction as LinkDirection,
88
+ hostKeyFingerprint: fingerprint as string | null,
89
+ tunnelPort: raw.tunnelPort as number,
90
+ apiKeyId: raw.apiKeyId as string,
91
+ createdAt: raw.createdAt as string,
92
+ };
93
+ }
94
+
95
+ export function parseLinkStore(text: string): LinkStore {
96
+ let raw: unknown;
97
+ try { raw = JSON.parse(text); } catch { throw new LinkStoreError("links.json is not valid JSON"); }
98
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new LinkStoreError("links.json is not an object");
99
+ const body = raw as Record<string, unknown>;
100
+ assertOnlyKeys(body, STORE_KEYS, "links.json");
101
+ if (body.version !== 1) throw new LinkStoreError("links.json has an unsupported version");
102
+ if (body.listenerPort !== null && !isListenerPort(body.listenerPort)) throw new LinkStoreError("listenerPort is invalid");
103
+ if (!Array.isArray(body.links)) throw new LinkStoreError("links is not an array");
104
+ const links = body.links.map(parseRecord);
105
+ const ids = new Set(links.map(link => link.id));
106
+ if (ids.size !== links.length) throw new LinkStoreError("links.json has duplicate link ids");
107
+ return { version: 1, listenerPort: body.listenerPort as number | null, links };
108
+ }
109
+
110
+ /** A missing file is an empty store. A damaged file is an error, never silently empty. */
111
+ export function readLinkStore(path: string): LinkStore {
112
+ let text: string;
113
+ try { text = readFileSync(path, "utf8"); } catch (error) {
114
+ if (isMissingPathError(error)) return emptyLinkStore();
115
+ throw error;
116
+ }
117
+ return parseLinkStore(text);
118
+ }
119
+
120
+ export function writeLinkStore(path: string, store: LinkStore): void {
121
+ const normalized = parseLinkStore(JSON.stringify(store));
122
+ const dir = dirname(path);
123
+ assertNotRealHomeUnderTest(dirname(dir));
124
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
125
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
126
+ else chmodSync(dir, 0o700);
127
+ // atomicWriteFile hardens its private temp on Windows before the rename, so only POSIX needs
128
+ // the explicit mode here.
129
+ atomicWriteFile(path, `${JSON.stringify(normalized, null, 2)}\n`);
130
+ if (process.platform !== "win32") chmodSync(path, 0o600);
131
+ }
132
+
133
+ /**
134
+ * Activation gate for the link listener: true only for a readable store with at least one link.
135
+ * A damaged store keeps the listener closed rather than guessing.
136
+ */
137
+ export function hasLinks(path: string): boolean {
138
+ try { return readLinkStore(path).links.length > 0; } catch { return false; }
139
+ }
@@ -0,0 +1,404 @@
1
+ import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../config/atomic-write";
4
+ import { linkDir, linkKnownHostsPath, linkStorePath } from "./paths";
5
+ import { buildTunnelArgv } from "./ssh-argv";
6
+ import { createSshRunner, type SshChild, type SshRunner } from "./ssh-runner";
7
+ import {
8
+ classifySshStderr,
9
+ dueForSpawn,
10
+ IDLE,
11
+ reduceTunnel,
12
+ type TunnelState,
13
+ } from "./tunnel-state";
14
+ import { emptyLinkStore, readLinkStore, type LinkRecord, type LinkStore } from "./store";
15
+
16
+ export type LinkTunnelStatus =
17
+ | {
18
+ linkId: string;
19
+ direction: "hub-initiated";
20
+ state: TunnelState;
21
+ pid: number | null;
22
+ orphan?: "orphan-unverified";
23
+ }
24
+ | {
25
+ linkId: string;
26
+ direction: "client-initiated";
27
+ state: "client-owned";
28
+ pid: null;
29
+ };
30
+
31
+ export interface LinkSupervisor {
32
+ start(): void;
33
+ ensureStarted(): Promise<void>;
34
+ reload(): Promise<void>;
35
+ stopLink(linkId: string): Promise<void>;
36
+ status(): readonly LinkTunnelStatus[];
37
+ notifyAuthenticatedRequest?(apiKeyId: string): void;
38
+ stop(): Promise<void>;
39
+ }
40
+
41
+ interface Pidfile {
42
+ version: 1;
43
+ linkId: string;
44
+ pid: number;
45
+ argv: string[];
46
+ }
47
+
48
+ export interface LinkSupervisorDeps {
49
+ readStore?: () => LinkStore;
50
+ writeStore?: (store: LinkStore) => void;
51
+ runner?: SshRunner;
52
+ now?: () => number;
53
+ setTimer?: (callback: () => void, ms: number) => ReturnType<typeof setInterval>;
54
+ clearTimer?: (timer: ReturnType<typeof setInterval>) => void;
55
+ readProcessArgv?: (pid: number) => readonly string[] | null;
56
+ killProcess?: (pid: number) => void;
57
+ pidfileDir?: string;
58
+ readPidfile?: (path: string) => Pidfile | null;
59
+ writePidfile?: (path: string, pidfile: Pidfile) => void;
60
+ removePidfile?: (path: string) => void;
61
+ platform?: NodeJS.Platform;
62
+ random?: () => number;
63
+ apiKeys?: () => readonly { id: string; name: string }[];
64
+ revokeApiKey?: (id: string) => boolean;
65
+ warn?: (message: string) => void;
66
+ }
67
+
68
+ const TIMER_MS = 1_000;
69
+ const SPAWN_GRACE_MS = 5_000;
70
+
71
+ function sameArgv(left: readonly string[], right: readonly string[]): boolean {
72
+ return left.length === right.length && left.every((value, index) => value === right[index]);
73
+ }
74
+
75
+ function parsePidfile(text: string): Pidfile | null {
76
+ try {
77
+ const raw: unknown = JSON.parse(text);
78
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
79
+ const body = raw as Record<string, unknown>;
80
+ if (body.version !== 1 || typeof body.linkId !== "string" || typeof body.pid !== "number"
81
+ || !Number.isSafeInteger(body.pid) || body.pid < 1 || !Array.isArray(body.argv)
82
+ || body.argv.some(value => typeof value !== "string")) return null;
83
+ return { version: 1, linkId: body.linkId, pid: body.pid, argv: body.argv as string[] };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function linuxProcessArgv(pid: number): readonly string[] | null {
90
+ try {
91
+ const values = readFileSync(`/proc/${pid}/cmdline`).toString().split("\0");
92
+ if (values.at(-1) === "") values.pop();
93
+ return values.length > 0 ? values : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function defaultKillProcess(pid: number): void {
100
+ try { process.kill(pid, "SIGTERM"); } catch { /* the process may already have exited */ }
101
+ }
102
+
103
+ export function createLinkSupervisor(deps: LinkSupervisorDeps = {}): LinkSupervisor {
104
+ const storePath = linkStorePath();
105
+ const storeDir = deps.pidfileDir ?? linkDir();
106
+ const readStore = deps.readStore ?? (() => readLinkStore(storePath));
107
+ const runner = deps.runner ?? createSshRunner();
108
+ const now = deps.now ?? (() => Date.now());
109
+ const setTimer = deps.setTimer ?? ((callback, ms) => setInterval(callback, ms));
110
+ const clearTimer = deps.clearTimer ?? ((timer: ReturnType<typeof setInterval>) => clearInterval(timer));
111
+ const platform = deps.platform ?? process.platform;
112
+ const readProcessArgv = deps.readProcessArgv ?? (platform === "linux" ? linuxProcessArgv : () => null);
113
+ const killProcess = deps.killProcess ?? defaultKillProcess;
114
+ const readPidfile = deps.readPidfile ?? ((path: string) => {
115
+ try { return parsePidfile(readFileSync(path, "utf8")); } catch { return null; }
116
+ });
117
+ const writePidfile = deps.writePidfile ?? ((path: string, pidfile: Pidfile) => {
118
+ mkdirSync(storeDir, { recursive: true, mode: 0o700 });
119
+ atomicWriteFile(path, `${JSON.stringify(pidfile)}\n`);
120
+ });
121
+ const removePidfile = deps.removePidfile ?? ((path: string) => {
122
+ try { unlinkSync(path); } catch (error) {
123
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
124
+ }
125
+ });
126
+ const warn = deps.warn ?? ((message: string) => console.warn(message));
127
+
128
+ let store: LinkStore = emptyLinkStore();
129
+ let started = false;
130
+ let stopping = false;
131
+ let lifecycleFlight: Promise<void> | undefined;
132
+ let timer: ReturnType<typeof setInterval> | undefined;
133
+ const states = new Map<string, TunnelState>();
134
+ const children = new Map<string, { child: SshChild; argv: readonly string[] }>();
135
+ const orphanUnverified = new Set<string>();
136
+ const recordInstances = new Map<string, string>();
137
+
138
+ const reconcileApiKeys = (): void => {
139
+ const keys = deps.apiKeys?.() ?? [];
140
+ if (!deps.revokeApiKey || keys.length === 0) return;
141
+ const referenced = new Set(store.links.map(link => link.apiKeyId));
142
+ for (const key of keys) {
143
+ if (!key.name.startsWith("link:") || referenced.has(key.id)) continue;
144
+ try {
145
+ if (deps.revokeApiKey(key.id)) warn(`[link] revoked orphaned link key id=${key.id}`);
146
+ } catch (error) {
147
+ warn(`[link] orphaned link key revoke failed id=${key.id}: ${error instanceof Error ? error.message : String(error)}`);
148
+ }
149
+ }
150
+ };
151
+
152
+ const pidfilePath = (linkId: string): string => join(storeDir, `${linkId}.pid`);
153
+
154
+ const recordInstance = (record: LinkRecord, listenerPort: number | null): string => JSON.stringify([
155
+ record.id,
156
+ record.alias,
157
+ record.direction,
158
+ record.hostKeyFingerprint,
159
+ record.tunnelPort,
160
+ record.apiKeyId,
161
+ record.createdAt,
162
+ listenerPort,
163
+ ]);
164
+
165
+ const conditionalRemovePidfile = (linkId: string, pid: number): void => {
166
+ const path = pidfilePath(linkId);
167
+ const current = readPidfile(path);
168
+ if (current?.linkId === linkId && current.pid === pid) removePidfile(path);
169
+ };
170
+
171
+ const reapOrphans = (): void => {
172
+ for (const record of store.links) {
173
+ const pidfile = readPidfile(pidfilePath(record.id));
174
+ if (!pidfile || pidfile.linkId !== record.id) continue;
175
+ // macOS deliberately has no exact argv source here. A ps rendering is not an identity proof.
176
+ if (platform !== "linux") {
177
+ orphanUnverified.add(record.id);
178
+ continue;
179
+ }
180
+ const actualArgv = readProcessArgv(pidfile.pid);
181
+ if (actualArgv && sameArgv(actualArgv, pidfile.argv)) {
182
+ try { killProcess(pidfile.pid); } finally { removePidfile(pidfilePath(record.id)); }
183
+ } else {
184
+ orphanUnverified.add(record.id);
185
+ }
186
+ }
187
+ };
188
+
189
+ const setEvent = (linkId: string, event: Parameters<typeof reduceTunnel>[1]): TunnelState => {
190
+ const next = reduceTunnel(states.get(linkId) ?? IDLE, event, deps.random);
191
+ states.set(linkId, next);
192
+ return next;
193
+ };
194
+
195
+ const spawnFor = (record: LinkRecord): void => {
196
+ if (stopping || record.direction !== "hub-initiated" || children.has(record.id)) return;
197
+ if (states.get(record.id)?.kind === "failed") return;
198
+ if (store.listenerPort === null) {
199
+ states.set(record.id, { kind: "failed", since: now(), reason: "forward" });
200
+ return;
201
+ }
202
+ let argv: string[];
203
+ try {
204
+ argv = buildTunnelArgv({
205
+ alias: record.alias,
206
+ direction: "R",
207
+ bindPort: record.tunnelPort,
208
+ targetPort: store.listenerPort,
209
+ knownHostsFile: linkKnownHostsPath(),
210
+ });
211
+ const child = runner.spawnTunnel(argv);
212
+ children.set(record.id, { child, argv });
213
+ orphanUnverified.delete(record.id);
214
+ setEvent(record.id, { type: "spawn", now: now() });
215
+ writePidfile(pidfilePath(record.id), { version: 1, linkId: record.id, pid: child.pid, argv });
216
+ void child.exited.then(async () => {
217
+ if (children.get(record.id)?.child !== child) return;
218
+ children.delete(record.id);
219
+ conditionalRemovePidfile(record.id, child.pid);
220
+ if (stopping) return;
221
+ const stderr = child.stderr ? await child.stderr : "";
222
+ const next = setEvent(record.id, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) });
223
+ if (next.kind === "failed") return;
224
+ }).catch(() => {
225
+ if (children.get(record.id)?.child !== child) return;
226
+ children.delete(record.id);
227
+ conditionalRemovePidfile(record.id, child.pid);
228
+ if (!stopping) setEvent(record.id, { type: "exit", now: now(), stderrClass: "network" });
229
+ });
230
+ } catch {
231
+ states.set(record.id, { kind: "failed", since: now(), reason: "forward" });
232
+ }
233
+ };
234
+
235
+ const tick = (): void => {
236
+ if (stopping) return;
237
+ const current = now();
238
+ for (const record of store.links) {
239
+ if (record.direction !== "hub-initiated") continue;
240
+ const next = setEvent(record.id, { type: "tick", now: current });
241
+ const child = children.get(record.id);
242
+ if (next.kind === "failed" && child) {
243
+ child.child.kill("SIGTERM");
244
+ children.delete(record.id);
245
+ 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
+ } else if (dueForSpawn(next, current)) {
249
+ spawnFor(record);
250
+ }
251
+ }
252
+ };
253
+
254
+ const syncTimer = (): void => {
255
+ const hasHubLinks = store.links.some(record => record.direction === "hub-initiated");
256
+ if (hasHubLinks && timer === undefined && !stopping) timer = setTimer(tick, TIMER_MS);
257
+ if (!hasHubLinks && timer !== undefined) {
258
+ clearTimer(timer);
259
+ timer = undefined;
260
+ }
261
+ };
262
+
263
+ const begin = (): void => {
264
+ if (started || stopping) return;
265
+ store = readStore();
266
+ reconcileApiKeys();
267
+ reapOrphans();
268
+ started = true;
269
+ for (const record of store.links) recordInstances.set(record.id, recordInstance(record, store.listenerPort));
270
+ for (const record of store.links) {
271
+ if (record.direction === "hub-initiated") spawnFor(record);
272
+ }
273
+ syncTimer();
274
+ };
275
+
276
+ let queuedLifecycle: (() => void | Promise<void>) | undefined;
277
+ const runLifecycle = (operation: () => void | Promise<void>): Promise<void> => {
278
+ if (lifecycleFlight) {
279
+ queuedLifecycle = operation;
280
+ return lifecycleFlight;
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;
292
+ });
293
+ return lifecycleFlight;
294
+ };
295
+
296
+ const stopLink = async (linkId: string): Promise<void> => {
297
+ const current = children.get(linkId);
298
+ if (!current) {
299
+ states.set(linkId, IDLE);
300
+ return;
301
+ }
302
+ children.delete(linkId);
303
+ current.child.kill("SIGTERM");
304
+ await current.child.exited;
305
+ conditionalRemovePidfile(linkId, current.child.pid);
306
+ states.set(linkId, IDLE);
307
+ };
308
+
309
+ return {
310
+ start() {
311
+ if (lifecycleFlight) return;
312
+ begin();
313
+ },
314
+ ensureStarted() {
315
+ return runLifecycle(begin);
316
+ },
317
+ async reload() {
318
+ await runLifecycle(async () => {
319
+ if (stopping) return;
320
+ if (!started) {
321
+ begin();
322
+ return;
323
+ }
324
+ const nextStore = readStore();
325
+ const nextInstances = new Map(nextStore.links.map(record => [record.id, recordInstance(record, nextStore.listenerPort)]));
326
+ const changed = new Set<string>();
327
+ for (const record of nextStore.links) {
328
+ if (recordInstances.get(record.id) !== undefined && recordInstances.get(record.id) !== nextInstances.get(record.id)) {
329
+ changed.add(record.id);
330
+ }
331
+ }
332
+ store = nextStore;
333
+ for (const [linkId] of [...children]) {
334
+ if (nextInstances.has(linkId) && !changed.has(linkId)) continue;
335
+ await stopLink(linkId);
336
+ states.delete(linkId);
337
+ orphanUnverified.delete(linkId);
338
+ recordInstances.delete(linkId);
339
+ if (stopping) return;
340
+ }
341
+ for (const record of nextStore.links) {
342
+ if (changed.has(record.id)) {
343
+ states.delete(record.id);
344
+ orphanUnverified.delete(record.id);
345
+ }
346
+ }
347
+ recordInstances.clear();
348
+ for (const [linkId, instance] of nextInstances) recordInstances.set(linkId, instance);
349
+ for (const record of nextStore.links) {
350
+ if (stopping || record.direction !== "hub-initiated" || children.has(record.id)) continue;
351
+ spawnFor(record);
352
+ }
353
+ syncTimer();
354
+ });
355
+ },
356
+ stopLink,
357
+ notifyAuthenticatedRequest(apiKeyId: string) {
358
+ for (const record of store.links) {
359
+ if (record.direction === "hub-initiated" && record.apiKeyId === apiKeyId && children.has(record.id)) {
360
+ const state = states.get(record.id);
361
+ if (state?.kind === "connecting" || state?.kind === "reconnecting") setEvent(record.id, { type: "ready", now: now() });
362
+ }
363
+ }
364
+ },
365
+ status() {
366
+ return store.links.map(record => {
367
+ if (record.direction === "client-initiated") {
368
+ return { linkId: record.id, direction: record.direction, state: "client-owned", pid: null };
369
+ }
370
+ const child = children.get(record.id);
371
+ return {
372
+ linkId: record.id,
373
+ direction: record.direction,
374
+ state: states.get(record.id) ?? IDLE,
375
+ pid: child?.child.pid ?? null,
376
+ ...(orphanUnverified.has(record.id) ? { orphan: "orphan-unverified" as const } : {}),
377
+ };
378
+ });
379
+ },
380
+ async stop() {
381
+ stopping = true;
382
+ if (timer !== undefined) {
383
+ clearTimer(timer);
384
+ timer = undefined;
385
+ }
386
+ const active = [...children.entries()];
387
+ for (const [linkId, current] of active) {
388
+ children.delete(linkId);
389
+ current.child.kill("SIGTERM");
390
+ }
391
+ await Promise.all(active.map(async ([linkId, current]) => {
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;
400
+ },
401
+ };
402
+ }
403
+
404
+ export type { Pidfile };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Lifecycle of one link tunnel as a pure reducer. The supervisor that spawns ssh feeds events in
3
+ * and reads decisions out; nothing here touches a process, a timer or the clock.
4
+ *
5
+ * - connected: the forward is up.
6
+ * - reconnecting: a transient failure; requests through the link fail with 503 meanwhile, and a
7
+ * new attempt is due at `retryAt`.
8
+ * - failed: needs the user. Auth, host key and forward failures are not retried, and neither is a
9
+ * link that stayed down for FAILED_AFTER_MS.
10
+ */
11
+
12
+ export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
13
+ export type StderrClass = "auth" | "hostkey" | "forward" | "network" | "unknown";
14
+
15
+ export type TunnelState =
16
+ | { kind: "idle" }
17
+ | { kind: "connecting"; since: number }
18
+ | { kind: "connected"; since: number }
19
+ | { kind: "reconnecting"; since: number; attempt: number; retryAt: number; inFlight: boolean }
20
+ | { kind: "failed"; since: number; reason: TunnelFailure };
21
+
22
+ export type TunnelEvent =
23
+ | { type: "spawn"; now: number }
24
+ | { type: "ready"; now: number }
25
+ | { type: "exit"; now: number; stderrClass: StderrClass }
26
+ | { type: "tick"; now: number }
27
+ | { type: "stop" };
28
+
29
+ export const FAILED_AFTER_MS = 5 * 60_000;
30
+ export const BASE_DELAY_MS = 1_000;
31
+ export const MAX_DELAY_MS = 30_000;
32
+
33
+ export const IDLE: TunnelState = { kind: "idle" };
34
+
35
+ /** Capped exponential backoff with ±20% jitter. `random` is injectable for tests. */
36
+ export function nextDelayMs(attempt: number, random: () => number = Math.random): number {
37
+ const exponent = Math.max(0, Math.min(attempt - 1, 16));
38
+ const base = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** exponent);
39
+ const jitter = 0.8 + random() * 0.4;
40
+ return Math.round(Math.min(MAX_DELAY_MS, base * jitter));
41
+ }
42
+
43
+ export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: () => number): TunnelState {
44
+ if (event.type === "stop") return IDLE;
45
+ switch (event.type) {
46
+ case "spawn":
47
+ if (state.kind === "idle" || state.kind === "failed") return { kind: "connecting", since: event.now };
48
+ if (state.kind === "reconnecting") return { ...state, inFlight: true };
49
+ return state;
50
+ case "ready":
51
+ if (state.kind === "connecting" || state.kind === "reconnecting") return { kind: "connected", since: event.now };
52
+ return state;
53
+ case "exit": {
54
+ if (state.kind === "idle" || state.kind === "failed") return state;
55
+ const cls = event.stderrClass;
56
+ if (cls === "auth" || cls === "hostkey" || cls === "forward") return { kind: "failed", since: event.now, reason: cls };
57
+ const since = state.kind === "reconnecting" || state.kind === "connecting" ? state.since : event.now;
58
+ if (event.now - since >= FAILED_AFTER_MS) return { kind: "failed", since: event.now, reason: "timeout" };
59
+ const attempt = state.kind === "reconnecting" ? state.attempt + 1 : 1;
60
+ return { kind: "reconnecting", since, attempt, retryAt: event.now + nextDelayMs(attempt, random), inFlight: false };
61
+ }
62
+ case "tick":
63
+ // An attempt in flight does not pause the clock: a first attempt or a retry that hangs past
64
+ // the limit still fails the link, and the supervisor kills the child on seeing `failed`.
65
+ if ((state.kind === "reconnecting" || state.kind === "connecting") && event.now - state.since >= FAILED_AFTER_MS) {
66
+ return { kind: "failed", since: event.now, reason: "timeout" };
67
+ }
68
+ return state;
69
+ }
70
+ }
71
+
72
+ /** Whether the supervisor should start a new ssh attempt now. */
73
+ export function dueForSpawn(state: TunnelState, now: number): boolean {
74
+ return state.kind === "reconnecting" && !state.inFlight && now >= state.retryAt;
75
+ }
76
+
77
+ /** Classify ssh stderr. Anything unrecognised is treated as transient. */
78
+ export function classifySshStderr(stderr: string): StderrClass {
79
+ const text = stderr.toLowerCase();
80
+ if (text.includes("host key verification failed") || text.includes("remote host identification has changed")
81
+ || text.includes("no ecdsa host key is known") || text.includes("no ed25519 host key is known")
82
+ || text.includes("no rsa host key is known") || text.includes("host key is known for")) return "hostkey";
83
+ if (text.includes("permission denied") || text.includes("too many authentication failures")) return "auth";
84
+ if (text.includes("remote port forwarding failed") || text.includes("port forwarding failed")
85
+ || text.includes("address already in use") || text.includes("cannot listen to port")
86
+ || text.includes("could not request local forwarding")) return "forward";
87
+ if (text.includes("connection refused") || text.includes("timed out") || text.includes("could not resolve")
88
+ || text.includes("network is unreachable") || text.includes("connection closed") || text.includes("broken pipe")
89
+ || text.includes("connection reset") || text.includes("no route to host")) return "network";
90
+ return "unknown";
91
+ }