@bitkyc08/opencodex 2.65.0-preview.20260925 → 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.
- package/README.md +14 -0
- package/gui/dist/assets/App-BVjA0T6O.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-DUvc_Wul.js → Tray-BCFtiWwD.js} +1 -1
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/index-vO4dgfiU.js +86 -0
- package/gui/dist/assets/{tray-data-f0lTZ4sF.js → tray-data-C4FiNaxk.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-CsYvvpr3.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-BB0iHG8-.js +0 -86
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
// Definition-site import, not the ../config barrel: the link modules stay small and load nothing
|
|
3
|
+
// from the server or the config loader.
|
|
4
|
+
import { getConfigDir } from "../config/paths";
|
|
5
|
+
|
|
6
|
+
/** Directory that holds link state: `<configDir>/link`, created with mode 0700 on first write. */
|
|
7
|
+
export function linkDir(configDir: string = getConfigDir()): string {
|
|
8
|
+
return join(configDir, "link");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Link records. Holds no secrets: data keys stay in the running proxy's `apiKeys`. */
|
|
12
|
+
export function linkStorePath(configDir?: string): string {
|
|
13
|
+
return join(linkDir(configDir), "links.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The only known_hosts file link SSH commands trust. Entries are keyed by host alias. */
|
|
17
|
+
export function linkKnownHostsPath(configDir?: string): string {
|
|
18
|
+
return join(linkDir(configDir), "known_hosts");
|
|
19
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port contract for the client side of a link: the loopback port P that the client's
|
|
3
|
+
* `ssh -L` tunnel listens on. Privileged ports are refused so that an ordinary user
|
|
4
|
+
* process can always bind it. The hub listener port L is chosen by the OS and keeps the
|
|
5
|
+
* plain 1-65535 range; it does not use this contract.
|
|
6
|
+
*/
|
|
7
|
+
export const MIN_LINK_PORT = 1024;
|
|
8
|
+
export const MAX_LINK_PORT = 65535;
|
|
9
|
+
|
|
10
|
+
export function isLinkPort(value: unknown): value is number {
|
|
11
|
+
return typeof value === "number" && Number.isInteger(value) && value >= MIN_LINK_PORT && value <= MAX_LINK_PORT;
|
|
12
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const CONTEXT_PATHS = new Set([
|
|
2
|
+
"/v1/alpha/history/v2/list_windows",
|
|
3
|
+
"/v1/alpha/history/v2/list_items",
|
|
4
|
+
"/v1/alpha/history/v2/read_item",
|
|
5
|
+
"/v1/alpha/history/v2/search_contents",
|
|
6
|
+
"/v1/alpha/notes/v2/thread_hint",
|
|
7
|
+
"/v1/alpha/notes/v2/list_files_by_prefix",
|
|
8
|
+
"/v1/alpha/notes/v2/read_file",
|
|
9
|
+
"/v1/alpha/notes/v2/search_contents",
|
|
10
|
+
"/v1/alpha/notes/v2/append_to_file",
|
|
11
|
+
"/v1/alpha/notes/v2/write_file",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/** The link socket is an HTTP data plane; every upgrade header is rejected first. */
|
|
15
|
+
export function linkRouteAllowed(url: URL, req: Request): boolean {
|
|
16
|
+
if (req.headers.has("upgrade")) return false;
|
|
17
|
+
const { pathname } = url;
|
|
18
|
+
if (pathname === "/readyz") return req.method === "GET";
|
|
19
|
+
if (pathname === "/v1/catalog" || pathname === "/v1/hub-state") {
|
|
20
|
+
return req.method === "GET" || req.method === "HEAD";
|
|
21
|
+
}
|
|
22
|
+
if (pathname === "/v1/usage" || pathname === "/v1/models") return req.method === "GET";
|
|
23
|
+
if (pathname === "/v1/responses" || pathname === "/v1/responses/compact"
|
|
24
|
+
|| pathname === "/v1/messages" || pathname === "/v1/messages/count_tokens"
|
|
25
|
+
|| pathname === "/v1/chat/completions" || pathname === "/v1/audio/transcriptions"
|
|
26
|
+
|| pathname === "/v1/alpha/search" || pathname === "/v1/images/generations"
|
|
27
|
+
|| pathname === "/v1/images/edits" || pathname === "/v1/live"
|
|
28
|
+
|| pathname === "/v1/realtime/calls" || CONTEXT_PATHS.has(pathname)) {
|
|
29
|
+
return req.method === "POST";
|
|
30
|
+
}
|
|
31
|
+
return req.method === "GET" && pathname.startsWith("/v1/opencodex/artifacts/");
|
|
32
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Argument vectors for the system OpenSSH client used by machine links.
|
|
3
|
+
*
|
|
4
|
+
* Every command is built here so the security-relevant options live in one place:
|
|
5
|
+
* - BatchMode=yes: no password or passphrase prompt, keys and agents only.
|
|
6
|
+
* - HostKeyAlias=<alias> with UserKnownHostsFile=<link known_hosts> and
|
|
7
|
+
* GlobalKnownHostsFile=none: only a host key the user confirmed for this link is trusted,
|
|
8
|
+
* and the stored entry has the same form for every port and ProxyJump route.
|
|
9
|
+
* - KnownHostsCommand=none, VerifyHostKeyDNS=no and CheckHostIP=no: no other source of host-key
|
|
10
|
+
* trust (a helper command, DNS SSHFP records, IP entries) can stand in for the link file.
|
|
11
|
+
* Command-line options win over ~/.ssh/config, so a user config cannot re-enable them.
|
|
12
|
+
* - Tunnel and exec commands use StrictHostKeyChecking=yes. Only the probe uses accept-new,
|
|
13
|
+
* against an empty temporary file, so the key it records can be shown to the user first.
|
|
14
|
+
* - Forwards always bind 127.0.0.1 on both ends.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isAbsolute } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type TunnelDirection = "R" | "L";
|
|
20
|
+
|
|
21
|
+
const ALIAS_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._@%+:\[\]-]{0,252}$/;
|
|
22
|
+
|
|
23
|
+
export class LinkSshArgumentError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "LinkSshArgumentError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A host alias as ssh reads it. A leading "-" would be parsed as an option, so it is refused. */
|
|
31
|
+
export function isSshAlias(alias: string): boolean {
|
|
32
|
+
return ALIAS_PATTERN.test(alias);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function assertSshAlias(alias: string): string {
|
|
36
|
+
if (!ALIAS_PATTERN.test(alias)) throw new LinkSshArgumentError(`invalid ssh host alias: ${JSON.stringify(alias)}`);
|
|
37
|
+
return alias;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function assertPort(port: number, label: string): number {
|
|
41
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
42
|
+
throw new LinkSshArgumentError(`${label} must be an integer port between 1 and 65535`);
|
|
43
|
+
}
|
|
44
|
+
return port;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ssh splits `-o` values on whitespace, expands `%` tokens, `${ENV}` and a leading `~` in
|
|
49
|
+
* UserKnownHostsFile, treats the value `none` as "no file" and resolves a relative path against
|
|
50
|
+
* its working directory. Any of those could name a different file than the one written, so only
|
|
51
|
+
* an absolute path without expansion syntax is accepted; whitespace is quoted.
|
|
52
|
+
*/
|
|
53
|
+
function assertKnownHostsPath(path: string): string {
|
|
54
|
+
if (!path || !isAbsolute(path) || /["%$\x00-\x1f]/.test(path) || path.startsWith("~")) {
|
|
55
|
+
throw new LinkSshArgumentError("known_hosts path is not usable in an ssh option");
|
|
56
|
+
}
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function optionPath(path: string): string {
|
|
61
|
+
assertKnownHostsPath(path);
|
|
62
|
+
return /\s/.test(path) ? `"${path}"` : path;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function commonOptions(alias: string, knownHostsFile: string, strict: "yes" | "accept-new"): string[] {
|
|
66
|
+
return [
|
|
67
|
+
"-o", "BatchMode=yes",
|
|
68
|
+
"-o", "ConnectTimeout=10",
|
|
69
|
+
"-o", "ServerAliveInterval=15",
|
|
70
|
+
"-o", "ServerAliveCountMax=3",
|
|
71
|
+
"-o", `StrictHostKeyChecking=${strict}`,
|
|
72
|
+
"-o", `HostKeyAlias=${alias}`,
|
|
73
|
+
"-o", `UserKnownHostsFile=${optionPath(knownHostsFile)}`,
|
|
74
|
+
"-o", "GlobalKnownHostsFile=none",
|
|
75
|
+
"-o", "KnownHostsCommand=none",
|
|
76
|
+
"-o", "VerifyHostKeyDNS=no",
|
|
77
|
+
"-o", "CheckHostIP=no",
|
|
78
|
+
"-o", "UpdateHostKeys=no",
|
|
79
|
+
"-o", "ControlMaster=no",
|
|
80
|
+
"-o", "ControlPath=none",
|
|
81
|
+
"-o", "ForwardAgent=no",
|
|
82
|
+
"-o", "ForwardX11=no",
|
|
83
|
+
"-o", "PermitLocalCommand=no",
|
|
84
|
+
"-o", "LogLevel=ERROR",
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TunnelArgvOptions {
|
|
89
|
+
alias: string;
|
|
90
|
+
/** "R": listen on the remote host and reach this host. "L": listen here and reach the remote host. */
|
|
91
|
+
direction: TunnelDirection;
|
|
92
|
+
/** Port the forward listens on (remote for "R", local for "L"). */
|
|
93
|
+
bindPort: number;
|
|
94
|
+
/** Port the forward connects to on the other side's 127.0.0.1. */
|
|
95
|
+
targetPort: number;
|
|
96
|
+
knownHostsFile: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildTunnelArgv(options: TunnelArgvOptions): string[] {
|
|
100
|
+
const alias = assertSshAlias(options.alias);
|
|
101
|
+
const bind = assertPort(options.bindPort, "bindPort");
|
|
102
|
+
const target = assertPort(options.targetPort, "targetPort");
|
|
103
|
+
if (options.direction !== "R" && options.direction !== "L") throw new LinkSshArgumentError("direction must be R or L");
|
|
104
|
+
return [
|
|
105
|
+
"ssh", "-N", "-T",
|
|
106
|
+
...commonOptions(alias, options.knownHostsFile, "yes"),
|
|
107
|
+
"-o", "ExitOnForwardFailure=yes",
|
|
108
|
+
`-${options.direction}`, `127.0.0.1:${bind}:127.0.0.1:${target}`,
|
|
109
|
+
"--", alias,
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ExecArgvOptions {
|
|
114
|
+
alias: string;
|
|
115
|
+
/** Remote argv. Each element is quoted for the remote POSIX shell. */
|
|
116
|
+
argv: readonly string[];
|
|
117
|
+
knownHostsFile: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function buildExecArgv(options: ExecArgvOptions): string[] {
|
|
121
|
+
const alias = assertSshAlias(options.alias);
|
|
122
|
+
if (options.argv.length === 0) throw new LinkSshArgumentError("remote argv must not be empty");
|
|
123
|
+
return [
|
|
124
|
+
"ssh", "-T",
|
|
125
|
+
...commonOptions(alias, options.knownHostsFile, "yes"),
|
|
126
|
+
"-o", "ClearAllForwardings=yes",
|
|
127
|
+
"--", alias,
|
|
128
|
+
quoteRemote(options.argv),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ProbeArgvOptions {
|
|
133
|
+
alias: string;
|
|
134
|
+
/** An empty, private, temporary file. The probe records the offered host key here. */
|
|
135
|
+
tempKnownHostsFile: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* First contact with a host that has no confirmed key. accept-new writes the offered key to the
|
|
140
|
+
* temporary file; the caller reads it with `ssh-keygen -lf` and shows the fingerprint before
|
|
141
|
+
* anything is trusted. The remote command is `true` and its result is not trusted either.
|
|
142
|
+
*/
|
|
143
|
+
export function buildProbeArgv(options: ProbeArgvOptions): string[] {
|
|
144
|
+
const alias = assertSshAlias(options.alias);
|
|
145
|
+
return [
|
|
146
|
+
"ssh", "-T",
|
|
147
|
+
...commonOptions(alias, options.tempKnownHostsFile, "accept-new"),
|
|
148
|
+
"-o", "ClearAllForwardings=yes",
|
|
149
|
+
"--", alias,
|
|
150
|
+
"true",
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Read the fingerprint of the host key recorded by a probe. */
|
|
155
|
+
export function buildFingerprintArgv(tempKnownHostsFile: string): string[] {
|
|
156
|
+
return ["ssh-keygen", "-l", "-f", assertKnownHostsPath(tempKnownHostsFile)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `ssh -G <alias>`: resolve an alias the same way ssh itself does, without connecting. */
|
|
160
|
+
export function buildResolveArgv(alias: string): string[] {
|
|
161
|
+
return ["ssh", "-G", "--", assertSshAlias(alias)];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Quote argv for a POSIX remote shell: every element single-quoted, embedded quotes escaped. */
|
|
165
|
+
export function quoteRemote(argv: readonly string[]): string {
|
|
166
|
+
return argv.map(arg => {
|
|
167
|
+
if (arg.includes("\0")) throw new LinkSshArgumentError("remote argument contains NUL");
|
|
168
|
+
return `'${arg.replaceAll("'", `'"'"'`)}'`;
|
|
169
|
+
}).join(" ");
|
|
170
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { isSshAlias } from "./ssh-argv";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Host candidates from OpenSSH client configuration.
|
|
8
|
+
*
|
|
9
|
+
* Candidates are offers, not trust: a host is usable only after a probe proves key login and the
|
|
10
|
+
* user confirms its host key. Patterns (`*`, `?`, `!`) and `Match` blocks name no single host,
|
|
11
|
+
* so they are skipped. An `Include` inside a `Host` or `Match` block applies only under that
|
|
12
|
+
* block's condition, so only top-level includes are followed.
|
|
13
|
+
*/
|
|
14
|
+
export interface HostCandidate {
|
|
15
|
+
alias: string;
|
|
16
|
+
source: "ssh_config";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ParseHostCandidatesOptions {
|
|
20
|
+
/** Contents of every file an `Include` pattern names, in order. */
|
|
21
|
+
resolveInclude?: (pattern: string) => string[];
|
|
22
|
+
/** Include nesting limit; OpenSSH uses 16. */
|
|
23
|
+
maxDepth?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Split arguments exactly the way OpenSSH's argv_split (misc.c) does when readconf calls it with
|
|
28
|
+
* terminate_on_comment set:
|
|
29
|
+
* - blanks and tabs separate arguments; an unquoted `#` at the start of an argument ends the line;
|
|
30
|
+
* - a backslash before `'`, `"` or `\\` — or, outside quotes, before a space — yields that
|
|
31
|
+
* character; any other backslash (including a trailing one) is kept literally;
|
|
32
|
+
* - single or double quotes group, and the backslash rule above applies inside them too.
|
|
33
|
+
* Returns null for an unterminated quote, which OpenSSH rejects as an invalid line.
|
|
34
|
+
*/
|
|
35
|
+
export function splitSshArgs(text: string): string[] | null {
|
|
36
|
+
const out: string[] = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < text.length) {
|
|
39
|
+
const lead = text[i];
|
|
40
|
+
if (lead === " " || lead === "\t") { i += 1; continue; }
|
|
41
|
+
if (lead === "#") break;
|
|
42
|
+
let arg = "";
|
|
43
|
+
let quote: "'" | '"' | null = null;
|
|
44
|
+
for (; i < text.length; i += 1) {
|
|
45
|
+
const ch = text[i]!;
|
|
46
|
+
const next = text[i + 1];
|
|
47
|
+
if (ch === "\\") {
|
|
48
|
+
if (next === "'" || next === '"' || next === "\\" || (quote === null && next === " ")) {
|
|
49
|
+
i += 1;
|
|
50
|
+
arg += next;
|
|
51
|
+
} else {
|
|
52
|
+
arg += ch;
|
|
53
|
+
}
|
|
54
|
+
} else if (quote === null && (ch === " " || ch === "\t")) {
|
|
55
|
+
break;
|
|
56
|
+
} else if (quote === null && (ch === '"' || ch === "'")) {
|
|
57
|
+
quote = ch;
|
|
58
|
+
} else if (quote !== null && ch === quote) {
|
|
59
|
+
quote = null;
|
|
60
|
+
} else {
|
|
61
|
+
arg += ch;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (quote !== null) return null;
|
|
65
|
+
out.push(arg);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function splitDirective(raw: string): { keyword: string; args: string[] } | null {
|
|
71
|
+
const line = raw.trim();
|
|
72
|
+
if (!line || line.startsWith("#")) return null;
|
|
73
|
+
const match = /^([A-Za-z]+)(?:\s*=\s*|\s+)(.*)$/.exec(line);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const args = splitSshArgs(match[2]!);
|
|
76
|
+
if (!args) return null;
|
|
77
|
+
return { keyword: match[1]!.toLowerCase(), args };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseHostCandidates(text: string, options: ParseHostCandidatesOptions = {}): HostCandidate[] {
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const out: HostCandidate[] = [];
|
|
83
|
+
const maxDepth = options.maxDepth ?? 16;
|
|
84
|
+
const visit = (body: string, depth: number): void => {
|
|
85
|
+
let block: "top" | "host" | "match" = "top";
|
|
86
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
87
|
+
const directive = splitDirective(raw);
|
|
88
|
+
if (!directive) continue;
|
|
89
|
+
if (directive.keyword === "match") { block = "match"; continue; }
|
|
90
|
+
if (directive.keyword === "include") {
|
|
91
|
+
if (block !== "top" || depth >= maxDepth || !options.resolveInclude) continue;
|
|
92
|
+
for (const pattern of directive.args) {
|
|
93
|
+
for (const included of options.resolveInclude(pattern)) visit(included, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (directive.keyword !== "host") continue;
|
|
98
|
+
block = "host";
|
|
99
|
+
for (const alias of directive.args) {
|
|
100
|
+
if (/[*?!]/.test(alias) || !isSshAlias(alias)) continue;
|
|
101
|
+
const key = alias.toLowerCase();
|
|
102
|
+
if (seen.has(key)) continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push({ alias, source: "ssh_config" });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
visit(text, 0);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface LoadHostCandidatesOptions {
|
|
113
|
+
/** Home directory whose `.ssh/config` is read. Defaults to the user's home. */
|
|
114
|
+
home?: string;
|
|
115
|
+
readFile?: (path: string) => string;
|
|
116
|
+
/** Expand one absolute glob pattern to file paths. */
|
|
117
|
+
glob?: (pattern: string) => string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function defaultGlob(pattern: string): string[] {
|
|
121
|
+
if (!/[*?[]/.test(pattern)) return [pattern];
|
|
122
|
+
const base = dirname(pattern.slice(0, pattern.search(/[*?[]/) + 1));
|
|
123
|
+
const relative = pattern.slice(base.length + 1);
|
|
124
|
+
return [...new Bun.Glob(relative).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read `~/.ssh/config` and its includes. A missing or unreadable file yields no candidates.
|
|
129
|
+
* Include patterns support a leading `~/`, absolute and `~/.ssh`-relative paths and globs;
|
|
130
|
+
* `~user`, environment variables and `%` tokens are not expanded, so such an include adds no
|
|
131
|
+
* candidates (the host can still be entered by hand).
|
|
132
|
+
*/
|
|
133
|
+
export function loadHostCandidates(options: LoadHostCandidatesOptions = {}): HostCandidate[] {
|
|
134
|
+
const home = options.home ?? homedir();
|
|
135
|
+
const sshDir = join(home, ".ssh");
|
|
136
|
+
const read = options.readFile ?? ((path: string) => readFileSync(path, "utf8"));
|
|
137
|
+
const glob = options.glob ?? defaultGlob;
|
|
138
|
+
const safeRead = (path: string): string | null => {
|
|
139
|
+
try { return read(path); } catch { return null; }
|
|
140
|
+
};
|
|
141
|
+
const root = safeRead(join(sshDir, "config"));
|
|
142
|
+
if (root === null) return [];
|
|
143
|
+
return parseHostCandidates(root, {
|
|
144
|
+
resolveInclude: pattern => {
|
|
145
|
+
const expanded = pattern.startsWith("~/") ? join(home, pattern.slice(2)) : pattern;
|
|
146
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(sshDir, expanded);
|
|
147
|
+
let paths: string[];
|
|
148
|
+
try { paths = glob(absolute); } catch { return []; }
|
|
149
|
+
return paths.map(safeRead).filter((body): body is string => body !== null);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
|
|
2
|
+
|
|
3
|
+
export interface SshRunResult {
|
|
4
|
+
code: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SshChild {
|
|
10
|
+
readonly pid: number;
|
|
11
|
+
readonly argv: readonly string[];
|
|
12
|
+
readonly exited: Promise<number>;
|
|
13
|
+
readonly stdout?: Promise<string>;
|
|
14
|
+
readonly stderr?: Promise<string>;
|
|
15
|
+
kill(signal?: NodeJS.Signals): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SshRunner {
|
|
19
|
+
run(argv: readonly string[], options?: {
|
|
20
|
+
stdin?: string | Uint8Array;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
maxOutputBytes?: number;
|
|
23
|
+
}): Promise<SshRunResult>;
|
|
24
|
+
spawnTunnel(argv: readonly string[]): SshChild;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class SshRunnerError extends Error {
|
|
28
|
+
constructor(readonly code: "spawn" | "timeout" | "output_limit" | "decode", message: string, options?: ErrorOptions) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "SshRunnerError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void): Promise<string> {
|
|
35
|
+
const reader = stream.getReader();
|
|
36
|
+
const chunks: Uint8Array[] = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
try {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const next = await reader.read();
|
|
41
|
+
if (next.done) break;
|
|
42
|
+
total += next.value.byteLength;
|
|
43
|
+
if (total > maxBytes) {
|
|
44
|
+
kill?.();
|
|
45
|
+
throw new SshRunnerError("output_limit", `ssh output exceeded ${maxBytes} bytes`);
|
|
46
|
+
}
|
|
47
|
+
chunks.push(next.value);
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
reader.releaseLock();
|
|
51
|
+
}
|
|
52
|
+
const bytes = new Uint8Array(total);
|
|
53
|
+
let offset = 0;
|
|
54
|
+
for (const chunk of chunks) {
|
|
55
|
+
bytes.set(chunk, offset);
|
|
56
|
+
offset += chunk.byteLength;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throw new SshRunnerError("decode", "ssh output was not valid UTF-8", { cause: error });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function outputStream(stream: Bun.Subprocess["stdout"]): ReadableStream<Uint8Array> {
|
|
66
|
+
if (!stream || typeof stream === "number") throw new SshRunnerError("spawn", "ssh output was not piped");
|
|
67
|
+
return stream;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultKill(process: Bun.Subprocess, signal?: NodeJS.Signals): void {
|
|
71
|
+
process.kill(signal);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number } = {}): SshRunner {
|
|
75
|
+
const spawn = deps.spawn ?? ((argv, options) => Bun.spawn(argv, options));
|
|
76
|
+
const defaultTimeoutMs = deps.timeoutMs ?? 30_000;
|
|
77
|
+
|
|
78
|
+
const spawnChild = (argv: readonly string[]): SshChild => {
|
|
79
|
+
let child: Bun.Subprocess;
|
|
80
|
+
try {
|
|
81
|
+
child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
84
|
+
}
|
|
85
|
+
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
86
|
+
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
87
|
+
void stdout.catch(() => {});
|
|
88
|
+
void stderr.catch(() => {});
|
|
89
|
+
return {
|
|
90
|
+
pid: child.pid,
|
|
91
|
+
argv: [...argv],
|
|
92
|
+
exited: child.exited,
|
|
93
|
+
stdout,
|
|
94
|
+
stderr,
|
|
95
|
+
kill: signal => defaultKill(child, signal),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
async run(argv, options = {}) {
|
|
101
|
+
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_BYTES;
|
|
102
|
+
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0) {
|
|
103
|
+
throw new SshRunnerError("output_limit", "maxOutputBytes must be a non-negative integer");
|
|
104
|
+
}
|
|
105
|
+
let child: Bun.Subprocess;
|
|
106
|
+
try {
|
|
107
|
+
child = spawn([...argv], {
|
|
108
|
+
stdin: options.stdin === undefined ? "ignore" : "pipe",
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "pipe",
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stdout = readOutput(outputStream(child.stdout), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
|
|
117
|
+
const stderr = readOutput(outputStream(child.stderr), Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), () => defaultKill(child, "SIGTERM"));
|
|
118
|
+
if (options.stdin !== undefined) {
|
|
119
|
+
try {
|
|
120
|
+
const input = child.stdin;
|
|
121
|
+
if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
|
|
122
|
+
await input.write(options.stdin);
|
|
123
|
+
await input.end();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
child.kill("SIGTERM");
|
|
126
|
+
throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
133
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
134
|
+
timer = setTimeout(() => {
|
|
135
|
+
timedOut = true;
|
|
136
|
+
defaultKill(child, "SIGTERM");
|
|
137
|
+
reject(new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`));
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const code = await Promise.race([child.exited, timeout]);
|
|
142
|
+
const [out, err] = await Promise.all([stdout, stderr]);
|
|
143
|
+
if (timedOut) throw new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`);
|
|
144
|
+
return { code, stdout: out, stderr: err };
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof SshRunnerError) throw error;
|
|
147
|
+
throw new SshRunnerError("spawn", "ssh command failed", { cause: error });
|
|
148
|
+
} finally {
|
|
149
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
150
|
+
void Promise.allSettled([stdout, stderr, child.exited]);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
spawnTunnel,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function spawnTunnel(argv: readonly string[]): SshChild {
|
|
157
|
+
return spawnChild(argv);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const SSH_OUTPUT_CAP_BYTES = DEFAULT_OUTPUT_BYTES;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { LinkStore } from "./store";
|
|
2
|
+
import type { LinkTunnelStatus } from "./supervisor";
|
|
3
|
+
import type { TunnelState } from "./tunnel-state";
|
|
4
|
+
import { readCompensation, type CompensationStore } from "./compensation";
|
|
5
|
+
|
|
6
|
+
export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
|
|
7
|
+
|
|
8
|
+
export interface LinkStatusDto {
|
|
9
|
+
role: "standalone" | "home" | "child";
|
|
10
|
+
listener: { state: "off" | "listening" | "failed"; port: number | null };
|
|
11
|
+
links: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
alias: string;
|
|
14
|
+
direction: "hub-initiated" | "client-initiated";
|
|
15
|
+
state: LinkWireState;
|
|
16
|
+
since: string;
|
|
17
|
+
reason: string | null;
|
|
18
|
+
tunnelPort: number;
|
|
19
|
+
}>;
|
|
20
|
+
child: null | {
|
|
21
|
+
alias: string;
|
|
22
|
+
state: LinkWireState;
|
|
23
|
+
since: string;
|
|
24
|
+
reason: string | null;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LinkListenerStatusProjection {
|
|
29
|
+
state: "off" | "listening" | "failed";
|
|
30
|
+
port: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface LinkStatusConfig {
|
|
34
|
+
runtimeRole?: "standalone" | "hub" | "client";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function asWireState(state: TunnelState | "client-owned"): LinkWireState {
|
|
38
|
+
if (state === "client-owned") return "idle";
|
|
39
|
+
return state.kind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stateDetails(
|
|
43
|
+
record: LinkStore["links"][number],
|
|
44
|
+
status: LinkTunnelStatus | undefined,
|
|
45
|
+
compensation?: CompensationStore,
|
|
46
|
+
): { state: LinkWireState; since: string; reason: string | null } {
|
|
47
|
+
const failure = compensation?.entries[record.id];
|
|
48
|
+
if (failure) return { state: "failed", since: failure.since, reason: failure.reason };
|
|
49
|
+
if (!status || status.state === "client-owned") {
|
|
50
|
+
return { state: "idle", since: record.createdAt, reason: null };
|
|
51
|
+
}
|
|
52
|
+
if (status.orphan === "orphan-unverified") {
|
|
53
|
+
return { state: "failed", since: record.createdAt, reason: "stale tunnel may hold the port" };
|
|
54
|
+
}
|
|
55
|
+
const tunnel = status.state;
|
|
56
|
+
const reason = tunnel.kind === "failed" ? tunnel.reason : null;
|
|
57
|
+
const since = tunnel.kind === "idle" ? record.createdAt : new Date(tunnel.since).toISOString();
|
|
58
|
+
return { state: asWireState(tunnel), since, reason };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 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
|
|
64
|
+
* stays free of client imports, so the caller supplies it.
|
|
65
|
+
*/
|
|
66
|
+
export function projectLinkStatus(
|
|
67
|
+
store: LinkStore,
|
|
68
|
+
supervisorStates: readonly LinkTunnelStatus[],
|
|
69
|
+
listenerStatus: LinkListenerStatusProjection,
|
|
70
|
+
config: LinkStatusConfig,
|
|
71
|
+
compensation: CompensationStore = readCompensation(),
|
|
72
|
+
clientChild: LinkStatusDto["child"] = null,
|
|
73
|
+
): LinkStatusDto {
|
|
74
|
+
const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
|
|
75
|
+
const links = store.links.map(record => ({
|
|
76
|
+
id: record.id,
|
|
77
|
+
alias: record.alias,
|
|
78
|
+
direction: record.direction,
|
|
79
|
+
...stateDetails(record, byId.get(record.id), compensation),
|
|
80
|
+
tunnelPort: record.tunnelPort,
|
|
81
|
+
}));
|
|
82
|
+
const childRecord = config.runtimeRole === "client" ? store.links[0] : undefined;
|
|
83
|
+
const invalidClientSidecar = config.runtimeRole === "client" ? clientChild : null;
|
|
84
|
+
const childStatus = childRecord
|
|
85
|
+
? stateDetails(childRecord, byId.get(childRecord.id), compensation)
|
|
86
|
+
: undefined;
|
|
87
|
+
const role = config.runtimeRole === "client"
|
|
88
|
+
? "child"
|
|
89
|
+
: store.links.length > 0 ? "home" : "standalone";
|
|
90
|
+
return {
|
|
91
|
+
role,
|
|
92
|
+
listener: { state: listenerStatus.state, port: listenerStatus.port },
|
|
93
|
+
links,
|
|
94
|
+
child: invalidClientSidecar ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus } : null),
|
|
95
|
+
};
|
|
96
|
+
}
|