@bitkyc08/opencodex 2.65.0-preview.20260925 → 2.66.0-preview.20260925
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/gui/dist/assets/App-BVjA0T6O.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-DUvc_Wul.js → Tray-BCFtiWwD.js} +1 -1
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/index-vO4dgfiU.js +86 -0
- package/gui/dist/assets/{tray-data-f0lTZ4sF.js → tray-data-C4FiNaxk.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-CsYvvpr3.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-BB0iHG8-.js +0 -86
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv } from "../../link/ssh-argv";
|
|
6
|
+
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
7
|
+
import { awaitFirstAdmission } from "../../link/admission-wait";
|
|
8
|
+
import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
|
|
9
|
+
import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation";
|
|
10
|
+
import { loadHostCandidates } from "../../link/ssh-config";
|
|
11
|
+
import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store";
|
|
12
|
+
import { createSshRunner, type SshRunner } from "../../link/ssh-runner";
|
|
13
|
+
import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
14
|
+
import { isLinkPort } from "../../link/ports";
|
|
15
|
+
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
16
|
+
import { clientLinkTunnelStatus } from "../../client/link-tunnel";
|
|
17
|
+
import type { ManagementContext } from "./context";
|
|
18
|
+
import { readManagementJsonBodyOr } from "./body";
|
|
19
|
+
import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
|
|
20
|
+
import { acceptSystemRestart } from "./system-restart";
|
|
21
|
+
|
|
22
|
+
const PROBE_TTL_MS = 5 * 60_000;
|
|
23
|
+
const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
|
|
24
|
+
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
25
|
+
const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
|
|
26
|
+
|
|
27
|
+
export interface PendingLinkHost {
|
|
28
|
+
alias: string;
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
keyType: string;
|
|
31
|
+
knownHostLine: string;
|
|
32
|
+
probedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConfirmedLinkHost extends PendingLinkHost {
|
|
36
|
+
ocxVersion: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LinkRouteListener {
|
|
40
|
+
ensureStarted(): Promise<void>;
|
|
41
|
+
status(): { state: "off" | "listening" | "failed"; port: number | null; reason: string | null };
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
onAuthenticatedCatalog(listener: (apiKeyId: string) => void): () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface LinkRouteState {
|
|
47
|
+
pendingHosts: Map<string, PendingLinkHost>;
|
|
48
|
+
confirmedHosts?: Map<string, ConfirmedLinkHost>;
|
|
49
|
+
compensationFailures?: Map<string, { since: string; reason: "compensation_failed" }>;
|
|
50
|
+
supervisor: import("../../link/supervisor").LinkSupervisor;
|
|
51
|
+
listener: LinkRouteListener;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const states = new WeakMap<object, LinkRouteState>();
|
|
55
|
+
// One process runs at most one join: a join ends by restarting this process as a client.
|
|
56
|
+
let joinInProgress = false;
|
|
57
|
+
|
|
58
|
+
function fail(code: string, message: string, status: number): Response {
|
|
59
|
+
return Response.json({ error: { code, message } }, { status, headers: { "cache-control": "no-store" } });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function exactBody(body: unknown, keys: readonly string[]): Record<string, unknown> | null {
|
|
67
|
+
if (!isRecord(body)) return null;
|
|
68
|
+
const actual = Object.keys(body).sort();
|
|
69
|
+
const expected = [...keys].sort();
|
|
70
|
+
return actual.length === expected.length && actual.every((key, index) => key === expected[index]) ? body : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function port(value: unknown): value is number {
|
|
74
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dashboardSession(ctx: ManagementContext): boolean {
|
|
78
|
+
return ctx.principal === "gui-session"
|
|
79
|
+
&& ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function adminLoopback(ctx: ManagementContext): boolean {
|
|
83
|
+
return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null {
|
|
87
|
+
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
88
|
+
const dashboard = dashboardSession(ctx);
|
|
89
|
+
const admin = adminLoopback(ctx);
|
|
90
|
+
if ((kind === "dashboard" && !dashboard) || (kind === "admin" && !admin) || (kind === "either" && !dashboard && !admin)) {
|
|
91
|
+
return fail("forbidden", "The required link authorization was not present.", 403);
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runnerFor(ctx: ManagementContext): SshRunner {
|
|
97
|
+
return ctx.deps.sshRunner ?? createSshRunner();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readStoreFor(ctx: ManagementContext): LinkStore {
|
|
101
|
+
return ctx.deps.readLinkStore?.() ?? readLinkStore(linkStorePath());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeStoreFor(ctx: ManagementContext, store: LinkStore): void {
|
|
105
|
+
(ctx.deps.writeLinkStore ?? (value => writeLinkStore(linkStorePath(), value)))(store);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stateFor(ctx: ManagementContext): LinkRouteState | null {
|
|
109
|
+
const supervisor = ctx.deps.linkSupervisor?.();
|
|
110
|
+
const listener = ctx.deps.linkListener?.();
|
|
111
|
+
if (!supervisor || !listener) return null;
|
|
112
|
+
const key = ctx.config as object;
|
|
113
|
+
const existing = states.get(key);
|
|
114
|
+
if (existing) return existing;
|
|
115
|
+
const state: LinkRouteState = {
|
|
116
|
+
pendingHosts: new Map(),
|
|
117
|
+
confirmedHosts: new Map(),
|
|
118
|
+
compensationFailures: new Map(),
|
|
119
|
+
supervisor,
|
|
120
|
+
listener,
|
|
121
|
+
};
|
|
122
|
+
states.set(key, state);
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function knownHostsFile(ctx: ManagementContext): string {
|
|
127
|
+
return ctx.deps.linkKnownHostsPath?.() ?? linkKnownHostsPath();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function putKnownHost(path: string, alias: string, line: string): string {
|
|
131
|
+
const before = (() => { try { return readFileSync(path, "utf8"); } catch { return ""; } })();
|
|
132
|
+
const lines = before.split(/\r?\n/).filter(value => value && !value.startsWith(`${alias} `) && !value.startsWith(`${alias},`));
|
|
133
|
+
lines.push(line.trim());
|
|
134
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
135
|
+
writeFileSync(path, `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
136
|
+
try { chmodSync(path, 0o600); } catch { /* best effort on Windows */ }
|
|
137
|
+
return before;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function restoreKnownHost(path: string, before: string): void {
|
|
141
|
+
if (!before) {
|
|
142
|
+
try { rmSync(path, { force: true }); } catch { /* best effort compensation */ }
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
writeFileSync(path, before, { mode: 0o600 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function issueKey(ctx: ManagementContext, name: string): IssuedApiKey {
|
|
149
|
+
return (ctx.deps.issueApiKey ?? issueApiKeyInProcess)(ctx.config, name);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function revokeKey(ctx: ManagementContext, id: string): boolean {
|
|
153
|
+
return (ctx.deps.revokeApiKey ?? revokeApiKeyInProcess)(ctx.config, id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Revocation that a retry can repeat. A key that is no longer in the live config is already
|
|
158
|
+
* revoked, which is exactly the state a partially completed cleanup leaves behind; without this
|
|
159
|
+
* a retried DELETE would fail forever on the key it removed the first time.
|
|
160
|
+
*/
|
|
161
|
+
function revokeKeyIdempotent(ctx: ManagementContext, id: string): boolean {
|
|
162
|
+
let revoked = false;
|
|
163
|
+
try { revoked = revokeKey(ctx, id); } catch { revoked = false; }
|
|
164
|
+
if (revoked) return true;
|
|
165
|
+
return !(ctx.config.apiKeys ?? []).some(entry => entry.id === id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compensationFailure(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number], phase: string): Response {
|
|
169
|
+
const since = new Date((ctx.deps.now ?? Date.now)()).toISOString();
|
|
170
|
+
(state.compensationFailures ??= new Map()).set(record.id, { since, reason: "compensation_failed" });
|
|
171
|
+
try { markCompensationFailed(record.id, since, compensationPath()); } catch (error) {
|
|
172
|
+
console.warn(`[link] compensation marker persistence failed linkId=${record.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
173
|
+
}
|
|
174
|
+
console.warn(`[link] compensation_failed linkId=${record.id} phase=${phase}`);
|
|
175
|
+
return fail("compensation_failed", "The link compensation could not be completed.", 500);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function compensateNewLink(ctx: ManagementContext, state: LinkRouteState, record: LinkStore["links"][number]): Promise<Response | null> {
|
|
179
|
+
const revoked = revokeKeyIdempotent(ctx, record.apiKeyId);
|
|
180
|
+
if (!revoked) {
|
|
181
|
+
try { await state.supervisor.stopLink(record.id); } catch { /* retain the record and failure marker */ }
|
|
182
|
+
try {
|
|
183
|
+
const current = readStoreFor(ctx);
|
|
184
|
+
if (!current.links.some(link => link.id === record.id)) {
|
|
185
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// The in-memory marker still makes the residual visible when persistence is unavailable.
|
|
189
|
+
}
|
|
190
|
+
return compensationFailure(ctx, state, record, "revoke");
|
|
191
|
+
}
|
|
192
|
+
try { await state.supervisor.stopLink(record.id); } catch { return compensationFailure(ctx, state, record, "stop"); }
|
|
193
|
+
try {
|
|
194
|
+
const current = readStoreFor(ctx);
|
|
195
|
+
const next = { ...current, links: current.links.filter(link => link.id !== record.id) };
|
|
196
|
+
if (next.links.length !== current.links.length) {
|
|
197
|
+
writeStoreFor(ctx, next);
|
|
198
|
+
}
|
|
199
|
+
clearCompensationFailed(record.id, compensationPath());
|
|
200
|
+
state.compensationFailures?.delete(record.id);
|
|
201
|
+
if (next.links.length === 0) await state.listener.close();
|
|
202
|
+
return null;
|
|
203
|
+
} catch {
|
|
204
|
+
return compensationFailure(ctx, state, record, "store");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function candidates(ctx: ManagementContext): Promise<Response> {
|
|
209
|
+
const values = ctx.deps.loadLinkCandidates?.() ?? loadHostCandidates();
|
|
210
|
+
return Response.json({ candidates: values.map(value => ({ alias: value.alias, source: value.source })) });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
214
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
215
|
+
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
216
|
+
try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
217
|
+
const dir = mkdtempSync(join(tmpdir(), "ocx-link-probe-"));
|
|
218
|
+
const tempKnownHosts = join(dir, "known_hosts");
|
|
219
|
+
writeFileSync(tempKnownHosts, "", { mode: 0o600 });
|
|
220
|
+
const runner = runnerFor(ctx);
|
|
221
|
+
try {
|
|
222
|
+
const result = await runner.run(buildProbeArgv({ alias: body.alias, tempKnownHostsFile: tempKnownHosts }), { timeoutMs: 30_000 });
|
|
223
|
+
if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502);
|
|
224
|
+
const fingerprintResult = await runner.run(buildFingerprintArgv(tempKnownHosts), { timeoutMs: 10_000 });
|
|
225
|
+
if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502);
|
|
226
|
+
const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
|
|
227
|
+
const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
228
|
+
if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
|
|
229
|
+
state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
|
|
230
|
+
return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
|
|
231
|
+
} catch {
|
|
232
|
+
return fail("probe_failed", "SSH host probing failed.", 502);
|
|
233
|
+
} finally {
|
|
234
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
239
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "fingerprint"]);
|
|
240
|
+
if (!body || typeof body.alias !== "string" || typeof body.fingerprint !== "string") return fail("invalid_body", "alias and fingerprint are required.", 400);
|
|
241
|
+
const pending = state.pendingHosts.get(body.alias);
|
|
242
|
+
const now = (ctx.deps.now ?? Date.now)();
|
|
243
|
+
if (!pending || now - pending.probedAt > PROBE_TTL_MS) return fail("host_confirmation_expired", "The SSH host probe has expired.", 409);
|
|
244
|
+
if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
|
|
245
|
+
const path = knownHostsFile(ctx);
|
|
246
|
+
const before = putKnownHost(path, pending.alias, pending.knownHostLine);
|
|
247
|
+
try {
|
|
248
|
+
const result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: ["ocx", "--version"], knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
249
|
+
if (result.code !== 0 || !result.stdout.trim()) throw new Error("version probe failed");
|
|
250
|
+
const confirmed = { ...pending, ocxVersion: result.stdout.trim().split(/\r?\n/)[0]! };
|
|
251
|
+
state.confirmedHosts ??= new Map();
|
|
252
|
+
state.confirmedHosts.set(pending.alias, confirmed);
|
|
253
|
+
state.pendingHosts.delete(pending.alias);
|
|
254
|
+
return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
|
|
255
|
+
} catch {
|
|
256
|
+
restoreKnownHost(path, before);
|
|
257
|
+
return fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parsePortOutput(stdout: string): number | null {
|
|
262
|
+
try {
|
|
263
|
+
const value: unknown = JSON.parse(stdout.trim());
|
|
264
|
+
if (!isRecord(value) || Object.keys(value).length !== 1 || !port(value.port)) return null;
|
|
265
|
+
return value.port;
|
|
266
|
+
} catch { return null; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
270
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
271
|
+
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
272
|
+
const confirmed = state.confirmedHosts?.get(body.alias);
|
|
273
|
+
if (!confirmed) return fail("host_not_confirmed", "Confirm the SSH host before applying the link.", 409);
|
|
274
|
+
const knownHosts = knownHostsFile(ctx);
|
|
275
|
+
const runner = runnerFor(ctx);
|
|
276
|
+
let remotePort: number;
|
|
277
|
+
try {
|
|
278
|
+
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["ocx", "link", "port"], knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
|
|
279
|
+
remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
|
|
280
|
+
} catch { remotePort = 0; }
|
|
281
|
+
if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502);
|
|
282
|
+
let issued: IssuedApiKey;
|
|
283
|
+
try { issued = issueKey(ctx, `link:${body.alias}`); }
|
|
284
|
+
catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
|
|
285
|
+
const id = newLinkId();
|
|
286
|
+
const record = { id, alias: body.alias, direction: "hub-initiated" as const, hostKeyFingerprint: confirmed.fingerprint, tunnelPort: remotePort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
287
|
+
let store: LinkStore;
|
|
288
|
+
try {
|
|
289
|
+
const current = readStoreFor(ctx);
|
|
290
|
+
if (current.links.some(link => link.alias === body.alias && link.direction === "hub-initiated")) {
|
|
291
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
292
|
+
if (compensation) return compensation;
|
|
293
|
+
return fail("link_exists", "A link for this alias already exists.", 409);
|
|
294
|
+
}
|
|
295
|
+
store = { ...current, links: [...current.links, record] };
|
|
296
|
+
writeStoreFor(ctx, store);
|
|
297
|
+
await state.listener.ensureStarted();
|
|
298
|
+
if (state.listener.status().state !== "listening") {
|
|
299
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
300
|
+
return compensation ?? fail("listener_unavailable", "The link listener is unavailable.", 503);
|
|
301
|
+
}
|
|
302
|
+
await state.supervisor.ensureStarted();
|
|
303
|
+
await state.supervisor.reload();
|
|
304
|
+
} catch {
|
|
305
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
306
|
+
if (compensation) return compensation;
|
|
307
|
+
return fail("link_apply_failed", "The link could not be started.", 503);
|
|
308
|
+
}
|
|
309
|
+
const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
|
|
310
|
+
const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
|
|
311
|
+
try {
|
|
312
|
+
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["ocx", "connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id], knownHostsFile: knownHosts }), { stdin: input, timeoutMs: APPLY_ADMISSION_TIMEOUT_MS });
|
|
313
|
+
if (result.code !== 0) {
|
|
314
|
+
void admission.catch(() => {});
|
|
315
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
316
|
+
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502);
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await admission;
|
|
320
|
+
} catch {
|
|
321
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
322
|
+
return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
|
|
323
|
+
}
|
|
324
|
+
return Response.json({ linkId: id }, { status: 202 });
|
|
325
|
+
} catch {
|
|
326
|
+
void admission.catch(() => {});
|
|
327
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
328
|
+
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502);
|
|
329
|
+
} finally {
|
|
330
|
+
input.fill(0);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function issue(ctx: ManagementContext): Promise<Response> {
|
|
335
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias", "tunnelPort"]);
|
|
336
|
+
if (!body || typeof body.alias !== "string" || !isLinkPort(body.tunnelPort)) return fail("invalid_body", "alias and tunnelPort are required.", 400);
|
|
337
|
+
try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
338
|
+
let issued: IssuedApiKey;
|
|
339
|
+
try { issued = issueKey(ctx, `link:${body.alias}`); }
|
|
340
|
+
catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
|
|
341
|
+
const id = newLinkId();
|
|
342
|
+
const state = stateFor(ctx);
|
|
343
|
+
if (!state) {
|
|
344
|
+
if (!revokeKeyIdempotent(ctx, issued.id)) {
|
|
345
|
+
console.warn(`[link] compensation_failed apiKeyId=${issued.id} phase=revoke-without-lifecycle`);
|
|
346
|
+
return fail("compensation_failed", "The link compensation could not be completed.", 500);
|
|
347
|
+
}
|
|
348
|
+
return fail("link_unavailable", "The link lifecycle is unavailable.", 503);
|
|
349
|
+
}
|
|
350
|
+
const record = { id, alias: body.alias, direction: "client-initiated" as const, hostKeyFingerprint: null, tunnelPort: body.tunnelPort, apiKeyId: issued.id, createdAt: new Date((ctx.deps.now ?? Date.now)()).toISOString() };
|
|
351
|
+
let failureCode = "link_issue_failed";
|
|
352
|
+
try {
|
|
353
|
+
const current = readStoreFor(ctx);
|
|
354
|
+
if (current.links.some(link => link.id === id)) throw new Error("link id collision");
|
|
355
|
+
if (current.links.some(link => link.alias === body.alias && link.direction === "client-initiated")) {
|
|
356
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
357
|
+
return compensation ?? fail("link_exists", "A link for this alias already exists.", 409);
|
|
358
|
+
}
|
|
359
|
+
writeStoreFor(ctx, { ...current, links: [...current.links, record] });
|
|
360
|
+
await state.listener.ensureStarted();
|
|
361
|
+
if (state.listener.status().state !== "listening") {
|
|
362
|
+
failureCode = "listener_unavailable";
|
|
363
|
+
throw new Error("link listener unavailable");
|
|
364
|
+
}
|
|
365
|
+
const boundStore = readStoreFor(ctx);
|
|
366
|
+
if (!port(boundStore.listenerPort)) throw new Error("link listener did not bind");
|
|
367
|
+
return Response.json({ linkId: id, apiKeyId: issued.id, key: issued.key, listenerPort: boundStore.listenerPort });
|
|
368
|
+
} catch {
|
|
369
|
+
const compensation = await compensateNewLink(ctx, state, record);
|
|
370
|
+
return compensation ?? fail(failureCode, failureCode === "listener_unavailable" ? "The link listener is unavailable." : "The link could not be issued.", 503);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type LinkJoinRouteOverrides = {
|
|
375
|
+
joinHome?: typeof joinHome;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
function joinRouteOverrides(ctx: ManagementContext): LinkJoinRouteOverrides {
|
|
379
|
+
return ctx.deps as ManagementApiDepsWithJoinOverrides;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
type ManagementApiDepsWithJoinOverrides = ManagementContext["deps"] & LinkJoinRouteOverrides;
|
|
383
|
+
|
|
384
|
+
function joinFailure(error: unknown): Response {
|
|
385
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "";
|
|
386
|
+
switch (code) {
|
|
387
|
+
case "host_not_confirmed": return fail("host_not_confirmed", "Confirm the SSH host before joining the link.", 409);
|
|
388
|
+
case "host_confirmation_expired": return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409);
|
|
389
|
+
case "join_port_failed": return fail("join_port_failed", "No local port is available for the link tunnel.", 503);
|
|
390
|
+
case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502);
|
|
391
|
+
case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502);
|
|
392
|
+
case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502);
|
|
393
|
+
case "join_rollback_failed": {
|
|
394
|
+
const linkId = error && typeof error === "object" && "linkId" in error && typeof error.linkId === "string" ? error.linkId : "unknown";
|
|
395
|
+
return fail("join_rollback_failed", `The join failed and the home link could not be revoked; run ocx link revoke --link-id ${linkId} on the home.`, 502);
|
|
396
|
+
}
|
|
397
|
+
case "join_restart_failed": return fail("join_restart_failed", "The link is ready; restart OpenCodex to finish connecting as a Child.", 500);
|
|
398
|
+
default: return fail("join_connect_failed", "The client link join could not be completed.", 502);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function handleJoin(ctx: ManagementContext, state: LinkRouteState): Promise<Response> {
|
|
403
|
+
const body = exactBody(await readManagementJsonBodyOr(ctx.req, null), ["alias"]);
|
|
404
|
+
if (!body || typeof body.alias !== "string") return fail("invalid_body", "alias is required.", 400);
|
|
405
|
+
try { assertSshAlias(body.alias); } catch { return fail("invalid_alias", "alias must be a valid SSH host alias.", 400); }
|
|
406
|
+
if (joinInProgress) return fail("join_in_progress", "A link join is already in progress.", 409);
|
|
407
|
+
joinInProgress = true;
|
|
408
|
+
try {
|
|
409
|
+
const confirmed = state.confirmedHosts?.get(body.alias);
|
|
410
|
+
const overrides = joinRouteOverrides(ctx);
|
|
411
|
+
const deps: ClientLinkJoinDeps = {
|
|
412
|
+
runner: runnerFor(ctx),
|
|
413
|
+
knownHostsFile: knownHostsFile(ctx),
|
|
414
|
+
scheduleRestart: () => { acceptSystemRestart(); },
|
|
415
|
+
...(confirmed ? { confirmedHost: confirmed } : {}),
|
|
416
|
+
};
|
|
417
|
+
const result = await (overrides.joinHome ?? joinHome)(deps, { alias: body.alias });
|
|
418
|
+
return Response.json({ linkId: result.linkId, alias: body.alias, restarting: true }, { status: 202 });
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return joinFailure(error);
|
|
421
|
+
} finally {
|
|
422
|
+
joinInProgress = false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async function remove(ctx: ManagementContext, state: LinkRouteState, id: string): Promise<Response> {
|
|
427
|
+
if (!LINK_ID.test(id)) return fail("invalid_link_id", "The link id is invalid.", 400);
|
|
428
|
+
const current = readStoreFor(ctx);
|
|
429
|
+
const record = current.links.find(link => link.id === id);
|
|
430
|
+
if (!record) return fail("link_not_found", "The link was not found.", 404);
|
|
431
|
+
const body = await readManagementJsonBodyOr(ctx.req, {});
|
|
432
|
+
if (!isRecord(body) || Object.keys(body).some(key => key !== "force") || (body.force !== undefined && typeof body.force !== "boolean")) return fail("invalid_body", "force must be a boolean.", 400);
|
|
433
|
+
const force = body.force === true;
|
|
434
|
+
await state.supervisor.stopLink(id);
|
|
435
|
+
const restartTunnel = async (): Promise<void> => {
|
|
436
|
+
try {
|
|
437
|
+
await state.supervisor.ensureStarted();
|
|
438
|
+
await state.supervisor.reload();
|
|
439
|
+
} catch (error) {
|
|
440
|
+
console.warn(`[link] tunnel restart failed linkId=${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
if (!force && record.direction === "hub-initiated") {
|
|
444
|
+
try {
|
|
445
|
+
const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: ["ocx", "disconnect"], knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
|
|
446
|
+
if (result.code !== 0) {
|
|
447
|
+
await restartTunnel();
|
|
448
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
await restartTunnel();
|
|
452
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
|
|
456
|
+
let next: LinkStore;
|
|
457
|
+
try {
|
|
458
|
+
const latest = readStoreFor(ctx);
|
|
459
|
+
next = { ...latest, links: latest.links.filter(link => link.id !== id) };
|
|
460
|
+
if (next.links.length !== latest.links.length) writeStoreFor(ctx, next);
|
|
461
|
+
clearCompensationFailed(id, compensationPath());
|
|
462
|
+
} catch { return fail("link_remove_failed", "The link record could not be removed.", 503); }
|
|
463
|
+
state.compensationFailures?.delete(id);
|
|
464
|
+
if (next.links.length === 0) await state.listener.close();
|
|
465
|
+
return Response.json({ linkId: id });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: LinkRouteState): Promise<Response | null> {
|
|
469
|
+
const { url, req } = ctx;
|
|
470
|
+
const path = url.pathname;
|
|
471
|
+
if (!isLinkPath(path)) return null;
|
|
472
|
+
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
473
|
+
if (url.pathname === "/api/link/join" && req.method === "POST") {
|
|
474
|
+
const denied = auth(ctx, "dashboard");
|
|
475
|
+
if (denied) return denied;
|
|
476
|
+
if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409);
|
|
477
|
+
const state = suppliedState ?? stateFor(ctx);
|
|
478
|
+
if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503);
|
|
479
|
+
return handleJoin(ctx, state);
|
|
480
|
+
}
|
|
481
|
+
const state = suppliedState ?? stateFor(ctx);
|
|
482
|
+
if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503);
|
|
483
|
+
if (url.pathname === "/api/link/status" && req.method === "GET") {
|
|
484
|
+
const denied = auth(ctx, "either");
|
|
485
|
+
if (denied) return denied;
|
|
486
|
+
const store = readStoreFor(ctx);
|
|
487
|
+
const listenerStatus = state.listener.status();
|
|
488
|
+
const dto: LinkStatusDto = projectLinkStatus(
|
|
489
|
+
store, state.supervisor.status(), listenerStatus, ctx.config, readCompensation(),
|
|
490
|
+
ctx.config.runtimeRole === "client" ? clientLinkTunnelStatus() : null,
|
|
491
|
+
);
|
|
492
|
+
for (const link of dto.links) {
|
|
493
|
+
const failure = state.compensationFailures?.get(link.id);
|
|
494
|
+
if (failure) Object.assign(link, { state: "failed" as const, since: failure.since, reason: failure.reason });
|
|
495
|
+
}
|
|
496
|
+
if (dto.child && store.links[0]) {
|
|
497
|
+
const failure = state.compensationFailures?.get(store.links[0].id);
|
|
498
|
+
if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
|
|
499
|
+
}
|
|
500
|
+
return Response.json(dto, { headers: { "cache-control": "no-store" } });
|
|
501
|
+
}
|
|
502
|
+
if (url.pathname === "/api/link/candidates" && req.method === "GET") {
|
|
503
|
+
const denied = auth(ctx, "dashboard");
|
|
504
|
+
if (denied) return denied;
|
|
505
|
+
return candidates(ctx);
|
|
506
|
+
}
|
|
507
|
+
if (url.pathname === "/api/link/probe" && req.method === "POST") {
|
|
508
|
+
const denied = auth(ctx, "dashboard");
|
|
509
|
+
if (denied) return denied;
|
|
510
|
+
return probe(ctx, state);
|
|
511
|
+
}
|
|
512
|
+
if (url.pathname === "/api/link/confirm-host" && req.method === "POST") {
|
|
513
|
+
const denied = auth(ctx, "dashboard");
|
|
514
|
+
if (denied) return denied;
|
|
515
|
+
return confirmHost(ctx, state);
|
|
516
|
+
}
|
|
517
|
+
if (url.pathname === "/api/link/apply" && req.method === "POST") {
|
|
518
|
+
const denied = auth(ctx, "dashboard");
|
|
519
|
+
if (denied) return denied;
|
|
520
|
+
return apply(ctx, state);
|
|
521
|
+
}
|
|
522
|
+
if (url.pathname === "/api/link/issue" && req.method === "POST") {
|
|
523
|
+
const denied = auth(ctx, "admin");
|
|
524
|
+
if (denied) return denied;
|
|
525
|
+
return issue(ctx);
|
|
526
|
+
}
|
|
527
|
+
if (req.method === "DELETE" && path.startsWith("/api/link/")) {
|
|
528
|
+
const denied = auth(ctx, "either");
|
|
529
|
+
if (denied) return denied;
|
|
530
|
+
let id: string;
|
|
531
|
+
try { id = decodeURIComponent(path.slice("/api/link/".length)); } catch { return fail("invalid_link_id", "The link id is invalid.", 400); }
|
|
532
|
+
return remove(ctx, state, id);
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
@@ -84,7 +84,12 @@ import {
|
|
|
84
84
|
getUsageSummaryCacheEntry,
|
|
85
85
|
setUsageSummaryCacheEntry,
|
|
86
86
|
} from "./usage-summary-cache";
|
|
87
|
-
import {
|
|
87
|
+
import {
|
|
88
|
+
getFilteredUsageAggregate,
|
|
89
|
+
getJevStatsAggregate,
|
|
90
|
+
getUsageAggregate,
|
|
91
|
+
} from "./usage-aggregate-cache";
|
|
92
|
+
import { normalizeJevStatsComboId } from "../../usage/jev-stats";
|
|
88
93
|
|
|
89
94
|
function nextLocalMidnight(now: number): number {
|
|
90
95
|
const next = new Date(now);
|
|
@@ -180,6 +185,31 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
188
|
+
if (url.searchParams.get("jev") === "1") {
|
|
189
|
+
const range = parseRange(url.searchParams.get("range"));
|
|
190
|
+
const rawComboId = url.searchParams.get("comboId");
|
|
191
|
+
const comboId = rawComboId === null ? undefined : normalizeJevStatsComboId(rawComboId);
|
|
192
|
+
if (rawComboId !== null && (comboId === undefined || comboId !== rawComboId.trim())) {
|
|
193
|
+
return jsonResponse({ error: "invalid comboId" }, 400);
|
|
194
|
+
}
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const { since } = rangeWindow(range, now);
|
|
197
|
+
try {
|
|
198
|
+
const aggregate = await getJevStatsAggregate({ comboId, since });
|
|
199
|
+
return jsonResponse({
|
|
200
|
+
...aggregate.accumulator.summarize(range, now),
|
|
201
|
+
...(aggregate.usageIncomplete
|
|
202
|
+
? { usageIncomplete: true as const, usageIncompleteReason: "oversized_rows" as const }
|
|
203
|
+
: {}),
|
|
204
|
+
historyTruncated: false,
|
|
205
|
+
truncatedPrefixBytes: 0,
|
|
206
|
+
entriesTruncated: false,
|
|
207
|
+
entriesDropped: 0,
|
|
208
|
+
});
|
|
209
|
+
} catch {
|
|
210
|
+
return jsonResponse({ error: "read_failed" }, 500);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
183
213
|
// A sub-resource on the same route rather than a route of its own. It answers a different
|
|
184
214
|
// question -- which failures keep recurring, rather than what was spent -- and it costs a
|
|
185
215
|
// ledger scan, so it is opt-in: a dashboard asking for the usage summary must not pay for
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
|
|
2
|
+
import { commitClaudeCodeBlock } from "../../claude/claude-code-block";
|
|
2
3
|
/**
|
|
3
4
|
* Toggle routes for the integrations that are NOT file-merged clients.
|
|
4
5
|
*
|
|
@@ -886,19 +887,13 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro
|
|
|
886
887
|
} satisfies NativeToggleEnvelope);
|
|
887
888
|
}
|
|
888
889
|
|
|
889
|
-
const next = { ...(config.claudeCode ?? {}), enabled };
|
|
890
890
|
/*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
* subscriber and pins it to literal subscription. Toggling Claude ON is one
|
|
896
|
-
* of the two ways a block gets CREATED, so without this the next startServer
|
|
897
|
-
* would silently convert a user's Auto auth mode into a sticky manual
|
|
898
|
-
* subscription — a failure that surfaces nowhere near this route.
|
|
891
|
+
* Same block writer as PUT /api/claude-code: it stamps the migration sentinel.
|
|
892
|
+
* Toggling Claude ON is one of the two ways a block gets CREATED, so without the
|
|
893
|
+
* sentinel the next startServer would silently convert a user's Auto auth mode into
|
|
894
|
+
* a sticky manual subscription — a failure that surfaces nowhere near this route.
|
|
899
895
|
*/
|
|
900
|
-
|
|
901
|
-
config.claudeCode = next;
|
|
896
|
+
commitClaudeCodeBlock(config, { ...(config.claudeCode ?? {}), enabled });
|
|
902
897
|
|
|
903
898
|
/*
|
|
904
899
|
* `deps.` first: ManagementApiDeps carries this seam so route tests with an
|