@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,83 @@
|
|
|
1
|
+
import { MessageBudget } from "./budget";
|
|
2
|
+
import type { LocalSocket } from "./socket";
|
|
3
|
+
import { capability, exactRecord, matchesProof, peerProof, REMOTE_PROTOCOL, remoteError,
|
|
4
|
+
validCapability, type RemotePeer, type RemoteState } from "./remote-contract";
|
|
5
|
+
|
|
6
|
+
export interface RemoteEndpoint { port: number; machineId: string; transaction: string; key: string }
|
|
7
|
+
/** Select one directional receiver-issued capability, not a provider or Remote Link credential. */
|
|
8
|
+
export function peerEndpoint(peer: RemotePeer, port: number): RemoteEndpoint {
|
|
9
|
+
return { port, machineId: peer.machine.id, transaction: peer.transaction, key: peer.outgoing };
|
|
10
|
+
}
|
|
11
|
+
/** Authenticate the actual socket before sending a bearer-derived client proof or any native/body frame. */
|
|
12
|
+
export async function authenticatedRemoteSocket(state: RemoteState, endpoint: RemoteEndpoint,
|
|
13
|
+
budget: MessageBudget): Promise<LocalSocket> {
|
|
14
|
+
budget.throwIfEnded();
|
|
15
|
+
if (!Number.isInteger(endpoint.port) || endpoint.port < 1024 || endpoint.port > 65535) throw remoteError("invalid_port", "Invalid messaging route port.");
|
|
16
|
+
const Constructor = WebSocket as unknown as { new(url: string): LocalSocket };
|
|
17
|
+
const socket = new Constructor(`ws://127.0.0.1:${endpoint.port}/message`);
|
|
18
|
+
const nonce = capability();
|
|
19
|
+
try {
|
|
20
|
+
await new Promise<void>((resolve, reject) => {
|
|
21
|
+
let stage = 0;
|
|
22
|
+
const cleanup = () => {
|
|
23
|
+
clearTimeout(timer); budget.signal.removeEventListener("abort", abort);
|
|
24
|
+
socket.onopen = socket.onerror = socket.onclose = socket.onmessage = null;
|
|
25
|
+
};
|
|
26
|
+
const fail = () => { cleanup(); reject(remoteError("remote_auth_failed", "Messaging connection identity/authentication failed; no message was sent.")); };
|
|
27
|
+
const abort = () => { cleanup(); reject(budget.signal.reason); };
|
|
28
|
+
const timer = setTimeout(fail, budget.remainingMs(5000));
|
|
29
|
+
budget.signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
socket.onerror = socket.onclose = fail;
|
|
31
|
+
socket.onopen = () => socket.send(JSON.stringify({ type: "hello", protocol: REMOTE_PROTOCOL,
|
|
32
|
+
from: state.machine.id, to: endpoint.machineId, transaction: endpoint.transaction, nonce }));
|
|
33
|
+
socket.onmessage = event => {
|
|
34
|
+
try {
|
|
35
|
+
if (typeof event.data !== "string" || Buffer.byteLength(event.data) > 4096) throw new Error();
|
|
36
|
+
const raw: unknown = JSON.parse(event.data);
|
|
37
|
+
if (stage === 0) {
|
|
38
|
+
const proof = exactRecord(raw, ["type", "nonce", "proof"]);
|
|
39
|
+
if (proof.type !== "challenge" || !validCapability(proof.nonce)
|
|
40
|
+
|| !matchesProof(proof.proof, peerProof(endpoint.key, "server", state.machine.id, endpoint.machineId,
|
|
41
|
+
endpoint.transaction, nonce, proof.nonce))) throw new Error();
|
|
42
|
+
budget.throwIfEnded(); stage = 1;
|
|
43
|
+
socket.send(JSON.stringify({ type: "authorize", proof: peerProof(endpoint.key, "client", state.machine.id,
|
|
44
|
+
endpoint.machineId, endpoint.transaction, nonce, proof.nonce) }));
|
|
45
|
+
} else {
|
|
46
|
+
const ready = exactRecord(raw, ["type", "protocol"]);
|
|
47
|
+
if (ready.type !== "authorized" || ready.protocol !== REMOTE_PROTOCOL) throw new Error();
|
|
48
|
+
cleanup(); resolve();
|
|
49
|
+
}
|
|
50
|
+
} catch { fail(); }
|
|
51
|
+
};
|
|
52
|
+
if (budget.signal.aborted) abort();
|
|
53
|
+
});
|
|
54
|
+
budget.throwIfEnded(); return socket;
|
|
55
|
+
} catch (error) { socket.terminate(); throw error; }
|
|
56
|
+
}
|
|
57
|
+
/** Bound a control operation to one authenticated connection; never reconnect/replay on uncertainty. */
|
|
58
|
+
export async function remoteControl(state: RemoteState, endpoint: RemoteEndpoint, action: string,
|
|
59
|
+
params: Record<string, unknown>, budget: MessageBudget): Promise<unknown> {
|
|
60
|
+
const socket = await authenticatedRemoteSocket(state, endpoint, budget);
|
|
61
|
+
try {
|
|
62
|
+
return await new Promise((resolve, reject) => {
|
|
63
|
+
const finish = (error?: Error, result?: unknown) => {
|
|
64
|
+
clearTimeout(timer); budget.signal.removeEventListener("abort", abort);
|
|
65
|
+
socket.onmessage = socket.onerror = socket.onclose = null;
|
|
66
|
+
if (error) reject(error); else resolve(result);
|
|
67
|
+
};
|
|
68
|
+
const abort = () => finish(remoteError("remote_control_unknown", "Messaging control outcome is unconfirmed; reconcile configuration before retrying."));
|
|
69
|
+
const timer = setTimeout(abort, budget.remainingMs(10000));
|
|
70
|
+
budget.signal.addEventListener("abort", abort, { once: true });
|
|
71
|
+
socket.onclose = socket.onerror = abort;
|
|
72
|
+
socket.onmessage = event => {
|
|
73
|
+
try {
|
|
74
|
+
if (typeof event.data !== "string" || Buffer.byteLength(event.data) > 8192) throw new Error();
|
|
75
|
+
const reply = exactRecord(JSON.parse(event.data), ["id", "result"]);
|
|
76
|
+
if (reply.id !== 1) throw new Error(); finish(undefined, reply.result);
|
|
77
|
+
} catch { abort(); }
|
|
78
|
+
};
|
|
79
|
+
if (budget.signal.aborted) { abort(); return; }
|
|
80
|
+
try { socket.send(JSON.stringify({ id: 1, method: action, params })); } catch { abort(); }
|
|
81
|
+
});
|
|
82
|
+
} finally { socket.terminate(); }
|
|
83
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { MessageBudget } from "./budget";
|
|
3
|
+
import { LocalMessageRpc } from "./rpc";
|
|
4
|
+
import { localDaemonEndpoint } from "./socket";
|
|
5
|
+
import { isRecord, isThreadId } from "./types";
|
|
6
|
+
import { capability, exactRecord, matchesProof, peerProof, RemoteCapacity, REMOTE_LIMITS,
|
|
7
|
+
REMOTE_PROTOCOL, validCapability, type RemotePeer } from "./remote-contract";
|
|
8
|
+
import { admitRemoteRpc } from "./remote-rpc-admission";
|
|
9
|
+
import { receiveRemoteEnvelope } from "./remote-envelope";
|
|
10
|
+
import { RemoteReplyGrants } from "./remote-replies";
|
|
11
|
+
import type { RemoteMessageStore } from "./remote-store";
|
|
12
|
+
|
|
13
|
+
interface Connection {
|
|
14
|
+
stage: "hello" | "proof" | "authorized";
|
|
15
|
+
budget: MessageBudget;
|
|
16
|
+
release: () => void;
|
|
17
|
+
peer?: RemotePeer;
|
|
18
|
+
key?: string;
|
|
19
|
+
from?: string;
|
|
20
|
+
transaction?: string;
|
|
21
|
+
nonce?: string;
|
|
22
|
+
serverNonce?: string;
|
|
23
|
+
ws?: ServerWebSocket<Connection>;
|
|
24
|
+
rpc?: LocalMessageRpc;
|
|
25
|
+
pending: number;
|
|
26
|
+
lastId: number;
|
|
27
|
+
queue: boolean;
|
|
28
|
+
initialized: boolean;
|
|
29
|
+
initializing: boolean;
|
|
30
|
+
closed: boolean;
|
|
31
|
+
authTimer?: ReturnType<typeof setTimeout>;
|
|
32
|
+
loadedIds: Set<string>;
|
|
33
|
+
}
|
|
34
|
+
export interface RemoteBridgeHooks {
|
|
35
|
+
control(peer: RemotePeer | null, method: string, params: unknown, budget: MessageBudget): Promise<unknown>;
|
|
36
|
+
}
|
|
37
|
+
/** A foreground-owned loopback gateway authenticates before touching the existing native daemon. */
|
|
38
|
+
export function startRemoteBridge(store: RemoteMessageStore, codexHome: string, capacity: RemoteCapacity,
|
|
39
|
+
signal: AbortSignal, hooks: RemoteBridgeHooks) {
|
|
40
|
+
const initial = store.requireEnabled();
|
|
41
|
+
const connections = new Set<Connection>();
|
|
42
|
+
const tasks = new Set<Promise<void>>();
|
|
43
|
+
const replies = new RemoteReplyGrants(store);
|
|
44
|
+
let stopping = false;
|
|
45
|
+
let closePromise: Promise<void> | undefined;
|
|
46
|
+
const validConnection = (data: Connection): boolean => {
|
|
47
|
+
const state = store.requireEnabled();
|
|
48
|
+
if (state.generation !== initial.generation) return false;
|
|
49
|
+
if (!data.peer) return data.key === state.controlKey;
|
|
50
|
+
return state.peers.some(peer => peer.machine.id === data.from
|
|
51
|
+
&& peer.transaction === data.transaction && peer.incoming === data.key);
|
|
52
|
+
};
|
|
53
|
+
const retire = (data: Connection) => {
|
|
54
|
+
if (data.closed) return;
|
|
55
|
+
data.closed = true;
|
|
56
|
+
clearTimeout(data.authTimer);
|
|
57
|
+
connections.delete(data);
|
|
58
|
+
data.budget.dispose();
|
|
59
|
+
data.rpc?.close();
|
|
60
|
+
data.ws?.terminate();
|
|
61
|
+
data.release();
|
|
62
|
+
};
|
|
63
|
+
const send = (data: Connection, value: unknown) => {
|
|
64
|
+
const frame = JSON.stringify(value);
|
|
65
|
+
const free = capacity.reserve("outputBytes", Buffer.byteLength(frame));
|
|
66
|
+
try {
|
|
67
|
+
if (!data.ws || data.ws.send(frame) <= 0) retire(data);
|
|
68
|
+
} finally {
|
|
69
|
+
free();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const authenticate = (data: Connection, raw: unknown) => {
|
|
73
|
+
const state = store.requireEnabled();
|
|
74
|
+
if (state.generation !== initial.generation) throw new Error("Messaging owner generation changed.");
|
|
75
|
+
if (data.stage === "hello") {
|
|
76
|
+
const hello = exactRecord(raw, ["type", "protocol", "from", "to", "transaction", "nonce"]);
|
|
77
|
+
if (hello.type !== "hello" || hello.protocol !== REMOTE_PROTOCOL || !isThreadId(hello.from)
|
|
78
|
+
|| hello.to !== state.machine.id || !isThreadId(hello.transaction) || !validCapability(hello.nonce)) {
|
|
79
|
+
throw new Error("Invalid messaging authentication hello.");
|
|
80
|
+
}
|
|
81
|
+
const peer = state.peers.find(item => item.machine.id === hello.from && item.transaction === hello.transaction);
|
|
82
|
+
const isLocalControl = hello.from === state.machine.id && hello.transaction === state.generation;
|
|
83
|
+
const key = isLocalControl ? state.controlKey : peer?.incoming;
|
|
84
|
+
if (!key) throw new Error("Messaging enrollment is unavailable.");
|
|
85
|
+
Object.assign(data, { peer, key, from: hello.from, transaction: hello.transaction, nonce: hello.nonce, serverNonce: capability() });
|
|
86
|
+
data.stage = "proof";
|
|
87
|
+
send(data, { type: "challenge", nonce: data.serverNonce, proof: peerProof(key, "server", data.from!, state.machine.id,
|
|
88
|
+
data.transaction!, data.nonce!, data.serverNonce!) });
|
|
89
|
+
} else {
|
|
90
|
+
const proof = exactRecord(raw, ["type", "proof"]);
|
|
91
|
+
if (proof.type !== "authorize" || !validConnection(data) || !matchesProof(proof.proof,
|
|
92
|
+
peerProof(data.key!, "client", data.from!, state.machine.id, data.transaction!, data.nonce!, data.serverNonce!))) {
|
|
93
|
+
throw new Error("Messaging authentication proof was refused.");
|
|
94
|
+
}
|
|
95
|
+
// Unverified sockets never consume outgoing/control capacity. Promote only after proof verification.
|
|
96
|
+
// Peer sockets share the connections outside the owner's reserved control/probe/renewal slots.
|
|
97
|
+
const release = data.peer ? capacity.reservePeerConnection() : capacity.reserve("connections");
|
|
98
|
+
data.release();
|
|
99
|
+
data.release = release;
|
|
100
|
+
data.stage = "authorized";
|
|
101
|
+
clearTimeout(data.authTimer);
|
|
102
|
+
send(data, { type: "authorized", protocol: REMOTE_PROTOCOL });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const execute = async (data: Connection, frame: Record<string, unknown>) => {
|
|
106
|
+
const id = frame.id;
|
|
107
|
+
const method = frame.method as string;
|
|
108
|
+
data.budget.throwIfEnded();
|
|
109
|
+
if (!validConnection(data)) throw new Error("Messaging enrollment changed.");
|
|
110
|
+
if (method.startsWith("message/")) {
|
|
111
|
+
if (data.rpc || data.pending !== 1) throw new Error("Control and native RPC cannot share a connection.");
|
|
112
|
+
const result = method === "message/reply-route" && !data.peer
|
|
113
|
+
? replies.control(frame.params)
|
|
114
|
+
: await hooks.control(data.peer ?? null, method, frame.params, data.budget);
|
|
115
|
+
if (!data.closed && validConnection(data)) send(data, { id, result });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const params = admitRemoteRpc(method, frame.params);
|
|
119
|
+
let result: unknown;
|
|
120
|
+
if (method === "initialize") {
|
|
121
|
+
if (data.rpc || data.initializing || data.initialized) throw new Error("Messaging connection was already initialized.");
|
|
122
|
+
data.initializing = true;
|
|
123
|
+
data.rpc = await LocalMessageRpc.connect(localDaemonEndpoint(codexHome).url, data.budget);
|
|
124
|
+
data.initializing = false;
|
|
125
|
+
result = { userAgent: REMOTE_PROTOCOL };
|
|
126
|
+
} else if (method === "initialized") {
|
|
127
|
+
if (id !== undefined || !data.rpc || data.initialized) throw new Error("Invalid messaging initialization acknowledgement.");
|
|
128
|
+
data.initialized = true;
|
|
129
|
+
return;
|
|
130
|
+
} else {
|
|
131
|
+
if (!data.rpc || !data.initialized) throw new Error("Initialize the messaging connection before native RPC.");
|
|
132
|
+
if (method === "thread/loaded/list") {
|
|
133
|
+
const page = await data.rpc.loadedPage(params.cursor as string | undefined);
|
|
134
|
+
const visible = page.data.filter(id => !data.peer || replies.visible(data.peer, id));
|
|
135
|
+
for (const id of visible) data.loadedIds.add(id);
|
|
136
|
+
if (data.loadedIds.size > 1000) throw new Error("Loaded session limit exceeded.");
|
|
137
|
+
result = { ...page, data: visible };
|
|
138
|
+
}
|
|
139
|
+
else if (method === "thread/read") {
|
|
140
|
+
if (!data.loadedIds.has(params.threadId as string)
|
|
141
|
+
|| (data.peer && !replies.visible(data.peer, params.threadId as string))) {
|
|
142
|
+
throw new Error("Session is outside this peer's reply routes.");
|
|
143
|
+
}
|
|
144
|
+
const thread = await data.rpc.readThread(params.threadId as string);
|
|
145
|
+
if (data.peer && !replies.visible(data.peer, thread.id)) throw new Error("Session permission changed during lookup.");
|
|
146
|
+
result = { thread: { id: thread.id, name: thread.name, status: { type: thread.status } } };
|
|
147
|
+
} else {
|
|
148
|
+
if (data.queue || !data.peer) throw new Error("Only one peer message may be queued per connection.");
|
|
149
|
+
data.queue = true;
|
|
150
|
+
const envelope = receiveRemoteEnvelope((params.input as { text: string }[])[0]!.text,
|
|
151
|
+
params.clientUserMessageId as string, data.peer);
|
|
152
|
+
if (!data.loadedIds.has(params.threadId as string)) {
|
|
153
|
+
send(data, { id, error: { code: -32000, message: "Recipient was not discovered loaded on this connection." } });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const fresh = await data.rpc.readThread(params.threadId as string);
|
|
157
|
+
if (fresh.status === "notLoaded") {
|
|
158
|
+
send(data, { id, error: { code: -32000, message: "Recipient unloaded before queue submission." } });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!validConnection(data)) throw new Error("Messaging enrollment changed before submission.");
|
|
162
|
+
data.budget.throwIfEnded();
|
|
163
|
+
const settleReply = replies.begin(data.peer, envelope.header, params.threadId as string);
|
|
164
|
+
let submissionId: string | undefined;
|
|
165
|
+
// An exception or uncertain receipt must spend the one-shot route, not authorize a replay.
|
|
166
|
+
let submitted = true;
|
|
167
|
+
let receipt;
|
|
168
|
+
try {
|
|
169
|
+
receipt = await data.rpc.queueMessage(params.threadId as string,
|
|
170
|
+
envelope.text, params.clientUserMessageId as string, value => { submissionId = value; });
|
|
171
|
+
submitted = receipt.status !== "not_sent";
|
|
172
|
+
} finally {
|
|
173
|
+
settleReply(submitted);
|
|
174
|
+
}
|
|
175
|
+
if (receipt.status === "unknown") {
|
|
176
|
+
retire(data);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (receipt.status === "not_sent") {
|
|
180
|
+
send(data, { id, error: { code: receipt.error?.code === "unsupported_queue" ? -32601 : -32000,
|
|
181
|
+
message: "Messaging queue request was rejected." } });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
result = { queuedSubmission: { id: submissionId, input: params.input, clientUserMessageId: params.clientUserMessageId } };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!data.closed && validConnection(data)) {
|
|
188
|
+
send(data, { id, result });
|
|
189
|
+
} else {
|
|
190
|
+
retire(data);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const server = Bun.serve<Connection>({ hostname: "127.0.0.1", port: initial.port, development: false,
|
|
194
|
+
maxRequestBodySize: 4096, idleTimeout: 30,
|
|
195
|
+
fetch(req, runtime) {
|
|
196
|
+
try {
|
|
197
|
+
const url = new URL(req.url);
|
|
198
|
+
if (stopping || signal.aborted || !store.requireEnabled() || url.pathname !== "/message" || url.search
|
|
199
|
+
|| req.method !== "GET" || req.headers.has("origin") || req.headers.has("authorization")) return new Response(null, { status: 403 });
|
|
200
|
+
const unauthenticated = [...connections].filter(data => data.stage !== "authorized");
|
|
201
|
+
// Reclaim the oldest idle admission instead of letting four silent clients block all fresh proofs.
|
|
202
|
+
if (unauthenticated.length >= REMOTE_LIMITS.unauthenticated) retire(unauthenticated[0]!);
|
|
203
|
+
const release = () => {};
|
|
204
|
+
const budget = new MessageBudget(30000, signal);
|
|
205
|
+
const data: Connection = { stage: "hello", budget, release, pending: 0, lastId: 0, queue: false,
|
|
206
|
+
initialized: false, initializing: false, closed: false, loadedIds: new Set() };
|
|
207
|
+
connections.add(data);
|
|
208
|
+
budget.signal.addEventListener("abort", () => retire(data), { once: true });
|
|
209
|
+
data.authTimer = setTimeout(() => retire(data), REMOTE_LIMITS.authMs);
|
|
210
|
+
if (runtime.upgrade(req, { data })) return;
|
|
211
|
+
retire(data);
|
|
212
|
+
return new Response(null, { status: 400 });
|
|
213
|
+
} catch { return new Response(null, { status: 503 }); }
|
|
214
|
+
},
|
|
215
|
+
websocket: { maxPayloadLength: REMOTE_LIMITS.frameBytes, backpressureLimit: 1,
|
|
216
|
+
closeOnBackpressureLimit: true,
|
|
217
|
+
open(ws) { ws.data.ws = ws; },
|
|
218
|
+
close(ws) { retire(ws.data); },
|
|
219
|
+
message(ws, message) {
|
|
220
|
+
const data = ws.data;
|
|
221
|
+
try {
|
|
222
|
+
if (stopping || data.closed || typeof message !== "string"
|
|
223
|
+
|| Buffer.byteLength(message) > (data.stage === "authorized" ? REMOTE_LIMITS.frameBytes : 4096)) {
|
|
224
|
+
throw new Error("Messaging connection is closed or its frame is invalid.");
|
|
225
|
+
}
|
|
226
|
+
const raw: unknown = JSON.parse(message);
|
|
227
|
+
if (data.stage !== "authorized") {
|
|
228
|
+
authenticate(data, raw);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!isRecord(raw) || typeof raw.method !== "string" || Object.keys(raw).some(key => !["id", "method", "params"].includes(key))) {
|
|
232
|
+
throw new Error("Invalid messaging RPC frame.");
|
|
233
|
+
}
|
|
234
|
+
if (raw.method !== "initialized") {
|
|
235
|
+
if (!Number.isSafeInteger(raw.id) || Number(raw.id) <= data.lastId) throw new Error("Messaging RPC IDs must strictly increase.");
|
|
236
|
+
data.lastId = Number(raw.id);
|
|
237
|
+
} else if (raw.id !== undefined) throw new Error("The initialized notification must not have a request ID.");
|
|
238
|
+
if (data.pending >= 4) throw new Error("Messaging RPC concurrency limit exceeded.");
|
|
239
|
+
const free = capacity.reserve("requests");
|
|
240
|
+
let freeBytes: () => void;
|
|
241
|
+
try {
|
|
242
|
+
freeBytes = capacity.reserve("outputBytes", Buffer.byteLength(message));
|
|
243
|
+
} catch (error) {
|
|
244
|
+
free();
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
data.pending++;
|
|
248
|
+
const task = execute(data, raw).catch(() => retire(data)).finally(() => {
|
|
249
|
+
data.pending--;
|
|
250
|
+
free();
|
|
251
|
+
freeBytes();
|
|
252
|
+
tasks.delete(task);
|
|
253
|
+
});
|
|
254
|
+
tasks.add(task);
|
|
255
|
+
} catch { retire(data); }
|
|
256
|
+
},
|
|
257
|
+
}, error() { return new Response(null, { status: 503 }); },
|
|
258
|
+
});
|
|
259
|
+
const close = (): Promise<void> => closePromise ??= (async () => {
|
|
260
|
+
stopping = true;
|
|
261
|
+
signal.removeEventListener("abort", abort);
|
|
262
|
+
for (const data of [...connections]) retire(data);
|
|
263
|
+
await server.stop(true);
|
|
264
|
+
await Promise.allSettled([...tasks]);
|
|
265
|
+
})();
|
|
266
|
+
const abort = () => { void close(); };
|
|
267
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
268
|
+
if (signal.aborted) abort();
|
|
269
|
+
return { port: server.port!, close };
|
|
270
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isRecord, isThreadId, LocalMessagingError } from "./types";
|
|
3
|
+
|
|
4
|
+
export const REMOTE_PROTOCOL = "ocx-message-remote/1";
|
|
5
|
+
export const REMOTE_LIMITS = Object.freeze({ peers: 4, connections: 16, requests: 32, helpers: 10,
|
|
6
|
+
unauthenticated: 4,
|
|
7
|
+
// Authenticated peer sockets together may never use the slots kept for this node's own
|
|
8
|
+
// control, outgoing probes and lease renewal.
|
|
9
|
+
ownerReservedConnections: 4,
|
|
10
|
+
persistentHelpers: 8, transientHelpers: 2,
|
|
11
|
+
outputBytes: 2 * 1024 * 1024, frameBytes: 1024 * 1024, storeBytes: 128 * 1024, authMs: 5000,
|
|
12
|
+
leaseMs: 25000, shutdownMs: 3000 });
|
|
13
|
+
export interface MessageMachine { id: string; name: string }
|
|
14
|
+
export interface RemotePeer {
|
|
15
|
+
alias: string; machine: MessageMachine; transaction: string; incoming: string; outgoing: string;
|
|
16
|
+
port: number; ssh: string | null; hostKey: string | null; fingerprint: string | null;
|
|
17
|
+
allowInbound?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface RemoteState {
|
|
20
|
+
protocol: typeof REMOTE_PROTOCOL; machine: MessageMachine; enabled: boolean; port: number;
|
|
21
|
+
controlKey: string; generation: string; peers: RemotePeer[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fail closed with authored diagnostics rather than untrusted transport/configuration output. */
|
|
25
|
+
export function remoteError(code: string, message: string): LocalMessagingError {
|
|
26
|
+
return new LocalMessagingError(code, message);
|
|
27
|
+
}
|
|
28
|
+
/** Accept an exact bounded object schema, including rejection of unknown fields. */
|
|
29
|
+
export function exactRecord(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
30
|
+
if (!isRecord(value) || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) {
|
|
31
|
+
throw remoteError("invalid_remote_contract", "Messaging peer returned an invalid protocol object.");
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/** Validate the stable machine identity; names are display metadata, never route authority. */
|
|
36
|
+
export function validMachine(value: unknown): value is MessageMachine {
|
|
37
|
+
return isRecord(value) && Object.keys(value).length === 2 && isThreadId(value.id)
|
|
38
|
+
&& typeof value.name === "string" && value.name.length > 0 && value.name.length <= 128
|
|
39
|
+
&& !/[\x00-\x1f\x7f-\x9f]/.test(value.name);
|
|
40
|
+
}
|
|
41
|
+
/** Permit exact CLI aliases, not names containing shell syntax or fuzzy selectors. */
|
|
42
|
+
export function validAlias(value: unknown): value is string {
|
|
43
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value);
|
|
44
|
+
}
|
|
45
|
+
/** Messaging listeners and both forward directions use unprivileged ports only. */
|
|
46
|
+
export function validPort(value: unknown): value is number {
|
|
47
|
+
return Number.isInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
|
|
48
|
+
}
|
|
49
|
+
/** Generate a receiver-issued 256-bit capability; never print it in ordinary CLI output. */
|
|
50
|
+
export function capability(): string { return randomBytes(32).toString("hex"); }
|
|
51
|
+
/** Validate protocol capability/nonces before any keyed proof calculation. */
|
|
52
|
+
export function validCapability(value: unknown): value is string {
|
|
53
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
54
|
+
}
|
|
55
|
+
/** Direction-separated proofs bind both identities, enrollment and fresh nonces on one socket. */
|
|
56
|
+
export function peerProof(key: string, direction: "server" | "client", from: string, to: string,
|
|
57
|
+
transaction: string, clientNonce: string, serverNonce: string): string {
|
|
58
|
+
return createHmac("sha256", key).update(JSON.stringify([
|
|
59
|
+
REMOTE_PROTOCOL, direction, from, to, transaction, clientNonce, serverNonce,
|
|
60
|
+
])).digest("hex");
|
|
61
|
+
}
|
|
62
|
+
/** Fixed-size comparison refuses malformed proofs rather than comparing secret strings. */
|
|
63
|
+
export function matchesProof(actual: unknown, expected: string): boolean {
|
|
64
|
+
return validCapability(actual) && timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(expected, "hex"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One foreground owner's reservations are shared across peers and both tunnel directions. */
|
|
68
|
+
export class RemoteCapacity {
|
|
69
|
+
private used = { connections: 0, requests: 0, helpers: 0, outputBytes: 0 };
|
|
70
|
+
private helperSlots = { persistent: 0, transient: 0 };
|
|
71
|
+
private peerTotal = 0;
|
|
72
|
+
/** Enrolled peers share the connections outside the owner's reserved control/probe/renewal slots. */
|
|
73
|
+
reservePeerConnection(): () => void {
|
|
74
|
+
if (this.peerTotal + 1 > REMOTE_LIMITS.connections - REMOTE_LIMITS.ownerReservedConnections) {
|
|
75
|
+
throw remoteError("remote_capacity", "The enrolled peers' connection share was reached.");
|
|
76
|
+
}
|
|
77
|
+
const free = this.reserve("connections");
|
|
78
|
+
this.peerTotal++;
|
|
79
|
+
let released = false;
|
|
80
|
+
return () => {
|
|
81
|
+
if (released) return;
|
|
82
|
+
released = true;
|
|
83
|
+
this.peerTotal--;
|
|
84
|
+
free();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Keep transient inspectors/control commands available alongside all eight tunnel children. */
|
|
88
|
+
reserveHelper(purpose: "persistent" | "transient"): () => void {
|
|
89
|
+
const limit = purpose === "persistent" ? REMOTE_LIMITS.persistentHelpers : REMOTE_LIMITS.transientHelpers;
|
|
90
|
+
if (this.helperSlots[purpose] >= limit) {
|
|
91
|
+
throw remoteError("remote_capacity", "The messaging owner's aggregate helper purpose limit was reached.");
|
|
92
|
+
}
|
|
93
|
+
const free = this.reserve("helpers"); this.helperSlots[purpose]++;
|
|
94
|
+
let released = false;
|
|
95
|
+
return () => { if (!released) { released = true; this.helperSlots[purpose]--; free(); } };
|
|
96
|
+
}
|
|
97
|
+
/** Reserve before allocating/spawning; overload creates no hidden waiting queue. */
|
|
98
|
+
reserve(kind: keyof RemoteCapacity["used"], amount = 1): () => void {
|
|
99
|
+
if (!Number.isSafeInteger(amount) || amount < 1 || this.used[kind] + amount > REMOTE_LIMITS[kind]) {
|
|
100
|
+
throw remoteError("remote_capacity", "The messaging owner's aggregate resource limit was reached.");
|
|
101
|
+
}
|
|
102
|
+
this.used[kind] += amount;
|
|
103
|
+
let released = false;
|
|
104
|
+
return () => { if (!released) { released = true; this.used[kind] -= amount; } };
|
|
105
|
+
}
|
|
106
|
+
/** Safe counters for tests/status: no identities, capabilities or message bodies. */
|
|
107
|
+
snapshot() { return { ...this.used }; }
|
|
108
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isSshAlias } from "../link/ssh-argv";
|
|
3
|
+
import { exactRecord, remoteError, REMOTE_PROTOCOL, validAlias, validCapability, validMachine, validPort, type MessageMachine } from "./remote-contract";
|
|
4
|
+
import { readRemoteFile, removeRemoteFile, withRemoteLock } from "./remote-files";
|
|
5
|
+
import type { RemoteMessageStore } from "./remote-store";
|
|
6
|
+
import { isThreadId } from "./types";
|
|
7
|
+
|
|
8
|
+
interface EnrollmentJournal {
|
|
9
|
+
alias: string; ssh: string; fingerprint: string; generation: string;
|
|
10
|
+
request: { protocol: typeof REMOTE_PROTOCOL; action: "enroll";
|
|
11
|
+
params: { machine: MessageMachine; transaction: string; returnCapability: string; port: number } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Interpret only safely read local journal text; storage/lock failures keep their own classification. */
|
|
15
|
+
export function parseEnrollmentJournal(text: string): EnrollmentJournal {
|
|
16
|
+
try {
|
|
17
|
+
const saved = exactRecord(JSON.parse(text), ["alias", "ssh", "fingerprint", "generation", "request"]);
|
|
18
|
+
const request = exactRecord(saved.request, ["protocol", "action", "params"]);
|
|
19
|
+
const params = exactRecord(request.params, ["machine", "transaction", "returnCapability", "port"]);
|
|
20
|
+
if (!validAlias(saved.alias) || typeof saved.ssh !== "string" || !isSshAlias(saved.ssh)
|
|
21
|
+
|| typeof saved.fingerprint !== "string" || !/^SHA256:[A-Za-z0-9+/=]{1,64}$/.test(saved.fingerprint)
|
|
22
|
+
|| !isThreadId(saved.generation) || request.protocol !== REMOTE_PROTOCOL || request.action !== "enroll"
|
|
23
|
+
|| !validMachine(params.machine) || !isThreadId(params.transaction) || !validCapability(params.returnCapability)
|
|
24
|
+
|| !validPort(params.port)) throw new Error();
|
|
25
|
+
// Preserve the parsed record's property order for exact saved-transaction ownership checks.
|
|
26
|
+
return saved as unknown as EnrollmentJournal;
|
|
27
|
+
} catch { throw remoteError("invalid_enrollment_journal", "Pending enrollment cannot be safely interpreted; explicit operator recovery is required."); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Validate a recovery journal before exposing metadata or authorizing its exact removal. */
|
|
31
|
+
function readPending(store: RemoteMessageStore) {
|
|
32
|
+
const text = readRemoteFile(join(store.directory, "enrollment.json"));
|
|
33
|
+
if (text === null) return null;
|
|
34
|
+
const saved = parseEnrollmentJournal(text);
|
|
35
|
+
return { alias: saved.alias, generation: saved.generation,
|
|
36
|
+
transaction: saved.request.params.transaction, machineId: saved.request.params.machine.id };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read only bounded non-secret recovery metadata; absent storage remains absent. */
|
|
40
|
+
export function pendingRemoteEnrollment(store: RemoteMessageStore) {
|
|
41
|
+
const pending = readPending(store);
|
|
42
|
+
return pending ? { alias: pending.alias, transaction: pending.transaction,
|
|
43
|
+
stale: pending.generation !== store.read()?.generation } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Abandon one explicitly selected local transaction, never retrying it or claiming remote cleanup. */
|
|
47
|
+
export function abandonRemoteEnrollment(store: RemoteMessageStore, transaction: string) {
|
|
48
|
+
if (!isThreadId(transaction)) throw remoteError("invalid_enrollment", "An exact pending transaction UUID is required.");
|
|
49
|
+
// Recovery remains available while disabled, but never creates a missing store or lock.
|
|
50
|
+
if (!readPending(store)) throw remoteError("enrollment_pending", "No pending enrollment exists to abandon.");
|
|
51
|
+
return withRemoteLock(store.directory, () => {
|
|
52
|
+
const pending = readPending(store);
|
|
53
|
+
if (!pending || pending.transaction !== transaction) throw remoteError("enrollment_pending", "Pending enrollment changed; inspect it before abandoning the exact transaction.");
|
|
54
|
+
removeRemoteFile(join(store.directory, "enrollment.json"));
|
|
55
|
+
return { protocol: REMOTE_PROTOCOL, transaction, locallyAbandoned: true, remote: "unconfirmed",
|
|
56
|
+
remoteCleanupCommand: `ocx message hosts remove ${pending.machineId} --transaction ${transaction} --json`,
|
|
57
|
+
message: "Only the local recovery journal was removed. Existing peers are unchanged; the remote node may retain an enrollment requiring explicit cleanup." };
|
|
58
|
+
});
|
|
59
|
+
}
|