@bitkyc08/opencodex 2.87.0 → 2.89.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/README.md +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/index-uchD-ngf.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +86 -48
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +52 -30
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +17 -3
- package/src/bridge/response-json.ts +4 -4
- package/src/bridge/sse.ts +17 -10
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +18 -5
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/cross-home-owner.ts +16 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/loopback-family.ts +31 -0
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-gather.ts +3 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/combos/failover.ts +2 -1
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/opencode-zen-upgrade.ts +9 -8
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +411 -175
- package/src/images/loop.ts +101 -27
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/request-failure-model.ts +2 -0
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/generic-account-failover.ts +7 -4
- package/src/oauth/index.ts +5 -35
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +12 -73
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +2 -1
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-replay-cache.ts +50 -4
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/analytics.ts +7 -15
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +28 -13
- package/src/server/claude-messages.ts +4 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +34 -7
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +104 -29
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +6 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +9 -3
- package/src/server/responses/core-combo.ts +34 -7
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +59 -10
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/server/ws-bridge.ts +2 -0
- package/src/service/definition.ts +9 -1
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/opencode-zen.ts +9 -3
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +30 -4
- package/src/types/wire.ts +4 -2
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +16 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +212 -73
- package/src/web-search/progress-stream.ts +14 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildExecArgv, buildFingerprintArgv, buildProbeArgv, remoteOcxArgv, isSshAlias } from "../link/ssh-argv";
|
|
5
|
+
import { parseFingerprintLine } from "../link/fingerprint";
|
|
6
|
+
import { MessageBudget } from "./budget";
|
|
7
|
+
import { capability, exactRecord, RemoteCapacity, REMOTE_PROTOCOL, remoteError, validAlias,
|
|
8
|
+
validCapability, validMachine, validPort, REMOTE_LIMITS, type RemoteState } from "./remote-contract";
|
|
9
|
+
import { readRemoteFile, removeRemoteFile, withRemoteLock, writeRemoteFile } from "./remote-files";
|
|
10
|
+
import { parseEnrollmentJournal } from "./remote-enrollment-recovery";
|
|
11
|
+
import { runRemoteHelper } from "./remote-process";
|
|
12
|
+
import { RemoteMessageStore } from "./remote-store";
|
|
13
|
+
import { isThreadId } from "./types";
|
|
14
|
+
|
|
15
|
+
export type RemoteControlRunner = (argv: readonly string[], budget: MessageBudget, capacity: RemoteCapacity,
|
|
16
|
+
stdin?: string) => Promise<string>;
|
|
17
|
+
|
|
18
|
+
/** Forget only the revoked transaction, while the state mutation lock is still owned. */
|
|
19
|
+
function forgetPendingTransaction(store: RemoteMessageStore, transaction: string, removeJournal = unlinkSync): void {
|
|
20
|
+
const path = join(store.directory, "enrollment.json");
|
|
21
|
+
const text = readRemoteFile(path);
|
|
22
|
+
if (text === null) return;
|
|
23
|
+
let pendingTransaction: unknown;
|
|
24
|
+
try {
|
|
25
|
+
const saved = exactRecord(JSON.parse(text), ["alias", "ssh", "fingerprint", "generation", "request"]);
|
|
26
|
+
const request = exactRecord(saved.request, ["protocol", "action", "params"]);
|
|
27
|
+
pendingTransaction = exactRecord(request.params, ["machine", "transaction", "returnCapability", "port"]).transaction;
|
|
28
|
+
} catch {
|
|
29
|
+
// Uninterpretable text cannot match a valid in-flight saved journal.
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (pendingTransaction === transaction) removeRemoteFile(path, removeJournal);
|
|
33
|
+
}
|
|
34
|
+
/** Offer one host fingerprint without enrollment or persistent state; clean only this probe's scratch directory. */
|
|
35
|
+
export async function probeRemoteHost(ssh: string, budget: MessageBudget, capacity: RemoteCapacity,
|
|
36
|
+
runner: RemoteControlRunner = runRemoteHelper) {
|
|
37
|
+
if (!isSshAlias(ssh)) throw remoteError("invalid_ssh_alias", "Choose a valid SSH destination.");
|
|
38
|
+
const root = mkdtempSync(join(tmpdir(), "ocx-message-probe-"));
|
|
39
|
+
const path = join(root, "known_hosts");
|
|
40
|
+
try {
|
|
41
|
+
writeRemoteFile(path, "");
|
|
42
|
+
await runner(buildProbeArgv({ alias: ssh, tempKnownHostsFile: path }), budget, capacity);
|
|
43
|
+
const fingerprint = parseFingerprintLine((await runner(buildFingerprintArgv(path), budget, capacity)).trim()).fingerprint;
|
|
44
|
+
const hostKey = readRemoteFile(path);
|
|
45
|
+
if (!hostKey || hostKey.length > 8192 || hostKey.trim().split("\n").length !== 1
|
|
46
|
+
|| !hostKey.startsWith(`${ssh} `)) {
|
|
47
|
+
throw remoteError("invalid_host_key", "SSH probe did not return one bounded alias-bound host key.");
|
|
48
|
+
}
|
|
49
|
+
return { fingerprint, hostKey };
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(root, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Versioned private stdio control: invoked over confirmed SSH, never the network messaging gateway. */
|
|
55
|
+
export function handleEnrollmentControl(store: RemoteMessageStore, input: unknown, removeJournal = unlinkSync): unknown {
|
|
56
|
+
const raw = exactRecord(input, ["protocol", "action", "params"]);
|
|
57
|
+
if (raw.protocol !== REMOTE_PROTOCOL) throw remoteError("remote_incompatible", "Remote messaging protocol versions differ.");
|
|
58
|
+
const state = store.requireEnabled();
|
|
59
|
+
if (raw.action === "enroll") {
|
|
60
|
+
const params = exactRecord(raw.params, ["machine", "transaction", "returnCapability", "port"]);
|
|
61
|
+
if (!validMachine(params.machine) || params.machine.id === state.machine.id || !isThreadId(params.transaction)
|
|
62
|
+
|| !validCapability(params.returnCapability) || !validPort(params.port)) {
|
|
63
|
+
throw remoteError("invalid_enrollment", "Invalid messaging enrollment contract.");
|
|
64
|
+
}
|
|
65
|
+
const machine = params.machine, transaction = params.transaction, returnCapability = params.returnCapability, port = params.port;
|
|
66
|
+
let incoming = "", full = false;
|
|
67
|
+
const committed = store.mutate(current => {
|
|
68
|
+
const existing = current.peers.find(peer => peer.machine.id === machine.id || peer.transaction === transaction);
|
|
69
|
+
if (existing) {
|
|
70
|
+
if (existing.transaction !== transaction || existing.machine.id !== machine.id || existing.outgoing !== returnCapability
|
|
71
|
+
|| existing.port !== port) {
|
|
72
|
+
throw remoteError("enrollment_conflict", "An existing enrollment cannot be silently replaced.");
|
|
73
|
+
}
|
|
74
|
+
incoming = existing.incoming;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (current.peers.length >= REMOTE_LIMITS.peers) {
|
|
78
|
+
full = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
incoming = capability();
|
|
82
|
+
current.peers.push({ alias: `peer-${machine.id}`, machine, transaction, incoming, outgoing: returnCapability,
|
|
83
|
+
port, ssh: null, hostKey: null, fingerprint: null, allowInbound: true });
|
|
84
|
+
});
|
|
85
|
+
if (full) return { protocol: REMOTE_PROTOCOL, transaction, rejected: "peer_capacity" };
|
|
86
|
+
return { protocol: REMOTE_PROTOCOL, transaction, machine: committed.machine, port: committed.port, capability: incoming };
|
|
87
|
+
}
|
|
88
|
+
if (raw.action === "remove") {
|
|
89
|
+
const params = exactRecord(raw.params, ["machineId", "transaction"]);
|
|
90
|
+
if (!isThreadId(params.machineId) || !isThreadId(params.transaction)) {
|
|
91
|
+
throw new Error();
|
|
92
|
+
}
|
|
93
|
+
store.mutate(current => {
|
|
94
|
+
// Invalidate recovery before publishing absence, including an enrollment not yet locally published.
|
|
95
|
+
forgetPendingTransaction(store, params.transaction as string, removeJournal);
|
|
96
|
+
current.peers = current.peers.filter(peer => peer.machine.id !== params.machineId || peer.transaction !== params.transaction);
|
|
97
|
+
});
|
|
98
|
+
return { protocol: REMOTE_PROTOCOL, removed: true };
|
|
99
|
+
}
|
|
100
|
+
throw remoteError("invalid_control", "Unsupported messaging enrollment control operation.");
|
|
101
|
+
}
|
|
102
|
+
/** Grant outbound delivery and correlated return replies, never arbitrary inbound delivery on the initiator. */
|
|
103
|
+
export async function enrollRemoteHost(store: RemoteMessageStore, alias: string, ssh: string, fingerprint: string,
|
|
104
|
+
budget: MessageBudget, capacity: RemoteCapacity, runner: RemoteControlRunner = runRemoteHelper) {
|
|
105
|
+
const state = store.requireEnabled();
|
|
106
|
+
if (!validAlias(alias) || !isSshAlias(ssh) || !/^SHA256:[A-Za-z0-9+/=]{1,64}$/.test(fingerprint)) {
|
|
107
|
+
throw remoteError("invalid_enrollment", "Alias, SSH destination and confirmed fingerprint are required.");
|
|
108
|
+
}
|
|
109
|
+
const offered = await probeRemoteHost(ssh, budget, capacity, runner);
|
|
110
|
+
if (offered.fingerprint !== fingerprint) throw remoteError("host_key_changed", "The offered SSH key differs from the explicitly confirmed fingerprint.");
|
|
111
|
+
const pendingPath = join(store.directory, "enrollment.json");
|
|
112
|
+
const pending = withRemoteLock(store.directory, () => {
|
|
113
|
+
const existing = readRemoteFile(pendingPath);
|
|
114
|
+
if (existing !== null) {
|
|
115
|
+
const saved = parseEnrollmentJournal(existing);
|
|
116
|
+
if (saved.alias !== alias || saved.ssh !== ssh || saved.fingerprint !== fingerprint) {
|
|
117
|
+
throw remoteError("enrollment_pending", "Reconcile the existing messaging enrollment before starting another.");
|
|
118
|
+
}
|
|
119
|
+
return saved;
|
|
120
|
+
}
|
|
121
|
+
const current = store.requireEnabled();
|
|
122
|
+
if (current.generation !== state.generation) throw remoteError("enrollment_pending", "Local configuration changed during the probe; repeat explicitly.");
|
|
123
|
+
if (current.peers.some(peer => peer.alias === alias)) throw remoteError("enrollment_conflict", "This messaging alias is already enrolled.");
|
|
124
|
+
if (current.peers.length >= REMOTE_LIMITS.peers) throw remoteError("peer_capacity", "Remove an enrolled peer before adding another.");
|
|
125
|
+
const saved = { alias, ssh, fingerprint, generation: current.generation, request: { protocol: REMOTE_PROTOCOL, action: "enroll",
|
|
126
|
+
params: { machine: state.machine, transaction: crypto.randomUUID(), returnCapability: capability(), port: state.port } } };
|
|
127
|
+
budget.throwIfEnded();
|
|
128
|
+
writeRemoteFile(pendingPath, JSON.stringify(saved));
|
|
129
|
+
return saved;
|
|
130
|
+
});
|
|
131
|
+
const request = pending.request, params = request.params;
|
|
132
|
+
if (pending.generation !== state.generation || request.protocol !== REMOTE_PROTOCOL || request.action !== "enroll" || !validMachine(params.machine)
|
|
133
|
+
|| params.machine.id !== state.machine.id || !isThreadId(params.transaction) || !validCapability(params.returnCapability)
|
|
134
|
+
|| params.port !== state.port) {
|
|
135
|
+
throw remoteError("enrollment_pending", "Pending enrollment no longer matches this node; reconcile it explicitly.");
|
|
136
|
+
}
|
|
137
|
+
const hostsPath = join(store.directory, `enrollment_known_hosts-${params.transaction}-${crypto.randomUUID()}`);
|
|
138
|
+
try {
|
|
139
|
+
withRemoteLock(store.directory, () => {
|
|
140
|
+
if (readRemoteFile(pendingPath) !== JSON.stringify(pending)) {
|
|
141
|
+
throw remoteError("enrollment_pending", "Pending enrollment changed before SSH preparation.");
|
|
142
|
+
}
|
|
143
|
+
writeRemoteFile(hostsPath, offered.hostKey);
|
|
144
|
+
});
|
|
145
|
+
let reply: Record<string, unknown>;
|
|
146
|
+
try {
|
|
147
|
+
const value = JSON.parse(await runner(buildExecArgv({ alias: ssh, knownHostsFile: hostsPath,
|
|
148
|
+
argv: remoteOcxArgv(["message", "_control"]) }), budget, capacity, JSON.stringify(request)));
|
|
149
|
+
reply = exactRecord(value, value?.rejected !== undefined ? ["protocol", "transaction", "rejected"]
|
|
150
|
+
: ["protocol", "transaction", "machine", "port", "capability"]);
|
|
151
|
+
if (reply.protocol !== REMOTE_PROTOCOL || reply.transaction !== params.transaction) {
|
|
152
|
+
throw new Error("Enrollment reply does not match this protocol/transaction.");
|
|
153
|
+
}
|
|
154
|
+
if (reply.rejected !== undefined) {
|
|
155
|
+
if (reply.rejected !== "peer_capacity") throw new Error("Unknown enrollment rejection.");
|
|
156
|
+
} else if (!validMachine(reply.machine) || reply.machine.id === state.machine.id
|
|
157
|
+
|| !validPort(reply.port) || !validCapability(reply.capability)) {
|
|
158
|
+
throw new Error("Invalid enrollment reply identity, port or capability.");
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
throw remoteError("enrollment_unknown", "Remote enrollment may have committed. Reconcile the retained transaction, if present; do not create a replacement automatically.");
|
|
162
|
+
}
|
|
163
|
+
if (reply.rejected === "peer_capacity") {
|
|
164
|
+
throw remoteError("peer_capacity", "The remote node has no free messaging peer slot. Retry the same transaction after freeing a slot, or explicitly abandon it.");
|
|
165
|
+
}
|
|
166
|
+
const machine = reply.machine as RemoteState["machine"];
|
|
167
|
+
let inbound = "responses-only";
|
|
168
|
+
try {
|
|
169
|
+
budget.throwIfEnded();
|
|
170
|
+
const savedText = JSON.stringify(pending);
|
|
171
|
+
let ownsJournal = false;
|
|
172
|
+
store.mutate(current => {
|
|
173
|
+
if (current.generation !== state.generation) {
|
|
174
|
+
throw remoteError("enrollment_unknown", "Local configuration changed during enrollment; the remote transaction is retained.");
|
|
175
|
+
}
|
|
176
|
+
ownsJournal = readRemoteFile(pendingPath) === savedText;
|
|
177
|
+
const existing = current.peers.find(peer => peer.alias === alias || peer.machine.id === machine.id);
|
|
178
|
+
if (existing) {
|
|
179
|
+
if (existing.alias !== alias || existing.machine.id !== machine.id || existing.transaction !== params.transaction
|
|
180
|
+
|| existing.incoming !== params.returnCapability || existing.outgoing !== reply.capability
|
|
181
|
+
|| existing.port !== reply.port || existing.ssh !== ssh || existing.fingerprint !== fingerprint
|
|
182
|
+
|| existing.hostKey !== offered.hostKey) {
|
|
183
|
+
throw remoteError("enrollment_conflict", "The remote identity is already enrolled differently.");
|
|
184
|
+
}
|
|
185
|
+
inbound = existing.allowInbound === true ? "messages" : "responses-only";
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!ownsJournal) throw remoteError("enrollment_unknown", "This enrollment no longer owns the saved transaction.");
|
|
189
|
+
current.peers.push({ alias, machine, transaction: params.transaction as string, incoming: params.returnCapability as string,
|
|
190
|
+
outgoing: reply.capability as string, port: reply.port as number, ssh, hostKey: offered.hostKey, fingerprint, allowInbound: false });
|
|
191
|
+
}, () => {
|
|
192
|
+
if (ownsJournal) forgetPendingTransaction(store, params.transaction as string);
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
throw remoteError("enrollment_unknown", "Remote enrollment committed, but local completion is uncertain. Inspect current enrollment and any retained transaction before an explicit retry.");
|
|
196
|
+
}
|
|
197
|
+
return { protocol: REMOTE_PROTOCOL, alias, machine, enrolled: true, outbound: "messages", inbound,
|
|
198
|
+
inboundOptInCommand: `ocx message hosts allow-inbound ${machine.id} --json` };
|
|
199
|
+
} finally {
|
|
200
|
+
// The unique attempt owns this path, not the shared journal; a busy mutation must not prevent cleanup.
|
|
201
|
+
try {
|
|
202
|
+
if (readRemoteFile(hostsPath) !== null) removeRemoteFile(hostsPath);
|
|
203
|
+
} catch {
|
|
204
|
+
throw remoteError("remote_storage_unknown", "Enrollment scratch cleanup could not be confirmed. Inspect local state before recovery; the remote enrollment may have committed.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Revoke locally first, then attempt exact remote revocation; unknown cleanup never restores local admission. */
|
|
209
|
+
export async function removeRemoteHost(store: RemoteMessageStore, selector: string, budget: MessageBudget,
|
|
210
|
+
capacity: RemoteCapacity, runner: RemoteControlRunner = runRemoteHelper, removeJournal = unlinkSync,
|
|
211
|
+
expectedTransaction?: string) {
|
|
212
|
+
let peer!: RemoteState["peers"][number];
|
|
213
|
+
const state = store.mutate(current => {
|
|
214
|
+
const matches = current.peers.filter(item => item.alias === selector || item.machine.id === selector);
|
|
215
|
+
if (matches.length !== 1) throw remoteError("unknown_peer", "No unique enrolled messaging peer matches this exact host.");
|
|
216
|
+
peer = matches[0]!;
|
|
217
|
+
if (expectedTransaction !== undefined && (!isThreadId(expectedTransaction) || peer.transaction !== expectedTransaction)) {
|
|
218
|
+
throw remoteError("enrollment_conflict", "The peer no longer matches the cleanup transaction; no peer was removed.");
|
|
219
|
+
}
|
|
220
|
+
forgetPendingTransaction(store, peer.transaction, removeJournal);
|
|
221
|
+
current.peers = current.peers.filter(item => item.transaction !== peer.transaction);
|
|
222
|
+
});
|
|
223
|
+
let remote: "removed" | "unconfirmed" = "unconfirmed";
|
|
224
|
+
if (peer.ssh) {
|
|
225
|
+
const path = join(store.directory, `revocation_known_hosts-${peer.transaction}-${crypto.randomUUID()}`);
|
|
226
|
+
try {
|
|
227
|
+
writeRemoteFile(path, peer.hostKey!);
|
|
228
|
+
const reply = exactRecord(JSON.parse(await runner(buildExecArgv({ alias: peer.ssh, knownHostsFile: path,
|
|
229
|
+
argv: remoteOcxArgv(["message", "_control"]) }), budget, capacity, JSON.stringify({ protocol: REMOTE_PROTOCOL,
|
|
230
|
+
action: "remove", params: { machineId: state.machine.id, transaction: peer.transaction } }))), ["protocol", "removed"]);
|
|
231
|
+
if (reply.protocol === REMOTE_PROTOCOL && reply.removed === true) remote = "removed";
|
|
232
|
+
} catch {
|
|
233
|
+
// Receipt explicitly preserves unconfirmed remote cleanup.
|
|
234
|
+
} finally {
|
|
235
|
+
try {
|
|
236
|
+
removeRemoteFile(path);
|
|
237
|
+
} catch {
|
|
238
|
+
// Scratch cleanup is best-effort and cannot undo local revocation.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { protocol: REMOTE_PROTOCOL, machine: peer.machine, locallyRemoved: true, remote,
|
|
243
|
+
...(remote === "unconfirmed" ? { remoteCleanupCommand: `ocx message hosts remove ${state.machine.id} --transaction ${peer.transaction} --json` } : {}) };
|
|
244
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { messageEnvelope } from "./envelope";
|
|
2
|
+
import { isRecord } from "./types";
|
|
3
|
+
import { remoteError, type RemotePeer } from "./remote-contract";
|
|
4
|
+
|
|
5
|
+
/** Accept only the canonical wrapper bound to this authenticated enrollment and queue UUID. */
|
|
6
|
+
export function receiveRemoteEnvelope(text: string, messageId: string, peer: RemotePeer) {
|
|
7
|
+
const prefix = "[opencodex-message ";
|
|
8
|
+
const end = text.indexOf("]\n\n");
|
|
9
|
+
const marker = "\n\nPeer-provided message body follows:\n\n";
|
|
10
|
+
const bodyStart = text.indexOf(marker, end);
|
|
11
|
+
try {
|
|
12
|
+
if (!text.startsWith(prefix) || end < prefix.length || bodyStart < end) {
|
|
13
|
+
throw new Error("Invalid message envelope framing.");
|
|
14
|
+
}
|
|
15
|
+
const header: unknown = JSON.parse(text.slice(prefix.length, end));
|
|
16
|
+
if (!isRecord(header) || header.messageId !== messageId) {
|
|
17
|
+
throw new Error("Message envelope header does not match the queue UUID.");
|
|
18
|
+
}
|
|
19
|
+
if (header.threadId !== null && typeof header.threadId !== "string") {
|
|
20
|
+
throw new Error("Invalid message envelope sender thread.");
|
|
21
|
+
}
|
|
22
|
+
if (header.name !== null && (typeof header.name !== "string" || header.name.length > 4096)) {
|
|
23
|
+
throw new Error("Invalid message envelope sender name.");
|
|
24
|
+
}
|
|
25
|
+
const sender = header.threadId === null ? null : {
|
|
26
|
+
id: header.threadId as string, name: header.name as string | null, status: "idle" as const,
|
|
27
|
+
};
|
|
28
|
+
const envelope = messageEnvelope(messageId, {
|
|
29
|
+
kind: header.kind as "request" | "response" | "notification",
|
|
30
|
+
...(header.inReplyTo === null ? {} : { inReplyTo: header.inReplyTo as string }),
|
|
31
|
+
}, text.slice(bodyStart + marker.length), sender, peer.machine);
|
|
32
|
+
// This also rejects forged reply commands/hosts, missing guidance, duplicate JSON keys and extra fields.
|
|
33
|
+
if (envelope.text !== text) {
|
|
34
|
+
throw new Error("Message envelope is not canonical for the authenticated peer.");
|
|
35
|
+
}
|
|
36
|
+
return envelope;
|
|
37
|
+
} catch {
|
|
38
|
+
throw remoteError("invalid_remote_envelope", "Remote message attribution or guidance does not match the authenticated peer.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync,
|
|
2
|
+
realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
5
|
+
import { remoteError, REMOTE_LIMITS } from "./remote-contract";
|
|
6
|
+
|
|
7
|
+
/** Check the entire path ancestry before using the private messaging subtree. */
|
|
8
|
+
export function checkRemoteDirectory(path: string): void {
|
|
9
|
+
checkDirectory(path, true);
|
|
10
|
+
}
|
|
11
|
+
/** The configuration home needs trusted ancestry; only messaging's own leaf must be private. */
|
|
12
|
+
function checkDirectory(path: string, privateLeaf: boolean): void {
|
|
13
|
+
if (!["linux", "darwin"].includes(process.platform) || !isAbsolute(path)) {
|
|
14
|
+
throw remoteError("unsupported_platform", "Remote messaging requires a private Linux or macOS configuration home.");
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const final = lstatSync(path);
|
|
18
|
+
if (!final.isDirectory() || final.isSymbolicLink()
|
|
19
|
+
|| (privateLeaf && (final.uid !== process.getuid!() || (final.mode & 0o077)))) throw new Error();
|
|
20
|
+
// Root-controlled OS aliases (/tmp and /var on macOS) are not user-controlled redirects.
|
|
21
|
+
for (let entry = path; ; entry = dirname(entry)) {
|
|
22
|
+
const stat = lstatSync(entry);
|
|
23
|
+
if (stat.isSymbolicLink()) { if (stat.uid !== 0) throw new Error(); }
|
|
24
|
+
else if (!stat.isDirectory() || (stat.uid !== process.getuid!() && stat.uid !== 0)
|
|
25
|
+
|| ((stat.mode & 0o022) !== 0 && !(stat.uid === 0 && (stat.mode & 0o1000) !== 0))
|
|
26
|
+
|| (privateLeaf && entry === path && (stat.uid !== process.getuid!() || (stat.mode & 0o077) !== 0))) throw new Error();
|
|
27
|
+
if (entry === "/") break;
|
|
28
|
+
}
|
|
29
|
+
for (let entry = realpathSync(path); ; entry = dirname(entry)) {
|
|
30
|
+
const stat = lstatSync(entry);
|
|
31
|
+
if (!stat.isDirectory() || (stat.uid !== process.getuid!() && stat.uid !== 0)
|
|
32
|
+
|| ((stat.mode & 0o022) && !(stat.uid === 0 && (stat.mode & 0o1000)))) throw new Error();
|
|
33
|
+
if (entry === "/") break;
|
|
34
|
+
}
|
|
35
|
+
} catch { throw remoteError("unsafe_remote_storage", "Messaging storage requires private owned directories and trusted ancestors."); }
|
|
36
|
+
}
|
|
37
|
+
/** ENOENT alone means absent; permission failures and redirects are not empty state. */
|
|
38
|
+
export function remotePathExists(path: string): boolean {
|
|
39
|
+
try { lstatSync(path); return true; }
|
|
40
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
41
|
+
}
|
|
42
|
+
/** A published directory entry must reach storage before dependent remote work starts. */
|
|
43
|
+
function syncRemoteDirectory(path: string, sync = fsyncSync): void {
|
|
44
|
+
let fd: number | undefined;
|
|
45
|
+
try {
|
|
46
|
+
fd = openSync(realpathSync(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
47
|
+
sync(fd);
|
|
48
|
+
} catch { throw remoteError("remote_storage_unknown", "Messaging storage changed, but its crash durability could not be confirmed; inspect state before explicit recovery."); }
|
|
49
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
50
|
+
}
|
|
51
|
+
/** Remove a validated private file durably; shared journals require the caller's mutation lock. */
|
|
52
|
+
export function removeRemoteFile(path: string, remove = unlinkSync, sync = fsyncSync): void {
|
|
53
|
+
assertNotRealHomeUnderTest(path); readRemoteFile(path);
|
|
54
|
+
remove(path); syncRemoteDirectory(dirname(path), sync);
|
|
55
|
+
}
|
|
56
|
+
/** Create only explicitly requested state, refusing an unsafe existing configuration home. */
|
|
57
|
+
export function createRemoteDirectory(path: string): void {
|
|
58
|
+
assertNotRealHomeUnderTest(path);
|
|
59
|
+
const parent = dirname(path);
|
|
60
|
+
if (!remotePathExists(parent)) {
|
|
61
|
+
if (!remotePathExists(dirname(parent))) throw remoteError("unsafe_remote_storage", "Messaging configuration parent must already exist.");
|
|
62
|
+
checkDirectory(dirname(parent), false);
|
|
63
|
+
mkdirSync(parent, { mode: 0o700 });
|
|
64
|
+
syncRemoteDirectory(dirname(parent));
|
|
65
|
+
}
|
|
66
|
+
checkDirectory(parent, false);
|
|
67
|
+
if (!remotePathExists(path)) { mkdirSync(path, { mode: 0o700 }); syncRemoteDirectory(parent); }
|
|
68
|
+
checkRemoteDirectory(path);
|
|
69
|
+
}
|
|
70
|
+
/** Bounded, fatal-UTF-8, no-follow regular file reads never open SQLite or create absent state. */
|
|
71
|
+
export function readRemoteFile(path: string): string | null {
|
|
72
|
+
if (!remotePathExists(dirname(path))) return null;
|
|
73
|
+
checkRemoteDirectory(dirname(path));
|
|
74
|
+
let fd: number;
|
|
75
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
76
|
+
catch (error) {
|
|
77
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
78
|
+
throw remoteError("unsafe_remote_storage", "Cannot safely read messaging state.");
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const stat = fstatSync(fd);
|
|
82
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid!() || (stat.mode & 0o077)
|
|
83
|
+
|| stat.size > REMOTE_LIMITS.storeBytes) throw new Error();
|
|
84
|
+
const bytes = Buffer.alloc(REMOTE_LIMITS.storeBytes + 1);
|
|
85
|
+
let size = 0;
|
|
86
|
+
while (size < bytes.length) { const count = readSync(fd, bytes, size, bytes.length - size, size); if (!count) break; size += count; }
|
|
87
|
+
if (size > REMOTE_LIMITS.storeBytes) throw new Error();
|
|
88
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, size));
|
|
89
|
+
} catch { throw remoteError("unsafe_remote_storage", "Messaging state is unsafe, oversized or malformed."); }
|
|
90
|
+
finally { closeSync(fd); }
|
|
91
|
+
}
|
|
92
|
+
/** Publish a private file atomically; this primitive is called while its store lock is owned. */
|
|
93
|
+
export function writeRemoteFile(path: string, text: string, sync = fsyncSync): void {
|
|
94
|
+
assertNotRealHomeUnderTest(path);
|
|
95
|
+
checkRemoteDirectory(dirname(path));
|
|
96
|
+
if (Buffer.byteLength(text) > REMOTE_LIMITS.storeBytes) throw remoteError("remote_capacity", "Messaging state exceeds its byte limit.");
|
|
97
|
+
readRemoteFile(path);
|
|
98
|
+
const temporary = join(dirname(path), `.write-${crypto.randomUUID()}`);
|
|
99
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
100
|
+
try {
|
|
101
|
+
try { writeFileSync(fd, text, "utf8"); sync(fd); } finally { closeSync(fd); }
|
|
102
|
+
renameSync(temporary, path);
|
|
103
|
+
syncRemoteDirectory(dirname(path), sync);
|
|
104
|
+
} finally { if (remotePathExists(temporary)) unlinkSync(temporary); }
|
|
105
|
+
}
|
|
106
|
+
/** Serialize cooperating mutations. Crash residue fails closed; it is never reaped by age. */
|
|
107
|
+
export function withRemoteLock<T>(directory: string, action: () => T): T {
|
|
108
|
+
const lock = join(directory, "mutation.lock");
|
|
109
|
+
try { mkdirSync(lock, { mode: 0o700 }); }
|
|
110
|
+
catch { throw remoteError("remote_state_busy", "Messaging state is locked; finish or recover its owning mutation first."); }
|
|
111
|
+
const identity = lstatSync(lock);
|
|
112
|
+
try { return action(); }
|
|
113
|
+
finally {
|
|
114
|
+
const current = lstatSync(lock);
|
|
115
|
+
if (current.dev === identity.dev && current.ino === identity.ino) rmdirSync(lock);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { MessageBudget } from "./budget";
|
|
2
|
+
import { authenticatedRemoteSocket, peerEndpoint, remoteControl, type RemoteEndpoint } from "./remote-auth";
|
|
3
|
+
import { startRemoteBridge } from "./remote-bridge";
|
|
4
|
+
import { exactRecord, RemoteCapacity, REMOTE_LIMITS, remoteError, validPort, type RemotePeer, type RemoteState } from "./remote-contract";
|
|
5
|
+
import { remotePortIsLoopback } from "./remote-ports";
|
|
6
|
+
import { LocalMessageRpc } from "./rpc";
|
|
7
|
+
import type { RemoteMessageStore } from "./remote-store";
|
|
8
|
+
import { startRemoteTunnels, type RemoteTunnelPair, type RemoteTunnelDeps } from "./remote-tunnels";
|
|
9
|
+
import { isThreadId, LocalMessagingError } from "./types";
|
|
10
|
+
|
|
11
|
+
interface Route { peer: RemotePeer; port: number; generation: string; expiry: number; ready: boolean; tunnels?: RemoteTunnelPair }
|
|
12
|
+
export interface RemoteOwnerDeps extends RemoteTunnelDeps { now?: () => number }
|
|
13
|
+
/** Local owner-control capability never leaves this configuration home or enters remote enrollment. */
|
|
14
|
+
export function ownerEndpoint(state: RemoteState): RemoteEndpoint {
|
|
15
|
+
return { port: state.port, machineId: state.machine.id, transaction: state.generation, key: state.controlKey };
|
|
16
|
+
}
|
|
17
|
+
/** Discover a route from the live authenticated owner, never a cached loopback port or implicit startup. */
|
|
18
|
+
export async function resolveRemoteRoute(store: RemoteMessageStore, selector: string, budget: MessageBudget) {
|
|
19
|
+
const state = store.requireEnabled(), peer = store.peer(selector);
|
|
20
|
+
const result = exactRecord(await remoteControl(state, ownerEndpoint(state), "message/routes", {}, budget), ["routes"]);
|
|
21
|
+
if (!Array.isArray(result.routes) || result.routes.length > REMOTE_LIMITS.peers) throw remoteError("invalid_remote_route", "Messaging owner returned invalid routes.");
|
|
22
|
+
const matches = result.routes.filter(route => route && typeof route === "object" && route.machineId === peer.machine.id);
|
|
23
|
+
if (matches.length !== 1) throw remoteError("route_unavailable", "No live messaging route exists for this enrolled peer; run the explicit foreground owner.");
|
|
24
|
+
const route = exactRecord(matches[0], ["machineId", "transaction", "port", "kind"]);
|
|
25
|
+
if (route.transaction !== peer.transaction || !validPort(route.port) || !["initiated", "leased"].includes(String(route.kind))) throw remoteError("invalid_remote_route", "Messaging route does not match the current enrollment.");
|
|
26
|
+
store.requireCurrentPeer(state, peer);
|
|
27
|
+
return { state, peer, endpoint: peerEndpoint(peer, route.port) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Explicit foreground lifetime: one listener, aggregate reservations and bounded owned tunnel teardown. */
|
|
31
|
+
export async function startRemoteOwner(store: RemoteMessageStore, home: string, selectors: readonly string[], parent?: AbortSignal,
|
|
32
|
+
tunnelDeps: RemoteOwnerDeps = {}) {
|
|
33
|
+
const state = store.requireEnabled(), generation = crypto.randomUUID(), controller = new AbortController();
|
|
34
|
+
const now = tunnelDeps.now ?? (() => performance.now());
|
|
35
|
+
const capacity = new RemoteCapacity(), routes = new Map<string, Route>(), operations = new Set<Promise<unknown>>();
|
|
36
|
+
// Availability may retire before cleanup settles; retain every published pair's join and failure.
|
|
37
|
+
const ownedTunnels = new Set<RemoteTunnelPair>();
|
|
38
|
+
let finish!: () => void, finishError!: (error: Error) => void;
|
|
39
|
+
const finished = new Promise<void>((resolve, reject) => { finish = resolve; finishError = reject; });
|
|
40
|
+
void finished.catch(() => {});
|
|
41
|
+
let stopping = false, closing: Promise<void> | undefined, timer: ReturnType<typeof setInterval> | undefined;
|
|
42
|
+
const check = () => {
|
|
43
|
+
const current = store.requireEnabled();
|
|
44
|
+
if (stopping || current.generation !== state.generation) throw remoteError("owner_retired", "Messaging owner configuration changed; restart this foreground owner explicitly.");
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
const probe = async (peer: RemotePeer, port: number, budget: MessageBudget) => {
|
|
48
|
+
const release = capacity.reserve("connections"); let rpc: LocalMessageRpc | undefined;
|
|
49
|
+
try { rpc = await LocalMessageRpc.attach(await authenticatedRemoteSocket(check(), peerEndpoint(peer, port), budget), budget); }
|
|
50
|
+
finally { rpc?.close(); release(); }
|
|
51
|
+
};
|
|
52
|
+
const bridge = startRemoteBridge(store, home, capacity, controller.signal, {
|
|
53
|
+
async control(peer, method, params, budget) {
|
|
54
|
+
check();
|
|
55
|
+
if (method === "message/routes" && !peer) {
|
|
56
|
+
exactRecord(params, []);
|
|
57
|
+
const current = check();
|
|
58
|
+
return { routes: [...routes.values()].filter(route => route.ready && current.peers.some(p => p.transaction === route.peer.transaction)
|
|
59
|
+
&& (route.tunnels ? route.tunnels.alive : route.expiry > now())).map(route => ({
|
|
60
|
+
machineId: route.peer.machine.id, transaction: route.peer.transaction, port: route.port, kind: route.tunnels ? "initiated" : "leased" })) };
|
|
61
|
+
}
|
|
62
|
+
if (method !== "message/lease" || !peer) throw new Error();
|
|
63
|
+
const lease = exactRecord(params, ["port", "generation"]);
|
|
64
|
+
if (!validPort(lease.port) || !isThreadId(lease.generation)) throw new Error();
|
|
65
|
+
if (!await remotePortIsLoopback(lease.port, budget, capacity)) throw new Error();
|
|
66
|
+
await probe(peer, lease.port, budget);
|
|
67
|
+
check(); budget.throwIfEnded(); store.requireCurrentPeer(state, peer);
|
|
68
|
+
const existing = routes.get(peer.machine.id);
|
|
69
|
+
if (existing?.tunnels) throw new Error();
|
|
70
|
+
if (existing && existing.generation !== lease.generation && existing.expiry > now()) {
|
|
71
|
+
if (existing.port === lease.port) throw new Error();
|
|
72
|
+
// A live generation keeps its lease. Native availability is not owner identity evidence.
|
|
73
|
+
const release = capacity.reserve("connections"); let oldBudget: MessageBudget | undefined;
|
|
74
|
+
let authenticated = false;
|
|
75
|
+
try {
|
|
76
|
+
oldBudget = new MessageBudget(budget.remainingMs(1000), budget.signal);
|
|
77
|
+
try {
|
|
78
|
+
const socket = await authenticatedRemoteSocket(check(), peerEndpoint(existing.peer, existing.port), oldBudget);
|
|
79
|
+
socket.terminate(); authenticated = true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!(error instanceof LocalMessagingError) || !["remote_auth_failed", "operation_timeout"].includes(error.code)) throw error;
|
|
82
|
+
}
|
|
83
|
+
} finally { oldBudget?.dispose(); release(); }
|
|
84
|
+
check(); budget.throwIfEnded(); store.requireCurrentPeer(state, peer);
|
|
85
|
+
if (authenticated || routes.get(peer.machine.id) !== existing) throw new Error();
|
|
86
|
+
}
|
|
87
|
+
routes.set(peer.machine.id, { peer, port: lease.port, generation: lease.generation, ready: true, expiry: now() + REMOTE_LIMITS.leaseMs });
|
|
88
|
+
return { leased: true, generation: lease.generation };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const register = async (route: Route, budget: MessageBudget) => {
|
|
92
|
+
const free = capacity.reserve("connections");
|
|
93
|
+
try {
|
|
94
|
+
const result = exactRecord(await remoteControl(check(), peerEndpoint(route.peer, route.port), "message/lease",
|
|
95
|
+
{ port: route.tunnels!.returnPort, generation }, budget), ["leased", "generation"]);
|
|
96
|
+
check(); budget.throwIfEnded(); store.requireCurrentPeer(state, route.peer);
|
|
97
|
+
if (routes.get(route.peer.machine.id) !== route) throw new Error();
|
|
98
|
+
if (result.leased !== true || result.generation !== generation || !route.tunnels!.alive) throw new Error();
|
|
99
|
+
} finally { free(); }
|
|
100
|
+
};
|
|
101
|
+
const renew = async (budget: MessageBudget) => {
|
|
102
|
+
const results = await Promise.allSettled([...routes.values()].filter(route => route.tunnels).map(route => register(route, budget)));
|
|
103
|
+
const failure = results.find(result => result.status === "rejected");
|
|
104
|
+
if (failure?.status === "rejected") throw failure.reason;
|
|
105
|
+
check(); budget.throwIfEnded();
|
|
106
|
+
};
|
|
107
|
+
const close = (): Promise<void> => closing ??= (async () => {
|
|
108
|
+
stopping = true; clearInterval(timer); parent?.removeEventListener("abort", abort); controller.abort();
|
|
109
|
+
const results = await Promise.allSettled([bridge.close(), ...[...ownedTunnels].map(tunnels => tunnels.close()), ...operations]);
|
|
110
|
+
routes.clear(); ownedTunnels.clear();
|
|
111
|
+
if (results.some(result => result.status === "rejected")) {
|
|
112
|
+
const error = remoteError("cleanup_incomplete", "Messaging owner cleanup did not complete cleanly.");
|
|
113
|
+
finishError(error); throw error;
|
|
114
|
+
}
|
|
115
|
+
finish();
|
|
116
|
+
})();
|
|
117
|
+
const abort = () => { void close().catch(() => {}); };
|
|
118
|
+
parent?.addEventListener("abort", abort, { once: true });
|
|
119
|
+
if (parent?.aborted) abort();
|
|
120
|
+
const setup = new MessageBudget(30000, controller.signal);
|
|
121
|
+
try {
|
|
122
|
+
const unique = new Set(selectors.map(selector => store.peer(selector).machine.id));
|
|
123
|
+
if (unique.size !== selectors.length) throw remoteError("duplicate_peer", "Do not initiate the same peer more than once.");
|
|
124
|
+
for (const selector of selectors) {
|
|
125
|
+
check(); const peer = store.peer(selector);
|
|
126
|
+
const tunnels = await startRemoteTunnels(store, peer, setup, capacity, controller.signal, tunnelDeps);
|
|
127
|
+
const route: Route = { peer, port: tunnels.localPort, generation, expiry: Infinity, ready: false, tunnels };
|
|
128
|
+
try {
|
|
129
|
+
check(); setup.throwIfEnded(); ownedTunnels.add(tunnels); routes.set(peer.machine.id, route);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
// Before publication this local scope, not the routes map, owns the join.
|
|
132
|
+
await tunnels.close(); throw error;
|
|
133
|
+
}
|
|
134
|
+
void tunnels.exited.then(() => {
|
|
135
|
+
if (!stopping && routes.get(peer.machine.id) === route) { routes.delete(peer.machine.id); abort(); }
|
|
136
|
+
});
|
|
137
|
+
// The data connection proves both the peer and native initialization before ready publication.
|
|
138
|
+
await probe(peer, route.port, setup); await register(route, setup);
|
|
139
|
+
check(); setup.throwIfEnded();
|
|
140
|
+
}
|
|
141
|
+
// Earlier peers may have spent their lease lifetime waiting for later setup.
|
|
142
|
+
const renewal = new MessageBudget(setup.remainingMs(5000), setup.signal);
|
|
143
|
+
try { await renew(renewal); for (const route of routes.values()) route.ready = true; }
|
|
144
|
+
finally { renewal.dispose(); }
|
|
145
|
+
} catch (error) { await close(); throw error; }
|
|
146
|
+
finally { setup.dispose(); }
|
|
147
|
+
if (stopping) { await close(); throw remoteError("owner_retired", "Messaging owner stopped during setup."); }
|
|
148
|
+
let refreshing = false;
|
|
149
|
+
timer = setInterval(() => {
|
|
150
|
+
if (refreshing || stopping) return;
|
|
151
|
+
refreshing = true;
|
|
152
|
+
const task = (async () => {
|
|
153
|
+
const budget = new MessageBudget(5000, controller.signal);
|
|
154
|
+
try {
|
|
155
|
+
check();
|
|
156
|
+
for (const route of [...routes.values()]) {
|
|
157
|
+
if (!store.read()?.peers.some(peer => peer.transaction === route.peer.transaction)) {
|
|
158
|
+
routes.delete(route.peer.machine.id); await route.tunnels?.close();
|
|
159
|
+
} else if (!route.tunnels && route.expiry <= now()) routes.delete(route.peer.machine.id);
|
|
160
|
+
}
|
|
161
|
+
await renew(budget);
|
|
162
|
+
} catch { abort(); }
|
|
163
|
+
finally { budget.dispose(); refreshing = false; }
|
|
164
|
+
})();
|
|
165
|
+
operations.add(task);
|
|
166
|
+
void task.finally(() => operations.delete(task));
|
|
167
|
+
}, 10000);
|
|
168
|
+
return { port: bridge.port, capacity, close, finished };
|
|
169
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { MessageBudget } from "./budget";
|
|
3
|
+
import { RemoteCapacity, remoteError, validPort } from "./remote-contract";
|
|
4
|
+
import { runRemoteHelper, type RemoteHelperOptions } from "./remote-process";
|
|
5
|
+
|
|
6
|
+
type RemoteProcFile = {
|
|
7
|
+
read(buffer: Buffer, offset: number, length: number, position: null): Promise<{ bytesRead: number }>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Select a candidate only; actual forwarding and authenticated readiness must still succeed. */
|
|
12
|
+
export function remotePortCandidate(): number {
|
|
13
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { socket.end(); } } });
|
|
14
|
+
const port = listener.port; listener.stop(true);
|
|
15
|
+
if (!validPort(port)) throw remoteError("invalid_port", "Cannot allocate an unprivileged messaging port candidate.");
|
|
16
|
+
return port;
|
|
17
|
+
}
|
|
18
|
+
/** Refuse GatewayPorts/public binds; neither port existence nor this scan proves endpoint identity. */
|
|
19
|
+
export async function remotePortIsLoopback(port: number, budget: MessageBudget, capacity: RemoteCapacity,
|
|
20
|
+
inspector: { platform?: NodeJS.Platform; helper?: RemoteHelperOptions;
|
|
21
|
+
openProc?: (path: string) => Promise<RemoteProcFile> } = {}): Promise<boolean> {
|
|
22
|
+
if (!validPort(port)) return false;
|
|
23
|
+
budget.throwIfEnded();
|
|
24
|
+
const platform = inspector.platform ?? process.platform;
|
|
25
|
+
if (platform === "linux") {
|
|
26
|
+
const found: string[] = [], hex = port.toString(16).toUpperCase().padStart(4, "0");
|
|
27
|
+
for (const path of ["/proc/net/tcp", "/proc/net/tcp6"]) {
|
|
28
|
+
let fd: RemoteProcFile;
|
|
29
|
+
try { fd = await (inspector.openProc ?? (path => open(path, "r")))(path); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (path === "/proc/net/tcp6" && (error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const bytes = Buffer.alloc(4 * 1024 * 1024 + 1); let size = 0;
|
|
36
|
+
while (size < bytes.length) {
|
|
37
|
+
budget.throwIfEnded(); const next = await fd.read(bytes, size, bytes.length - size, null);
|
|
38
|
+
if (!next.bytesRead) break; size += next.bytesRead;
|
|
39
|
+
}
|
|
40
|
+
if (size >= bytes.length) return false;
|
|
41
|
+
for (const line of bytes.subarray(0, size).toString("utf8").split("\n").slice(1)) {
|
|
42
|
+
const fields = line.trim().split(/\s+/), [address, fieldPort] = (fields[1] ?? "").split(":");
|
|
43
|
+
if (fields[3] === "0A" && fieldPort === hex) found.push(address!);
|
|
44
|
+
}
|
|
45
|
+
} finally { await fd.close(); }
|
|
46
|
+
}
|
|
47
|
+
return found.length > 0 && found.every(address => address === "0100007F" || address === "00000000000000000000000001000000");
|
|
48
|
+
}
|
|
49
|
+
if (platform === "darwin") {
|
|
50
|
+
const output = await runRemoteHelper(["/usr/sbin/lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fn"], budget, capacity,
|
|
51
|
+
undefined, undefined, { ...inspector.helper, successCodes: [0, 1] });
|
|
52
|
+
const addresses = output.split("\n").filter(line => line.startsWith("n"));
|
|
53
|
+
return addresses.length > 0 && addresses.every(line => line === `n127.0.0.1:${port}` || line === `n[::1]:${port}`);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|