@bitkyc08/opencodex 2.83.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.
Files changed (216) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The read-only management surface a Remote Link Child may reach on its Home (#6957).
3
+ *
4
+ * Default deny: a path is readable only when it is listed here, the method is GET or HEAD, and
5
+ * the query does not ask the Home to refresh upstream state. Everything else is refused on both
6
+ * ends: the Child's listener and CLI refuse before sending, and the Home's link listener answers
7
+ * 404 for any other path under the prefix. No route here returns a credential value; provider
8
+ * and account projections report presence and masked identities only.
9
+ *
10
+ * A link read must not change the Home. The Home runs these reads passively
11
+ * (`src/lib/passive-read.ts`): model discovery, which reaches provider upstreams and persists the
12
+ * initial model selection of a pending provider, config writes and diagnostics do not run (#6959).
13
+ * `tests/server/link-management-relay.test.ts` reads every path here and asserts that the Home
14
+ * config and its upstreams are untouched.
15
+ */
16
+ export const LINK_MANAGEMENT_PREFIX = "/v1/link-management";
17
+ /** The Home's OpenCodex-owned Codex settings a Child applies when it syncs. */
18
+ export const LINK_CODEX_SETTINGS_PATH = `${LINK_MANAGEMENT_PREFIX}/codex-settings`;
19
+
20
+ export const LINK_MANAGEMENT_READ_PATHS: readonly string[] = [
21
+ "/api/system/health",
22
+ "/api/startup-health",
23
+ "/api/providers",
24
+ "/api/settings",
25
+ "/api/usage",
26
+ "/api/effort-caps",
27
+ "/api/injection-model",
28
+ "/api/shadow-call-settings",
29
+ "/api/sidecar-settings",
30
+ "/api/v2",
31
+ "/api/provider-quotas",
32
+ "/api/codex-auth/active",
33
+ "/api/codex-auth/accounts",
34
+ "/api/oauth/accounts",
35
+ "/api/providers/keys",
36
+ "/api/oauth/providers",
37
+ "/api/catalog",
38
+ "/api/system/memory",
39
+ "/api/provider-presets",
40
+ "/api/provider-context-caps",
41
+ ];
42
+ const READ_PATHS = new Set(LINK_MANAGEMENT_READ_PATHS);
43
+ /**
44
+ * Query keys that make a read trigger upstream work on the Home: forced refreshes and the
45
+ * per-account quota probes `?quota=1` starts on the OAuth-account and key-pool reads.
46
+ * `/api/models`, `/api/model-presets`, `/api/subagent-models` and `/api/subagent-model-fallback`
47
+ * are absent for the same reason: they run live provider discovery and may persist initial model
48
+ * selection. The preset preview exists only to decide a write a Child cannot make.
49
+ */
50
+ const REFRESH_KEYS = ["refresh", "reload", "force", "quota"];
51
+
52
+ /** Is `method path?query` (a Home management path, without the link prefix) readable over the link? */
53
+ export function linkManagementReadAllowed(method: string, pathname: string, search: URLSearchParams | string = ""): boolean {
54
+ const upper = method.toUpperCase();
55
+ if (upper !== "GET" && upper !== "HEAD") return false;
56
+ if (!READ_PATHS.has(pathname)) return false;
57
+ const query = typeof search === "string" ? new URLSearchParams(search) : search;
58
+ return !REFRESH_KEYS.some(key => query.has(key));
59
+ }
60
+
61
+ /** The Home management URL a prefixed link request names, or null when it names none. */
62
+ export function linkManagementTarget(url: URL): URL | null {
63
+ if (!url.pathname.startsWith(`${LINK_MANAGEMENT_PREFIX}/api/`)) return null;
64
+ const target = new URL(url.href);
65
+ target.pathname = url.pathname.slice(LINK_MANAGEMENT_PREFIX.length);
66
+ return target;
67
+ }
68
+
69
+ /** Home side: may the hub-link listener dispatch this request to the link management handler? */
70
+ export function linkManagementRouteAllowed(url: URL, req: Request): boolean {
71
+ if (req.headers.has("upgrade")) return false;
72
+ if (url.pathname === LINK_CODEX_SETTINGS_PATH) return req.method === "GET" && url.search === "";
73
+ const target = linkManagementTarget(url);
74
+ return target !== null && linkManagementReadAllowed(req.method, target.pathname, target.searchParams);
75
+ }
76
+
77
+ export function isLinkManagementPath(pathname: string): boolean {
78
+ return pathname === LINK_MANAGEMENT_PREFIX || pathname.startsWith(`${LINK_MANAGEMENT_PREFIX}/`);
79
+ }
80
+
81
+ /**
82
+ * The one refusal every management surface on a link Child uses: the dashboard relay, the CLI,
83
+ * and the Child's listener. It names the Home and the link and never suggests a relay transport.
84
+ * `homePhrase` is "Home <name>" or "its Home" when the Child does not know the Home's name.
85
+ */
86
+ export function linkChildManagementRefusal(homePhrase: string, linkId: string, operation = "This management command"): string {
87
+ return `This machine is a Remote Link Child managed by ${homePhrase} (link ${linkId}). `
88
+ + `${operation} runs on the Home, not on this Child: use the Home's dashboard or run the command there. No request was sent.`;
89
+ }
@@ -0,0 +1,38 @@
1
+ import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
2
+
3
+ export const LINK_COMMAND_WORK_MS = 15_000;
4
+ export const LINK_COMMAND_MAX_MS = 60_000;
5
+ export const LINK_RELAY_VERIFY_MS = 15_000;
6
+ export const LINK_CATALOG_MAX_BYTES = 4 * 1024 * 1024;
7
+
8
+ /** Probe latency pays for SSH setup without making the remote work window unbounded. */
9
+ export function linkCommandBudget(latencyMs = 0): number {
10
+ const latency = Number.isFinite(latencyMs) ? Math.max(0, latencyMs) : 0;
11
+ return Math.min(LINK_COMMAND_MAX_MS, LINK_COMMAND_WORK_MS + Math.ceil(latency * 4));
12
+ }
13
+
14
+ export function linkRestartBudget(latencyMs = 0): number {
15
+ return MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + linkCommandBudget(latencyMs);
16
+ }
17
+
18
+ /** Credential-free Child HTTP probe. Ignore curlrc/proxies; never follow a redirect. */
19
+ export function remoteChildHttpArgv(port: number, path: "healthz" | "v1/catalog", timeoutMs = LINK_RELAY_VERIFY_MS): string[] {
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("invalid Child port");
21
+ const seconds = Math.max(1, Math.ceil(Math.min(LINK_RELAY_VERIFY_MS, timeoutMs) / 1000));
22
+ return ["sh", "-c", 'curl -q --noproxy "*" --silent --show-error --fail --connect-timeout 3 --max-time "$3" --write-out "\\n%{http_code}" "http://127.0.0.1:$1/$2"', "ocx-child-http", String(port), path, String(seconds)];
23
+ }
24
+
25
+ export const REMOTE_CURL_PREFLIGHT = ["sh", "-c", 'command -v curl >/dev/null 2>&1 && curl -q --version >/dev/null 2>&1', "ocx-curl-preflight"];
26
+
27
+ export function parseChildHttp(stdout: string): unknown {
28
+ const match = /\n200$/.exec(stdout.trimEnd());
29
+ if (!match) throw new Error("Child HTTP probe did not return 200");
30
+ return JSON.parse(stdout.trimEnd().slice(0, match.index));
31
+ }
32
+
33
+ export function isChildHealth(value: unknown, role: "standalone" | "client", expectedPort: number): boolean {
34
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
35
+ const body = value as Record<string, unknown>;
36
+ return body.port === expectedPort && body.service === "opencodex" && Number.isInteger(body.pid) && (body.pid as number) > 0
37
+ && (role === "client" ? body.role === "client" : (body.role === undefined || body.role === "standalone") && body.restartCapability === "v1");
38
+ }
@@ -131,15 +131,36 @@ export function buildExecArgv(options: ExecArgvOptions): string[] {
131
131
  ];
132
132
  }
133
133
 
134
+ /** The POSIX shell's exit status for a command it could not find. */
135
+ export const REMOTE_COMMAND_NOT_FOUND = 127;
136
+ /** Reserved prelude status: the remote OS cannot run the POSIX Remote Link workflow. */
137
+ export const REMOTE_PLATFORM_UNSUPPORTED = 125;
138
+
134
139
  /**
135
140
  * The remote sh script for every ocx call. `quoteRemote` single-quotes it, so the login shell
136
141
  * passes it through untouched and `$HOME`/`$PATH` expand in the remote `sh`; the arguments reach
137
- * ocx as `"$@"` without another round of parsing. The fallback directories are appended, so an
138
- * ocx the remote PATH already resolves keeps winning. A missing ocx exits 127.
142
+ * ocx as `"$@"` without another round of parsing. Desktop binaries precede PATH candidates;
143
+ * each candidate must run --version successfully. A missing runnable ocx exits 127.
139
144
  */
140
- export const REMOTE_OCX_SCRIPT = 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"';
141
- /** The POSIX shell's exit status for a command it could not find. */
142
- export const REMOTE_COMMAND_NOT_FOUND = 127;
145
+ export const REMOTE_OCX_SCRIPT = `export PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"
146
+ case "$(uname -s 2>/dev/null)" in Windows*|MSYS*|MINGW*|CYGWIN*) exit ${REMOTE_PLATFORM_UNSUPPORTED};; esac
147
+ try_ocx() {
148
+ if [ -x "$1" ] && "$1" --version >/dev/null 2>&1; then
149
+ executable=$1
150
+ shift
151
+ exec "$executable" "$@"
152
+ fi
153
+ }
154
+ for candidate in "$HOME/.opencodex-desktop/bin/ocx" "/Applications/OpenCodex.app/Contents/MacOS/ocx" "/usr/bin/ocx"; do
155
+ try_ocx "$candidate" "$@"
156
+ done
157
+ remaining=$PATH
158
+ while :; do
159
+ directory=\${remaining%%:*}
160
+ try_ocx "\${directory:-.}/ocx" "$@"
161
+ case "$remaining" in *:*) remaining=\${remaining#*:};; *) break;; esac
162
+ done
163
+ exit ${REMOTE_COMMAND_NOT_FOUND}`;
143
164
 
144
165
  export function remoteOcxArgv(args: readonly string[]): string[] {
145
166
  return ["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", ...args];
@@ -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", "maxOutputBytes must be a non-negative integer");
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 stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
185
- const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"), false);
186
- if (options.stdin !== undefined) {
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 code = await Promise.race([child.exited, timeout]);
210
- const [out, err] = await Promise.all([stdout, stderr]);
211
- if (timedOut) throw new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`);
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 Promise.allSettled([stdout, stderr, child.exited]);
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" ? store.links[0] : undefined;
83
- const invalidClientSidecar = config.runtimeRole === "client" ? clientChild : null;
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: invalidClientSidecar ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus } : null),
112
+ child,
95
113
  };
96
114
  }