@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.
- package/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +19 -0
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export.ts +46 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +311 -171
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +24 -15
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +14 -14
- package/src/providers/registry/entries-extended.ts +0 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +13 -0
- package/src/server/lifecycle.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +5 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/responses/adapter-dispatch.ts +232 -79
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -4
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +6 -8
- package/src/server/responses/sidecar-execution.ts +11 -2
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/usage/expected-prices.ts +4 -0
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- 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
|
+
}
|
package/src/link/ssh-argv.ts
CHANGED
|
@@ -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.
|
|
138
|
-
*
|
|
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 =
|
|
141
|
-
|
|
142
|
-
|
|
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];
|
package/src/link/ssh-runner.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
|
|
4
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
const TUNNEL_OUTPUT_EXIT_GRACE_MS = 250;
|
|
4
6
|
const HINT_MAX_CHARS = 160;
|
|
5
7
|
// Written as escapes on purpose: invisible and bidi controls must never sit literally in source.
|
|
6
8
|
const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g;
|
|
@@ -93,10 +95,21 @@ export class SshRunnerError extends Error {
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true): Promise<string> {
|
|
98
|
+
async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number, kill?: () => void, fatalUtf8 = true, exited?: Promise<number>, signal?: AbortSignal): Promise<string> {
|
|
97
99
|
const reader = stream.getReader();
|
|
98
100
|
const chunks: Uint8Array[] = [];
|
|
99
101
|
let total = 0;
|
|
102
|
+
let finished = false;
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
const cancel = (): void => { void reader.cancel().catch(() => {}); };
|
|
105
|
+
const onExit = (): void => {
|
|
106
|
+
if (!finished) timer = setTimeout(cancel, TUNNEL_OUTPUT_EXIT_GRACE_MS);
|
|
107
|
+
};
|
|
108
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
109
|
+
if (signal?.aborted) cancel();
|
|
110
|
+
// A descendant may inherit a pipe after ssh exits. Cancellation completes pending reads
|
|
111
|
+
// immediately; do not await the underlying source's possibly unbounded cancel promise.
|
|
112
|
+
void exited?.then(onExit, onExit);
|
|
100
113
|
try {
|
|
101
114
|
for (;;) {
|
|
102
115
|
const next = await reader.read();
|
|
@@ -104,11 +117,15 @@ async function readOutput(stream: ReadableStream<Uint8Array>, maxBytes: number,
|
|
|
104
117
|
total += next.value.byteLength;
|
|
105
118
|
if (total > maxBytes) {
|
|
106
119
|
kill?.();
|
|
120
|
+
cancel();
|
|
107
121
|
throw new SshRunnerError("output_limit", `ssh output exceeded ${maxBytes} bytes`);
|
|
108
122
|
}
|
|
109
123
|
chunks.push(next.value);
|
|
110
124
|
}
|
|
111
125
|
} finally {
|
|
126
|
+
finished = true;
|
|
127
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
128
|
+
signal?.removeEventListener("abort", cancel);
|
|
112
129
|
reader.releaseLock();
|
|
113
130
|
}
|
|
114
131
|
const bytes = new Uint8Array(total);
|
|
@@ -149,8 +166,8 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
149
166
|
} catch (error) {
|
|
150
167
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
151
168
|
}
|
|
152
|
-
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"));
|
|
153
|
-
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false);
|
|
169
|
+
const stdout = readOutput(outputStream(child.stdout), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), true, child.exited);
|
|
170
|
+
const stderr = readOutput(outputStream(child.stderr), DEFAULT_OUTPUT_BYTES, () => defaultKill(child, "SIGTERM"), false, child.exited);
|
|
154
171
|
void stdout.catch(() => {});
|
|
155
172
|
void stderr.catch(() => {});
|
|
156
173
|
return {
|
|
@@ -165,9 +182,13 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
165
182
|
|
|
166
183
|
return {
|
|
167
184
|
async run(argv, options = {}) {
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
|
186
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
187
|
+
throw new SshRunnerError("timeout", "timeoutMs must be a positive integer at most 2147483647");
|
|
188
|
+
}
|
|
168
189
|
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_OUTPUT_BYTES;
|
|
169
|
-
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0) {
|
|
170
|
-
throw new SshRunnerError("output_limit",
|
|
190
|
+
if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 0 || maxOutputBytes > MAX_OUTPUT_BYTES) {
|
|
191
|
+
throw new SshRunnerError("output_limit", `maxOutputBytes must be an integer between 0 and ${MAX_OUTPUT_BYTES}`);
|
|
171
192
|
}
|
|
172
193
|
let child: Bun.Subprocess;
|
|
173
194
|
try {
|
|
@@ -181,41 +202,50 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
181
202
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
182
203
|
}
|
|
183
204
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
const input = child.stdin;
|
|
189
|
-
if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
|
|
190
|
-
await input.write(options.stdin);
|
|
191
|
-
await input.end();
|
|
192
|
-
} catch (error) {
|
|
193
|
-
child.kill("SIGTERM");
|
|
194
|
-
throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
|
199
|
-
let timedOut = false;
|
|
205
|
+
const cancellation = new AbortController();
|
|
206
|
+
let stdout: Promise<string> | undefined;
|
|
207
|
+
let stderr: Promise<string> | undefined;
|
|
200
208
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
201
209
|
const timeout = new Promise<never>((_, reject) => {
|
|
202
210
|
timer = setTimeout(() => {
|
|
203
|
-
timedOut = true;
|
|
204
|
-
defaultKill(child, "SIGTERM");
|
|
205
211
|
reject(new SshRunnerError("timeout", `ssh command exceeded ${timeoutMs}ms`));
|
|
206
212
|
}, timeoutMs);
|
|
207
213
|
});
|
|
208
214
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
215
|
+
const outStream = outputStream(child.stdout), errStream = outputStream(child.stderr);
|
|
216
|
+
stdout = readOutput(outStream, maxOutputBytes, undefined, true, undefined, cancellation.signal);
|
|
217
|
+
stderr = readOutput(errStream, Math.min(maxOutputBytes, DEFAULT_OUTPUT_BYTES), undefined, false, undefined, cancellation.signal);
|
|
218
|
+
const stdin = async (): Promise<void> => {
|
|
219
|
+
if (options.stdin === undefined) return;
|
|
220
|
+
try {
|
|
221
|
+
const input = child.stdin;
|
|
222
|
+
if (!input || typeof input === "number") throw new Error("ssh stdin was not piped");
|
|
223
|
+
await input.write(options.stdin);
|
|
224
|
+
// A timed-out write may settle later; do not start another operation after failure.
|
|
225
|
+
if (cancellation.signal.aborted) return;
|
|
226
|
+
await input.end();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
throw new SshRunnerError("spawn", "could not write ssh stdin", { cause: error });
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
// Observe all rejections immediately, including errors arriving after timeout. One
|
|
232
|
+
// deadline covers stdin, exit and EOF: an inherited output pipe is still unfinished work.
|
|
233
|
+
const [, code, out, err] = await Promise.race([
|
|
234
|
+
Promise.all([stdin(), child.exited, stdout, stderr]), timeout,
|
|
235
|
+
]);
|
|
212
236
|
return { code, stdout: out, stderr: err };
|
|
213
237
|
} catch (error) {
|
|
238
|
+
cancellation.abort();
|
|
239
|
+
// Only terminate this local SSH child. A kill failure must not hide the original error.
|
|
240
|
+
try { defaultKill(child, "SIGTERM"); } catch { /* Preserve the command failure when an already-gone child cannot be signalled. */ }
|
|
214
241
|
if (error instanceof SshRunnerError) throw error;
|
|
215
242
|
throw new SshRunnerError("spawn", "ssh command failed", { cause: error });
|
|
216
243
|
} finally {
|
|
217
244
|
if (timer !== undefined) clearTimeout(timer);
|
|
218
|
-
void
|
|
245
|
+
void child.exited.catch(() => {});
|
|
246
|
+
// reader.cancel closes pending reads even if the source's cancel promise never settles.
|
|
247
|
+
// Do not await stdin or process exit: neither is guaranteed to settle after SIGTERM.
|
|
248
|
+
await Promise.allSettled([stdout, stderr]);
|
|
219
249
|
}
|
|
220
250
|
},
|
|
221
251
|
spawnTunnel,
|
|
@@ -227,3 +257,4 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
227
257
|
}
|
|
228
258
|
|
|
229
259
|
export const SSH_OUTPUT_CAP_BYTES = DEFAULT_OUTPUT_BYTES;
|
|
260
|
+
export const SSH_MAX_OUTPUT_BYTES = MAX_OUTPUT_BYTES;
|
|
@@ -2,6 +2,7 @@ import type { LinkStore } from "./store";
|
|
|
2
2
|
import type { LinkTunnelStatus } from "./supervisor";
|
|
3
3
|
import type { TunnelState } from "./tunnel-state";
|
|
4
4
|
import { readCompensation, type CompensationStore } from "./compensation";
|
|
5
|
+
import type { OcxClientConnectionConfig } from "../types";
|
|
5
6
|
|
|
6
7
|
export type LinkWireState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
|
|
7
8
|
|
|
@@ -25,6 +26,8 @@ export interface LinkStatusDto {
|
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export interface ClientLinkIdentity { direction: "hub-initiated" | "client-initiated"; alias?: string }
|
|
30
|
+
|
|
28
31
|
export interface LinkListenerStatusProjection {
|
|
29
32
|
state: "off" | "listening" | "failed";
|
|
30
33
|
port: number | null;
|
|
@@ -32,6 +35,7 @@ export interface LinkListenerStatusProjection {
|
|
|
32
35
|
|
|
33
36
|
export interface LinkStatusConfig {
|
|
34
37
|
runtimeRole?: "standalone" | "hub" | "client";
|
|
38
|
+
client?: Pick<OcxClientConnectionConfig, "transport" | "link" | "serverUrl" | "connectedAt">;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function asWireState(state: TunnelState | "client-owned"): LinkWireState {
|
|
@@ -60,7 +64,7 @@ function stateDetails(
|
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* Project private link state into the exact K16 wire DTO. `clientChild` lets a client-side caller
|
|
63
|
-
* report a child state this module cannot read itself (an invalid client sidecar); link code
|
|
67
|
+
* report a child state this module cannot read itself (including an invalid client sidecar); link code
|
|
64
68
|
* stays free of client imports, so the caller supplies it.
|
|
65
69
|
*/
|
|
66
70
|
export function projectLinkStatus(
|
|
@@ -70,6 +74,7 @@ export function projectLinkStatus(
|
|
|
70
74
|
config: LinkStatusConfig,
|
|
71
75
|
compensation: CompensationStore = readCompensation(),
|
|
72
76
|
clientChild: LinkStatusDto["child"] = null,
|
|
77
|
+
identity: ClientLinkIdentity | null = null,
|
|
73
78
|
): LinkStatusDto {
|
|
74
79
|
const byId = new Map(supervisorStates.map(status => [status.linkId, status]));
|
|
75
80
|
const links = store.links.map(record => ({
|
|
@@ -79,18 +84,31 @@ export function projectLinkStatus(
|
|
|
79
84
|
...stateDetails(record, byId.get(record.id), compensation),
|
|
80
85
|
tunnelPort: record.tunnelPort,
|
|
81
86
|
}));
|
|
82
|
-
const childRecord = config.runtimeRole === "client"
|
|
83
|
-
|
|
87
|
+
const childRecord = config.runtimeRole === "client"
|
|
88
|
+
? (config.client?.transport === "link" ? store.links.find(record => record.id === config.client?.link?.linkId) : store.links[0])
|
|
89
|
+
: undefined;
|
|
90
|
+
const projectedClientChild = config.runtimeRole === "client" ? clientChild : null;
|
|
84
91
|
const childStatus = childRecord
|
|
85
92
|
? stateDetails(childRecord, byId.get(childRecord.id), compensation)
|
|
86
93
|
: undefined;
|
|
87
94
|
const role = config.runtimeRole === "client"
|
|
88
95
|
? "child"
|
|
89
96
|
: store.links.length > 0 ? "home" : "standalone";
|
|
97
|
+
// Home owns the record and reverse SSH process. The Child's persisted connection supplies
|
|
98
|
+
// identity and port, while an optional caller observation supplies live tunnel state.
|
|
99
|
+
const client = config.runtimeRole === "client" && config.client?.transport === "link" ? config.client : undefined;
|
|
100
|
+
const child = projectedClientChild ?? (childRecord && childStatus ? { alias: childRecord.alias, ...childStatus }
|
|
101
|
+
: client?.link ? { alias: identity?.alias ?? client.serverUrl, state: "idle" as const, since: client.connectedAt, reason: null } : null);
|
|
102
|
+
if (client?.link && child && identity) {
|
|
103
|
+
const active = links.find(link => link.id === client.link!.linkId);
|
|
104
|
+
const projected = { id: client.link.linkId, direction: identity.direction, tunnelPort: client.link.tunnelPort, ...child, alias: identity.alias ?? child.alias };
|
|
105
|
+
if (active) Object.assign(active, projected, compensation.entries[active.id] && childRecord ? stateDetails(childRecord, byId.get(active.id), compensation) : {});
|
|
106
|
+
else links.push(projected);
|
|
107
|
+
}
|
|
90
108
|
return {
|
|
91
109
|
role,
|
|
92
110
|
listener: { state: listenerStatus.state, port: listenerStatus.port },
|
|
93
111
|
links,
|
|
94
|
-
child
|
|
112
|
+
child,
|
|
95
113
|
};
|
|
96
114
|
}
|