@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-D4bA1vff.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-BZXhB3qz.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-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { buildExecArgv } from "../link/ssh-argv";
|
|
2
|
+
import type { SshRunner } from "../link/ssh-runner";
|
|
3
|
+
import type { ClientLinkState } from "./link-state";
|
|
4
|
+
import type { OrphanTunnelResult } from "./link-tunnel";
|
|
5
|
+
|
|
6
|
+
const HOME_REVOKE_TIMEOUT_MS = 30_000;
|
|
7
|
+
|
|
8
|
+
export interface ClientLinkTeardownDeps {
|
|
9
|
+
readSidecar: () => ClientLinkState | null;
|
|
10
|
+
connectedLinkId: () => string | null;
|
|
11
|
+
reapOrphanTunnel: () => Promise<OrphanTunnelResult>;
|
|
12
|
+
runner: Pick<SshRunner, "run">;
|
|
13
|
+
knownHostsFile: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ClientLinkTeardownResult {
|
|
18
|
+
linkId: string | null;
|
|
19
|
+
homeRevoke: "revoked" | "failed" | "not_applicable";
|
|
20
|
+
tunnel: OrphanTunnelResult | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Reap the client tunnel and make one best-effort Home-side revoke attempt. */
|
|
24
|
+
export async function teardownClientLink(
|
|
25
|
+
deps: ClientLinkTeardownDeps,
|
|
26
|
+
): Promise<ClientLinkTeardownResult> {
|
|
27
|
+
let tunnel: OrphanTunnelResult | null = null;
|
|
28
|
+
try {
|
|
29
|
+
tunnel = await deps.reapOrphanTunnel();
|
|
30
|
+
} catch {
|
|
31
|
+
// A reap failure must not prevent the one allowed Home revoke attempt.
|
|
32
|
+
}
|
|
33
|
+
let sidecar: ClientLinkState | null;
|
|
34
|
+
try {
|
|
35
|
+
sidecar = deps.readSidecar();
|
|
36
|
+
} catch {
|
|
37
|
+
// An unreadable sidecar no longer names the Home alias, so the revoke cannot run here. The
|
|
38
|
+
// disconnect still proceeds, and a link connection gets the manual revoke instruction.
|
|
39
|
+
const linkId = deps.connectedLinkId();
|
|
40
|
+
return { linkId, homeRevoke: linkId ? "failed" : "not_applicable", tunnel };
|
|
41
|
+
}
|
|
42
|
+
if (!sidecar || sidecar.linkId !== deps.connectedLinkId()) {
|
|
43
|
+
return { linkId: null, homeRevoke: "not_applicable", tunnel };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const result = await deps.runner.run(
|
|
48
|
+
buildExecArgv({
|
|
49
|
+
alias: sidecar.alias,
|
|
50
|
+
argv: ["ocx", "link", "revoke", "--link-id", sidecar.linkId],
|
|
51
|
+
knownHostsFile: deps.knownHostsFile,
|
|
52
|
+
}),
|
|
53
|
+
{ timeoutMs: deps.timeoutMs ?? HOME_REVOKE_TIMEOUT_MS },
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
linkId: sidecar.linkId,
|
|
57
|
+
homeRevoke: result.code === 0 ? "revoked" : "failed",
|
|
58
|
+
tunnel,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return { linkId: sidecar.linkId, homeRevoke: "failed", tunnel };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
|
|
4
|
+
import { linkDir, linkKnownHostsPath } from "../link/paths";
|
|
5
|
+
import { buildTunnelArgv } from "../link/ssh-argv";
|
|
6
|
+
import { createSshRunner, type SshChild, type SshRunner } from "../link/ssh-runner";
|
|
7
|
+
import {
|
|
8
|
+
classifySshStderr,
|
|
9
|
+
dueForSpawn,
|
|
10
|
+
IDLE,
|
|
11
|
+
reduceTunnel,
|
|
12
|
+
type TunnelState,
|
|
13
|
+
} from "../link/tunnel-state";
|
|
14
|
+
import { isLinkPort } from "../link/ports";
|
|
15
|
+
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
16
|
+
import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
|
|
20
|
+
* of a client-initiated link. Two owners use it: the dashboard join (a short-lived tunnel that
|
|
21
|
+
* lives only until the in-process connect finishes) and the client runtime supervisor.
|
|
22
|
+
*/
|
|
23
|
+
export interface ClientLinkTunnelSpec {
|
|
24
|
+
linkId: string;
|
|
25
|
+
alias: string;
|
|
26
|
+
tunnelPort: number;
|
|
27
|
+
peerListenerPort: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ClientLinkTunnelHandle {
|
|
31
|
+
readonly pid: number;
|
|
32
|
+
readonly exited: Promise<number>;
|
|
33
|
+
/** TERM, wait up to 5 s, then KILL; removes the pidfile this handle wrote. Idempotent. */
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClientLinkTunnelDeps {
|
|
38
|
+
runner?: SshRunner;
|
|
39
|
+
configDir?: string;
|
|
40
|
+
knownHostsFile?: string;
|
|
41
|
+
setTimer?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
42
|
+
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type OrphanTunnelResult =
|
|
46
|
+
| { tunnel: "reaped" }
|
|
47
|
+
| { tunnel: "absent" }
|
|
48
|
+
| { tunnel: "owned" }
|
|
49
|
+
| { tunnel: "unresolved"; pid: number };
|
|
50
|
+
|
|
51
|
+
export interface OrphanReapDeps {
|
|
52
|
+
configDir?: string;
|
|
53
|
+
platform?: NodeJS.Platform;
|
|
54
|
+
readProcessArgv?: (pid: number) => readonly string[] | null;
|
|
55
|
+
isAlive?: (pid: number) => boolean;
|
|
56
|
+
signal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ClientLinkSupervisorStatus =
|
|
61
|
+
| { kind: "stopped" }
|
|
62
|
+
| { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null }
|
|
63
|
+
| { kind: "failed"; reason: "sidecar_invalid" };
|
|
64
|
+
|
|
65
|
+
export interface ClientLinkTunnelStatusProjection {
|
|
66
|
+
alias: string;
|
|
67
|
+
state: "failed";
|
|
68
|
+
since: string;
|
|
69
|
+
reason: "sidecar_invalid";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ClientLinkSupervisor {
|
|
73
|
+
start(): void;
|
|
74
|
+
/** Stops the tunnel (TERM, up to 5 s, KILL). The runtime calls this before stopping its listener. */
|
|
75
|
+
stop(): Promise<void>;
|
|
76
|
+
status(): ClientLinkSupervisorStatus;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read-only status bridge for a client whose persisted sidecar cannot be trusted. */
|
|
80
|
+
export function clientLinkTunnelStatus(
|
|
81
|
+
path: string = clientLinkStatePath(),
|
|
82
|
+
now: () => number = Date.now,
|
|
83
|
+
): ClientLinkTunnelStatusProjection | null {
|
|
84
|
+
try {
|
|
85
|
+
readClientLinkState(path);
|
|
86
|
+
return null;
|
|
87
|
+
} catch {
|
|
88
|
+
return { alias: "unknown", state: "failed", since: new Date(now()).toISOString(), reason: "sidecar_invalid" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanReapDeps {
|
|
93
|
+
readSidecar?: () => ClientLinkState | null;
|
|
94
|
+
/** Current link id of a connected link-transport client, or null when that no longer holds. */
|
|
95
|
+
connectedLinkId?: () => string | null;
|
|
96
|
+
/** Called once after the tunnel stopped because the link ended (the runtime recycles here). */
|
|
97
|
+
onLinkEnded?: () => void;
|
|
98
|
+
now?: () => number;
|
|
99
|
+
random?: () => number;
|
|
100
|
+
warn?: (message: string) => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clientTunnelPidfilePath(configDir?: string): string {
|
|
104
|
+
return join(linkDir(configDir), "client-tunnel.pid");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pidfile body at `clientTunnelPidfilePath()`: `{ version: 1, linkId, pid, argv, ownerPid }`.
|
|
109
|
+
* `ownerPid` is the process that spawned the tunnel. A tunnel is an orphan only while its owner
|
|
110
|
+
* is gone; a live owner means the tunnel is managed and `reapOrphanTunnel` reports "owned".
|
|
111
|
+
*/
|
|
112
|
+
export interface ClientTunnelPidfile {
|
|
113
|
+
version: 1;
|
|
114
|
+
linkId: string;
|
|
115
|
+
pid: number;
|
|
116
|
+
argv: string[];
|
|
117
|
+
ownerPid: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
121
|
+
const TIMER_MS = 1_000;
|
|
122
|
+
const SPAWN_GRACE_MS = 5_000;
|
|
123
|
+
|
|
124
|
+
function sameArgv(left: readonly string[], right: readonly string[]): boolean {
|
|
125
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePidfile(value: unknown): ClientTunnelPidfile | null {
|
|
129
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
130
|
+
const raw = value as Record<string, unknown>;
|
|
131
|
+
if (raw.version !== 1 || typeof raw.linkId !== "string" || typeof raw.pid !== "number"
|
|
132
|
+
|| !Number.isSafeInteger(raw.pid) || raw.pid < 1 || !Array.isArray(raw.argv)
|
|
133
|
+
|| raw.argv.length === 0 || raw.argv.some(item => typeof item !== "string")
|
|
134
|
+
|| typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1) return null;
|
|
135
|
+
return {
|
|
136
|
+
version: 1,
|
|
137
|
+
linkId: raw.linkId,
|
|
138
|
+
pid: raw.pid,
|
|
139
|
+
argv: raw.argv as string[],
|
|
140
|
+
ownerPid: raw.ownerPid,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readPidfile(path: string): ClientTunnelPidfile | null {
|
|
145
|
+
try {
|
|
146
|
+
return parsePidfile(JSON.parse(readFileSync(path, "utf8")) as unknown);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (isMissingPathError(error)) return null;
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writePidfile(path: string, value: ClientTunnelPidfile): void {
|
|
154
|
+
const dir = dirname(path);
|
|
155
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
156
|
+
atomicWriteFile(path, `${JSON.stringify(value)}\n`);
|
|
157
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function removePidfileIfPid(path: string, pid: number): void {
|
|
161
|
+
const current = readPidfile(path);
|
|
162
|
+
if (current?.pid !== pid) return;
|
|
163
|
+
try {
|
|
164
|
+
unlinkSync(path);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!isMissingPathError(error)) throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function defaultSignal(pid: number, signal: NodeJS.Signals): void {
|
|
171
|
+
try {
|
|
172
|
+
process.kill(pid, signal);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
175
|
+
if (code !== "ESRCH") throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function defaultIsAlive(pid: number): boolean {
|
|
180
|
+
try {
|
|
181
|
+
process.kill(pid, 0);
|
|
182
|
+
return true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function linuxProcessArgv(pid: number): readonly string[] | null {
|
|
189
|
+
try {
|
|
190
|
+
const values = readFileSync(`/proc/${pid}/cmdline`).toString().split("\0");
|
|
191
|
+
if (values.at(-1) === "") values.pop();
|
|
192
|
+
return values.length > 0 ? values : null;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (isMissingPathError(error)) return null;
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function timerDeps(deps: ClientLinkTunnelDeps): Required<Pick<ClientLinkTunnelDeps, "setTimer" | "clearTimer">> {
|
|
200
|
+
return {
|
|
201
|
+
setTimer: deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms)),
|
|
202
|
+
clearTimer: deps.clearTimer ?? (timer => clearTimeout(timer)),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function stopChild(child: SshChild, deps: ClientLinkTunnelDeps): Promise<void> {
|
|
207
|
+
const { setTimer, clearTimer } = timerDeps(deps);
|
|
208
|
+
try {
|
|
209
|
+
child.kill("SIGTERM");
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
212
|
+
}
|
|
213
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
214
|
+
let exited = false;
|
|
215
|
+
const exitedPromise = child.exited.then(() => { exited = true; }, () => { exited = true; });
|
|
216
|
+
const timeout = new Promise<void>(resolve => {
|
|
217
|
+
timer = setTimer(resolve, STOP_TIMEOUT_MS);
|
|
218
|
+
});
|
|
219
|
+
await Promise.race([exitedPromise, timeout]);
|
|
220
|
+
if (timer !== undefined) clearTimer(timer);
|
|
221
|
+
if (!exited) {
|
|
222
|
+
try {
|
|
223
|
+
child.kill("SIGKILL");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLinkTunnelDeps = {}): ClientLinkTunnelHandle {
|
|
231
|
+
if (!isLinkPort(spec.tunnelPort)) throw new Error("client tunnel port is outside the link range");
|
|
232
|
+
const knownHostsFile = deps.knownHostsFile ?? linkKnownHostsPath(deps.configDir);
|
|
233
|
+
const runner = deps.runner ?? createSshRunner();
|
|
234
|
+
const argv = buildTunnelArgv({
|
|
235
|
+
alias: spec.alias,
|
|
236
|
+
direction: "L",
|
|
237
|
+
bindPort: spec.tunnelPort,
|
|
238
|
+
targetPort: spec.peerListenerPort,
|
|
239
|
+
knownHostsFile,
|
|
240
|
+
});
|
|
241
|
+
const child = runner.spawnTunnel(argv);
|
|
242
|
+
const pidfile = clientTunnelPidfilePath(deps.configDir);
|
|
243
|
+
try {
|
|
244
|
+
writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
try { child.kill("SIGTERM"); } catch (killError) { if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; }
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let stopPromise: Promise<void> | undefined;
|
|
251
|
+
const handleExit = (): void => {
|
|
252
|
+
removePidfileIfPid(pidfile, child.pid);
|
|
253
|
+
};
|
|
254
|
+
const handle = {
|
|
255
|
+
pid: child.pid,
|
|
256
|
+
exited: child.exited,
|
|
257
|
+
stderr: child.stderr,
|
|
258
|
+
stop(): Promise<void> {
|
|
259
|
+
if (stopPromise) return stopPromise;
|
|
260
|
+
stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid));
|
|
261
|
+
return stopPromise;
|
|
262
|
+
},
|
|
263
|
+
} satisfies ClientLinkTunnelHandle & { stderr?: Promise<string> };
|
|
264
|
+
void child.exited.then(handleExit, handleExit);
|
|
265
|
+
return handle;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function reapOrphanTunnel(deps: OrphanReapDeps = {}): Promise<OrphanTunnelResult> {
|
|
269
|
+
const path = clientTunnelPidfilePath(deps.configDir);
|
|
270
|
+
const pidfile = readPidfile(path);
|
|
271
|
+
if (!pidfile) return { tunnel: "absent" };
|
|
272
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
273
|
+
if (isAlive(pidfile.ownerPid)) return { tunnel: "owned" };
|
|
274
|
+
const platform = deps.platform ?? process.platform;
|
|
275
|
+
if (platform !== "linux") return { tunnel: "unresolved", pid: pidfile.pid };
|
|
276
|
+
const readProcessArgv = deps.readProcessArgv ?? linuxProcessArgv;
|
|
277
|
+
const actualArgv = readProcessArgv(pidfile.pid);
|
|
278
|
+
if (!actualArgv || !sameArgv(actualArgv, pidfile.argv)) {
|
|
279
|
+
try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
|
|
280
|
+
return { tunnel: "absent" };
|
|
281
|
+
}
|
|
282
|
+
const signal = deps.signal ?? defaultSignal;
|
|
283
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
284
|
+
try { signal(pidfile.pid, "SIGTERM"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
285
|
+
await sleep(STOP_TIMEOUT_MS);
|
|
286
|
+
if (isAlive(pidfile.pid)) {
|
|
287
|
+
try { signal(pidfile.pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
288
|
+
}
|
|
289
|
+
try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
|
|
290
|
+
return { tunnel: "reaped" };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function defaultConnectedLinkId(): string | null {
|
|
294
|
+
const state = readClientConnectionState();
|
|
295
|
+
if (state.kind !== "connected" || !isLinkConnection(state.value)) return null;
|
|
296
|
+
return state.value.link?.linkId ?? null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
|
|
300
|
+
const readSidecar = deps.readSidecar ?? (() => readClientLinkState(clientLinkStatePath(deps.configDir)));
|
|
301
|
+
const connectedLinkId = deps.connectedLinkId ?? defaultConnectedLinkId;
|
|
302
|
+
const now = deps.now ?? (() => Date.now());
|
|
303
|
+
const random = deps.random ?? Math.random;
|
|
304
|
+
const setSupervisorTimer = deps.setTimer
|
|
305
|
+
?? ((callback: () => void, ms: number) => setInterval(callback, ms) as unknown as ReturnType<typeof setTimeout>);
|
|
306
|
+
const clearSupervisorTimer = deps.clearTimer
|
|
307
|
+
?? ((timer: ReturnType<typeof setTimeout>) => clearInterval(timer as unknown as ReturnType<typeof setInterval>));
|
|
308
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
309
|
+
let started = false;
|
|
310
|
+
let stopping = false;
|
|
311
|
+
let initialized = false;
|
|
312
|
+
let initializing = false;
|
|
313
|
+
let onLinkEndedCalled = false;
|
|
314
|
+
let child: ClientLinkTunnelHandle | undefined;
|
|
315
|
+
let state: TunnelState = IDLE;
|
|
316
|
+
let linkId: string | null = null;
|
|
317
|
+
let failure: ClientLinkSupervisorStatus | undefined;
|
|
318
|
+
let tickFlight: Promise<void> | undefined;
|
|
319
|
+
|
|
320
|
+
const readCurrent = (): { sidecar: ClientLinkState | null; invalid: boolean } => {
|
|
321
|
+
try {
|
|
322
|
+
return { sidecar: readSidecar(), invalid: false };
|
|
323
|
+
} catch (error) {
|
|
324
|
+
deps.warn?.("client link sidecar could not be read");
|
|
325
|
+
return { sidecar: null, invalid: true };
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const stopTunnel = async (): Promise<void> => {
|
|
330
|
+
const current = child;
|
|
331
|
+
child = undefined;
|
|
332
|
+
state = reduceTunnel(state, { type: "stop" });
|
|
333
|
+
if (current) await current.stop();
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const endLink = async (): Promise<void> => {
|
|
337
|
+
await stopTunnel();
|
|
338
|
+
if (onLinkEndedCalled || stopping) return;
|
|
339
|
+
linkId = null;
|
|
340
|
+
onLinkEndedCalled = true;
|
|
341
|
+
deps.onLinkEnded?.();
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const spawn = (sidecar: ClientLinkState): void => {
|
|
345
|
+
if (stopping || child || state.kind === "failed") return;
|
|
346
|
+
try {
|
|
347
|
+
child = spawnClientLinkTunnel({
|
|
348
|
+
linkId: sidecar.linkId,
|
|
349
|
+
alias: sidecar.alias,
|
|
350
|
+
tunnelPort: sidecar.tunnelPort,
|
|
351
|
+
peerListenerPort: sidecar.peerListenerPort,
|
|
352
|
+
}, deps);
|
|
353
|
+
linkId = sidecar.linkId;
|
|
354
|
+
state = reduceTunnel(state, { type: "spawn", now: now() }, random);
|
|
355
|
+
const current = child;
|
|
356
|
+
void current.exited.then(async () => {
|
|
357
|
+
if (child !== current) return;
|
|
358
|
+
child = undefined;
|
|
359
|
+
const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr
|
|
360
|
+
? await (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr!.catch(() => "")
|
|
361
|
+
: "";
|
|
362
|
+
const next = reduceTunnel(state, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) }, random);
|
|
363
|
+
state = next;
|
|
364
|
+
}).catch(() => {
|
|
365
|
+
if (child !== current) return;
|
|
366
|
+
child = undefined;
|
|
367
|
+
state = reduceTunnel(state, { type: "exit", now: now(), stderrClass: "network" }, random);
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
state = { kind: "failed", since: now(), reason: "forward" };
|
|
371
|
+
deps.warn?.("client link tunnel could not be started");
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const tick = async (): Promise<void> => {
|
|
376
|
+
if (stopping || !initialized) return;
|
|
377
|
+
const current = readCurrent();
|
|
378
|
+
if (current.invalid) {
|
|
379
|
+
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
380
|
+
await stopTunnel();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const connected = connectedLinkId();
|
|
384
|
+
if (!current.sidecar) {
|
|
385
|
+
if (child || linkId) await endLink();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (connected !== current.sidecar.linkId) {
|
|
389
|
+
if (child || linkId) await endLink();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
failure = undefined;
|
|
393
|
+
const timestamp = now();
|
|
394
|
+
state = reduceTunnel(state, { type: "tick", now: timestamp }, random);
|
|
395
|
+
if (child && state.kind === "connecting" && timestamp - state.since >= SPAWN_GRACE_MS) {
|
|
396
|
+
state = reduceTunnel(state, { type: "ready", now: timestamp }, random);
|
|
397
|
+
}
|
|
398
|
+
if (state.kind === "failed" && child) await stopTunnel();
|
|
399
|
+
else if (!child && (state.kind === "idle" || dueForSpawn(state, timestamp))) spawn(current.sidecar);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const runTick = (): void => {
|
|
403
|
+
if (tickFlight) return;
|
|
404
|
+
tickFlight = tick().finally(() => { tickFlight = undefined; });
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const initialize = async (): Promise<void> => {
|
|
408
|
+
if (initializing || initialized || stopping) return;
|
|
409
|
+
initializing = true;
|
|
410
|
+
const current = readCurrent();
|
|
411
|
+
if (current.invalid) {
|
|
412
|
+
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
413
|
+
initialized = true;
|
|
414
|
+
initializing = false;
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (current.sidecar && connectedLinkId() === current.sidecar.linkId) {
|
|
418
|
+
const orphan = await reapOrphanTunnel(deps);
|
|
419
|
+
if (orphan.tunnel === "owned" || orphan.tunnel === "unresolved") {
|
|
420
|
+
linkId = current.sidecar.linkId;
|
|
421
|
+
state = { kind: "connected", since: now() };
|
|
422
|
+
initialized = true;
|
|
423
|
+
initializing = false;
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const afterReap = readCurrent();
|
|
427
|
+
if (!afterReap.invalid && afterReap.sidecar && connectedLinkId() === afterReap.sidecar.linkId) spawn(afterReap.sidecar);
|
|
428
|
+
}
|
|
429
|
+
initialized = true;
|
|
430
|
+
initializing = false;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
start(): void {
|
|
435
|
+
if (started) return;
|
|
436
|
+
started = true;
|
|
437
|
+
stopping = false;
|
|
438
|
+
timer = setSupervisorTimer(runTick, TIMER_MS);
|
|
439
|
+
void initialize().catch(() => {
|
|
440
|
+
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
441
|
+
initialized = true;
|
|
442
|
+
initializing = false;
|
|
443
|
+
});
|
|
444
|
+
},
|
|
445
|
+
async stop(): Promise<void> {
|
|
446
|
+
if (stopping) {
|
|
447
|
+
if (tickFlight) await tickFlight;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
stopping = true;
|
|
451
|
+
if (timer !== undefined) {
|
|
452
|
+
clearSupervisorTimer(timer);
|
|
453
|
+
timer = undefined;
|
|
454
|
+
}
|
|
455
|
+
if (tickFlight) await tickFlight;
|
|
456
|
+
await stopTunnel();
|
|
457
|
+
failure = undefined;
|
|
458
|
+
},
|
|
459
|
+
status(): ClientLinkSupervisorStatus {
|
|
460
|
+
if (failure) return failure;
|
|
461
|
+
if (!child && !linkId) return { kind: "stopped" };
|
|
462
|
+
return { kind: "tunnel", linkId: linkId ?? "", state, pid: child?.pid ?? null };
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -11,11 +11,13 @@ import {
|
|
|
11
11
|
} from "../server/management-auth";
|
|
12
12
|
import type { OcxClientConnectionConfig, OcxConfig } from "../types";
|
|
13
13
|
import { disconnectClient, syncConnectedClient } from "./connect";
|
|
14
|
-
import { readClientConnectionState } from "./state";
|
|
14
|
+
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
15
15
|
import { handleMachineApi, type HubReachability, type MachineApiDeps } from "./machine-api";
|
|
16
16
|
import { MACHINE_GUI_ORIGIN_HEADER, requireMachineAuth } from "./machine-auth";
|
|
17
|
-
import { relayHubManagementRequest } from "./hub-relay";
|
|
17
|
+
import { HUB_RELAY_REQUEST_BODY_MAX_BYTES, relayHubManagementRequest } from "./hub-relay";
|
|
18
|
+
import { relayLinkDataRequest } from "./link-relay";
|
|
18
19
|
import { packageVersion } from "../lib/package-version";
|
|
20
|
+
import { linkRouteAllowed } from "../link/routes";
|
|
19
21
|
|
|
20
22
|
const VERSION = packageVersion("0.0.0");
|
|
21
23
|
const GUI_SPA_PATHS = new Set([
|
|
@@ -39,8 +41,9 @@ function machinePolicyConfig(config: OcxConfig): OcxConfig {
|
|
|
39
41
|
return { ...config, hostname: "127.0.0.1" };
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolean): boolean {
|
|
44
|
+
export function machineRouteAllowed(url: URL, req: Request, relayEnabled: boolean, linkMode = false): boolean {
|
|
43
45
|
if (req.headers.get("upgrade")) return false;
|
|
46
|
+
if (linkMode && linkRouteAllowed(url, req)) return true;
|
|
44
47
|
const path = url.pathname;
|
|
45
48
|
if (req.method === "GET" && (path === "/healthz" || path === "/readyz" || path === "/" || path === "/opencodex-session")) return true;
|
|
46
49
|
if ((req.method === "GET" || req.method === "HEAD") && (path === "/api/machine/status" || path === "/api/machine/clients" || path === "/api/machine/shim")) return true;
|
|
@@ -67,6 +70,8 @@ export function startMachineListener(
|
|
|
67
70
|
if (state.kind !== "connected") throw new Error(`machine listener requires connected client state, got ${state.kind}`);
|
|
68
71
|
return state.value;
|
|
69
72
|
})();
|
|
73
|
+
const linkMode = isLinkConnection(connection);
|
|
74
|
+
if (linkMode && !connection.link) throw new Error("link machine listener requires link transport metadata");
|
|
70
75
|
const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
|
|
71
76
|
let hubReachability: HubReachability = "unknown";
|
|
72
77
|
const machineApiDeps: MachineApiDeps = {
|
|
@@ -78,14 +83,18 @@ export function startMachineListener(
|
|
|
78
83
|
hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
|
|
79
84
|
setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
|
|
80
85
|
};
|
|
81
|
-
const relayEnabled = connection.managementTransport === "relay";
|
|
86
|
+
const relayEnabled = !linkMode && connection.managementTransport === "relay";
|
|
82
87
|
|
|
83
88
|
return Bun.serve({
|
|
84
89
|
port: port ?? config.port ?? 10100,
|
|
85
90
|
hostname: "127.0.0.1",
|
|
91
|
+
maxRequestBodySize: HUB_RELAY_REQUEST_BODY_MAX_BYTES,
|
|
86
92
|
async fetch(req, server) {
|
|
87
93
|
const url = new URL(req.url);
|
|
88
|
-
if (!machineRouteAllowed(url, req, relayEnabled)) return json404(req);
|
|
94
|
+
if (!machineRouteAllowed(url, req, relayEnabled, linkMode)) return json404(req);
|
|
95
|
+
if (linkMode && linkRouteAllowed(url, req)) {
|
|
96
|
+
return relayLinkDataRequest(req, { tunnelPort: connection.link!.tunnelPort }, { fetchImpl: deps.fetchImpl });
|
|
97
|
+
}
|
|
89
98
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
90
99
|
return Response.json({ service: "opencodex", version: VERSION, role: "client", uptime: process.uptime(), pid: process.pid, port: server.port });
|
|
91
100
|
}
|