@bitkyc08/opencodex 2.67.0-preview.20260926 → 2.68.0-preview.20260927
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/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-B5-cAEd2.js +51 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-BcXblvem.js +86 -0
- package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-B3VRRYSj.js +0 -51
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/Tray-TYAA0EQY.js +0 -1
- package/gui/dist/assets/index-9Ju7nHsu.js +0 -86
- package/gui/dist/assets/tray-data-xKfS2gSG.js +0 -1
|
@@ -2,14 +2,14 @@ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:
|
|
|
2
2
|
import { mkdtempSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv } from "../../link/ssh-argv";
|
|
5
|
+
import { assertSshAlias, buildExecArgv, buildFingerprintArgv, buildProbeArgv, REMOTE_COMMAND_NOT_FOUND, remoteOcxArgv } from "../../link/ssh-argv";
|
|
6
6
|
import { parseFingerprintLine } from "../../link/fingerprint";
|
|
7
7
|
import { awaitFirstAdmission } from "../../link/admission-wait";
|
|
8
8
|
import { linkKnownHostsPath, linkStorePath } from "../../link/paths";
|
|
9
9
|
import { clearCompensationFailed, compensationPath, markCompensationFailed, readCompensation } from "../../link/compensation";
|
|
10
10
|
import { loadHostCandidates } from "../../link/ssh-config";
|
|
11
11
|
import { newLinkId, readLinkStore, writeLinkStore, type LinkStore } from "../../link/store";
|
|
12
|
-
import { createSshRunner, type SshRunner } from "../../link/ssh-runner";
|
|
12
|
+
import { boundHint, createSshRunner, sshFailureHint, sshRunnerErrorHint, type SshRunner, type SshRunResult } from "../../link/ssh-runner";
|
|
13
13
|
import { projectLinkStatus, type LinkStatusDto } from "../../link/status-projection";
|
|
14
14
|
import { isLinkPort } from "../../link/ports";
|
|
15
15
|
import { joinHome, type ClientLinkJoinDeps } from "../../client/link-join";
|
|
@@ -17,11 +17,18 @@ import { clientLinkTunnelStatus } from "../../client/link-tunnel";
|
|
|
17
17
|
import type { ManagementContext } from "./context";
|
|
18
18
|
import { readManagementJsonBodyOr } from "./body";
|
|
19
19
|
import { issueApiKeyInProcess, revokeApiKeyInProcess, type IssuedApiKey } from "./oauth-account-routes";
|
|
20
|
-
import { acceptSystemRestart } from "./system-restart";
|
|
20
|
+
import { acceptSystemRestart, resolveListenPort } from "./system-restart";
|
|
21
21
|
|
|
22
22
|
const PROBE_TTL_MS = 5 * 60_000;
|
|
23
23
|
const APPLY_ADMISSION_TIMEOUT_MS = 15_000;
|
|
24
24
|
const LINK_ID = /^lnk_[0-9a-f]{16}$/;
|
|
25
|
+
// `ocx link` and `ocx connect --link` first shipped in 2.66.0; an older remote passes --version
|
|
26
|
+
// and then fails at apply with remote_port_failed.
|
|
27
|
+
const MIN_REMOTE_OCX = [2, 66, 0] as const;
|
|
28
|
+
// A bounded semver shape: the parsed version reaches the confirm-host body and the outdated hint,
|
|
29
|
+
// so the tail admits only a pre-release and build of semver identifier characters and must end
|
|
30
|
+
// the token. Anything else attached to the version makes the output unrecognized.
|
|
31
|
+
const REMOTE_OCX_VERSION = /^opencodex (\d{1,9})\.(\d{1,9})\.(\d{1,9})(-[0-9A-Za-z.-]{1,64})?(\+[0-9A-Za-z.-]{1,64})?(?=\s|$)/;
|
|
25
32
|
const isLinkPath = (path: string): boolean => path === "/api/link" || path.startsWith("/api/link/");
|
|
26
33
|
|
|
27
34
|
export interface PendingLinkHost {
|
|
@@ -55,8 +62,13 @@ const states = new WeakMap<object, LinkRouteState>();
|
|
|
55
62
|
// One process runs at most one join: a join ends by restarting this process as a client.
|
|
56
63
|
let joinInProgress = false;
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
/**
|
|
66
|
+
* `hint` is one line bounded by `boundHint`: from ssh stderr (`sshFailureHint`), from the ssh
|
|
67
|
+
* runner's own failure (`sshRunnerErrorHint`), or the parsed remote version for
|
|
68
|
+
* `remote_ocx_outdated`. It is returned, never logged.
|
|
69
|
+
*/
|
|
70
|
+
function fail(code: string, message: string, status: number, hint?: string): Response {
|
|
71
|
+
return Response.json({ error: { code, message, ...(hint ? { hint } : {}) } }, { status, headers: { "cache-control": "no-store" } });
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -74,23 +86,54 @@ function port(value: unknown): value is number {
|
|
|
74
86
|
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
/** A paired GUI session. Only hub runtimes issue these. */
|
|
90
|
+
function pairedSession(ctx: ManagementContext): boolean {
|
|
78
91
|
return ctx.principal === "gui-session"
|
|
79
92
|
&& ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A paired session, or on a standalone runtime the current loopback-issued session that reached
|
|
97
|
+
* the public listener bound to a loopback hostname. The loopback bootstrap mints that session
|
|
98
|
+
* without a credential, so this is casual-path protection like POST /api/github/star, not a
|
|
99
|
+
* secret-backed boundary like the admin token. Hubs keep the paired-only rule. Join admits this
|
|
100
|
+
* session too: turning a Child on from its own dashboard is the point of the route, and the
|
|
101
|
+
* dashboard warns first that the restart briefly interrupts running Codex turns.
|
|
102
|
+
*/
|
|
103
|
+
function dashboardSession(ctx: ManagementContext): boolean {
|
|
104
|
+
if (pairedSession(ctx)) return true;
|
|
105
|
+
return ctx.principal === "gui-session"
|
|
106
|
+
&& (ctx.config.runtimeRole ?? "standalone") === "standalone"
|
|
107
|
+
&& ctx.guiSessionIssuance === "loopback"
|
|
108
|
+
&& ctx.trustedLoopbackIngress
|
|
109
|
+
&& ctx.sessionControl?.isCurrent(ctx.req, ctx.config) === true;
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
function adminLoopback(ctx: ManagementContext): boolean {
|
|
83
113
|
return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null {
|
|
87
117
|
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
118
|
+
const allowed = kind === "dashboard" ? dashboardSession(ctx)
|
|
119
|
+
: kind === "admin" ? adminLoopback(ctx)
|
|
120
|
+
: dashboardSession(ctx) || adminLoopback(ctx);
|
|
121
|
+
return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The client runtime a join restarts into binds exactly the configured port, with no fallback.
|
|
126
|
+
* A standalone that runs elsewhere (`ocx start --port`, or a port fallback) would restart into a
|
|
127
|
+
* proxy that cannot bind where Codex is routed, so it may not join.
|
|
128
|
+
*/
|
|
129
|
+
function joinPortMatches(ctx: ManagementContext): boolean {
|
|
130
|
+
const live = (ctx.deps.liveListenPort ?? resolveListenPort)();
|
|
131
|
+
return live !== undefined && live === ctx.config.port;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */
|
|
135
|
+
function joinAvailable(ctx: ManagementContext): boolean {
|
|
136
|
+
return dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx);
|
|
94
137
|
}
|
|
95
138
|
|
|
96
139
|
function runnerFor(ctx: ManagementContext): SshRunner {
|
|
@@ -220,16 +263,16 @@ async function probe(ctx: ManagementContext, state: LinkRouteState): Promise<Res
|
|
|
220
263
|
const runner = runnerFor(ctx);
|
|
221
264
|
try {
|
|
222
265
|
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);
|
|
266
|
+
if (result.code !== 0) return fail("probe_failed", "SSH host probing failed.", 502, sshFailureHint(result.stderr));
|
|
224
267
|
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);
|
|
268
|
+
if (fingerprintResult.code !== 0) return fail("fingerprint_failed", "The SSH host fingerprint could not be read.", 502, sshFailureHint(fingerprintResult.stderr));
|
|
226
269
|
const parsed = parseFingerprintLine(fingerprintResult.stdout.trim());
|
|
227
270
|
const knownHostLine = readFileSync(tempKnownHosts, "utf8").trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
228
271
|
if (!knownHostLine) return fail("fingerprint_failed", "The SSH probe did not record a host key.", 502);
|
|
229
272
|
state.pendingHosts.set(body.alias, { alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType, knownHostLine, probedAt: (ctx.deps.now ?? Date.now)() });
|
|
230
273
|
return Response.json({ alias: body.alias, fingerprint: parsed.fingerprint, keyType: parsed.keyType });
|
|
231
|
-
} catch {
|
|
232
|
-
return fail("probe_failed", "SSH host probing failed.", 502);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return fail("probe_failed", "SSH host probing failed.", 502, sshRunnerErrorHint(error));
|
|
233
276
|
} finally {
|
|
234
277
|
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
235
278
|
}
|
|
@@ -244,18 +287,43 @@ async function confirmHost(ctx: ManagementContext, state: LinkRouteState): Promi
|
|
|
244
287
|
if (pending.fingerprint !== body.fingerprint) return fail("host_fingerprint_mismatch", "The confirmed fingerprint does not match the probe.", 409);
|
|
245
288
|
const path = knownHostsFile(ctx);
|
|
246
289
|
const before = putKnownHost(path, pending.alias, pending.knownHostLine);
|
|
290
|
+
// Every refusal restores known_hosts and keeps the pending probe, so a retry within its TTL works.
|
|
291
|
+
const refuse = (response: Response): Response => { restoreKnownHost(path, before); return response; };
|
|
292
|
+
let result: SshRunResult;
|
|
247
293
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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);
|
|
294
|
+
result = await runnerFor(ctx).run(buildExecArgv({ alias: pending.alias, argv: remoteOcxArgv(["--version"]), knownHostsFile: path }), { timeoutMs: 30_000 });
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshRunnerErrorHint(error)));
|
|
258
297
|
}
|
|
298
|
+
if (result.code === REMOTE_COMMAND_NOT_FOUND) return refuse(fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr)));
|
|
299
|
+
if (result.code !== 0) return refuse(fail("version_probe_failed", "The remote ocx version could not be confirmed.", 502, sshFailureHint(result.stderr)));
|
|
300
|
+
const version = parseRemoteOcxVersion(result.stdout);
|
|
301
|
+
if (!version) return refuse(fail("remote_ocx_unrecognized", "The remote ocx did not report an OpenCodex version.", 502));
|
|
302
|
+
if (!meetsRemoteOcxFloor(version.parts)) {
|
|
303
|
+
return refuse(fail("remote_ocx_outdated", `The remote OpenCodex is older than ${MIN_REMOTE_OCX.join(".")}.`, 409, boundHint(`opencodex ${version.version}`)));
|
|
304
|
+
}
|
|
305
|
+
const confirmed = { ...pending, ocxVersion: version.version };
|
|
306
|
+
state.confirmedHosts ??= new Map();
|
|
307
|
+
state.confirmedHosts.set(pending.alias, confirmed);
|
|
308
|
+
state.pendingHosts.delete(pending.alias);
|
|
309
|
+
return Response.json({ alias: pending.alias, fingerprint: pending.fingerprint, ocxVersion: confirmed.ocxVersion });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `ocx --version` prints `opencodex <version>` first; a usage banner, an unbounded version tail or
|
|
314
|
+
* other output is refused.
|
|
315
|
+
*/
|
|
316
|
+
function parseRemoteOcxVersion(stdout: string): { version: string; parts: [number, number, number] } | null {
|
|
317
|
+
const match = REMOTE_OCX_VERSION.exec(stdout.trim().split(/\r?\n/)[0] ?? "");
|
|
318
|
+
if (!match) return null;
|
|
319
|
+
return { version: `${match[1]}.${match[2]}.${match[3]}${match[4] ?? ""}${match[5] ?? ""}`, parts: [Number(match[1]), Number(match[2]), Number(match[3])] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function meetsRemoteOcxFloor(parts: readonly [number, number, number]): boolean {
|
|
323
|
+
for (let index = 0; index < MIN_REMOTE_OCX.length; index += 1) {
|
|
324
|
+
if (parts[index] !== MIN_REMOTE_OCX[index]) return parts[index]! > MIN_REMOTE_OCX[index]!;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
259
327
|
}
|
|
260
328
|
|
|
261
329
|
function parsePortOutput(stdout: string): number | null {
|
|
@@ -274,11 +342,14 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
|
|
|
274
342
|
const knownHosts = knownHostsFile(ctx);
|
|
275
343
|
const runner = runnerFor(ctx);
|
|
276
344
|
let remotePort: number;
|
|
345
|
+
let portHint: string | undefined;
|
|
277
346
|
try {
|
|
278
|
-
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["
|
|
347
|
+
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["link", "port"]), knownHostsFile: knownHosts }), { timeoutMs: 30_000 });
|
|
348
|
+
if (result.code === REMOTE_COMMAND_NOT_FOUND) return fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, sshFailureHint(result.stderr));
|
|
279
349
|
remotePort = result.code === 0 ? parsePortOutput(result.stdout) ?? 0 : 0;
|
|
280
|
-
|
|
281
|
-
|
|
350
|
+
if (result.code !== 0) portHint = sshFailureHint(result.stderr);
|
|
351
|
+
} catch (error) { remotePort = 0; portHint = sshRunnerErrorHint(error); }
|
|
352
|
+
if (!port(remotePort)) return fail("remote_port_failed", "The remote link port could not be determined.", 502, portHint);
|
|
282
353
|
let issued: IssuedApiKey;
|
|
283
354
|
try { issued = issueKey(ctx, `link:${body.alias}`); }
|
|
284
355
|
catch { return fail("key_issue_failed", "The link key could not be issued.", 503); }
|
|
@@ -309,11 +380,15 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
|
|
|
309
380
|
const admission = awaitFirstAdmission(issued.id, APPLY_ADMISSION_TIMEOUT_MS, state.listener.onAuthenticatedCatalog);
|
|
310
381
|
const input = new TextEncoder().encode(JSON.stringify({ apiKeyId: issued.id, key: issued.key }));
|
|
311
382
|
try {
|
|
312
|
-
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: ["
|
|
383
|
+
const result = await runner.run(buildExecArgv({ alias: body.alias, argv: remoteOcxArgv(["connect", "--link", "--key-stdin", "--tunnel-port", String(remotePort), "--link-id", id]), knownHostsFile: knownHosts }), { stdin: input, timeoutMs: APPLY_ADMISSION_TIMEOUT_MS });
|
|
313
384
|
if (result.code !== 0) {
|
|
314
385
|
void admission.catch(() => {});
|
|
315
386
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
316
|
-
|
|
387
|
+
if (compensation) return compensation;
|
|
388
|
+
const hint = sshFailureHint(result.stderr);
|
|
389
|
+
return result.code === REMOTE_COMMAND_NOT_FOUND
|
|
390
|
+
? fail("remote_ocx_missing", "ocx was not found on the remote host.", 502, hint)
|
|
391
|
+
: fail("remote_connect_failed", "The remote link connection failed.", 502, hint);
|
|
317
392
|
}
|
|
318
393
|
try {
|
|
319
394
|
await admission;
|
|
@@ -322,10 +397,10 @@ async function apply(ctx: ManagementContext, state: LinkRouteState): Promise<Res
|
|
|
322
397
|
return compensation ?? fail("admission_timeout", "The remote link did not authenticate a catalog request in time.", 502);
|
|
323
398
|
}
|
|
324
399
|
return Response.json({ linkId: id }, { status: 202 });
|
|
325
|
-
} catch {
|
|
400
|
+
} catch (error) {
|
|
326
401
|
void admission.catch(() => {});
|
|
327
402
|
const compensation = await compensateNewLink(ctx, state, record);
|
|
328
|
-
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502);
|
|
403
|
+
return compensation ?? fail("remote_connect_failed", "The remote link connection failed.", 502, sshRunnerErrorHint(error));
|
|
329
404
|
} finally {
|
|
330
405
|
input.fill(0);
|
|
331
406
|
}
|
|
@@ -362,6 +437,7 @@ async function issue(ctx: ManagementContext): Promise<Response> {
|
|
|
362
437
|
failureCode = "listener_unavailable";
|
|
363
438
|
throw new Error("link listener unavailable");
|
|
364
439
|
}
|
|
440
|
+
await state.supervisor.ensureStarted();
|
|
365
441
|
const boundStore = readStoreFor(ctx);
|
|
366
442
|
if (!port(boundStore.listenerPort)) throw new Error("link listener did not bind");
|
|
367
443
|
return Response.json({ linkId: id, apiKeyId: issued.id, key: issued.key, listenerPort: boundStore.listenerPort });
|
|
@@ -383,11 +459,13 @@ type ManagementApiDepsWithJoinOverrides = ManagementContext["deps"] & LinkJoinRo
|
|
|
383
459
|
|
|
384
460
|
function joinFailure(error: unknown): Response {
|
|
385
461
|
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "";
|
|
462
|
+
const hint = error && typeof error === "object" && "hint" in error && typeof error.hint === "string" ? error.hint : undefined;
|
|
386
463
|
switch (code) {
|
|
387
464
|
case "host_not_confirmed": return fail("host_not_confirmed", "Confirm the SSH host before joining the link.", 409);
|
|
388
465
|
case "host_confirmation_expired": return fail("host_confirmation_expired", "The SSH host confirmation has expired.", 409);
|
|
389
466
|
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);
|
|
467
|
+
case "join_issue_failed": return fail("join_issue_failed", "The home could not issue a link.", 502, hint);
|
|
468
|
+
case "remote_ocx_missing": return fail("remote_ocx_missing", "ocx was not found on the home.", 502, hint);
|
|
391
469
|
case "join_tunnel_failed": return fail("join_tunnel_failed", "The SSH tunnel to the home did not become ready.", 502);
|
|
392
470
|
case "admission_failed": return fail("admission_failed", "The home refused the issued link key.", 502);
|
|
393
471
|
case "join_rollback_failed": {
|
|
@@ -442,14 +520,14 @@ async function remove(ctx: ManagementContext, state: LinkRouteState, id: string)
|
|
|
442
520
|
};
|
|
443
521
|
if (!force && record.direction === "hub-initiated") {
|
|
444
522
|
try {
|
|
445
|
-
const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: ["
|
|
523
|
+
const result = await runnerFor(ctx).run(buildExecArgv({ alias: record.alias, argv: remoteOcxArgv(["disconnect"]), knownHostsFile: knownHostsFile(ctx) }), { timeoutMs: 30_000 });
|
|
446
524
|
if (result.code !== 0) {
|
|
447
525
|
await restartTunnel();
|
|
448
|
-
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
|
|
526
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshFailureHint(result.stderr));
|
|
449
527
|
}
|
|
450
|
-
} catch {
|
|
528
|
+
} catch (error) {
|
|
451
529
|
await restartTunnel();
|
|
452
|
-
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502);
|
|
530
|
+
return fail("remote_disconnect_failed", "The remote client could not be disconnected.", 502, sshRunnerErrorHint(error));
|
|
453
531
|
}
|
|
454
532
|
}
|
|
455
533
|
if (!revokeKeyIdempotent(ctx, record.apiKeyId)) return fail("key_revoke_failed", "The link key could not be revoked.", 502);
|
|
@@ -471,9 +549,12 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
471
549
|
if (!isLinkPath(path)) return null;
|
|
472
550
|
if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
|
|
473
551
|
if (url.pathname === "/api/link/join" && req.method === "POST") {
|
|
552
|
+
// The same dashboard admission as the Home side. Every refusal below runs before link state
|
|
553
|
+
// is read and before any SSH.
|
|
474
554
|
const denied = auth(ctx, "dashboard");
|
|
475
555
|
if (denied) return denied;
|
|
476
556
|
if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409);
|
|
557
|
+
if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409);
|
|
477
558
|
const state = suppliedState ?? stateFor(ctx);
|
|
478
559
|
if (!state) return fail("link_unavailable", "The link lifecycle is unavailable.", 503);
|
|
479
560
|
return handleJoin(ctx, state);
|
|
@@ -497,7 +578,10 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
|
|
|
497
578
|
const failure = state.compensationFailures?.get(store.links[0].id);
|
|
498
579
|
if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
|
|
499
580
|
}
|
|
500
|
-
|
|
581
|
+
// A dashboard session also learns whether it may join as a Child. The admin-token answer stays
|
|
582
|
+
// the exact K16 document that `ocx link status` validates key by key.
|
|
583
|
+
const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto;
|
|
584
|
+
return Response.json(body, { headers: { "cache-control": "no-store" } });
|
|
501
585
|
}
|
|
502
586
|
if (url.pathname === "/api/link/candidates" && req.method === "GET") {
|
|
503
587
|
const denied = auth(ctx, "dashboard");
|
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
} from "../../lib/debug-settings";
|
|
68
68
|
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
69
69
|
import { drainAndShutdown } from "../lifecycle";
|
|
70
|
-
import {
|
|
70
|
+
import { getRequestLogEntries, queryRequestLogs, type RequestLogEntry } from "../request-log";
|
|
71
71
|
import { decodeRequestLogCursor, selectRequestLogPoll } from "../request-log-cursor";
|
|
72
72
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
73
73
|
import { userCostOverlayVersion } from "../../usage/user-cost-overlays";
|
|
@@ -120,11 +120,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
120
120
|
return jsonResponse({ error: { code: "invalid_cursor", message: "invalid cursor" } }, 400);
|
|
121
121
|
}
|
|
122
122
|
const all = getRequestLogEntries();
|
|
123
|
-
const
|
|
123
|
+
const queried = queryRequestLogs(all, url.searchParams);
|
|
124
|
+
const total = queried.total;
|
|
124
125
|
// Not point-free: requestLogDto takes an options object second, and Array.map would pass the
|
|
125
126
|
// element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
|
|
126
127
|
// what /api/logs wants; /api/request-history opts out at its own call sites.
|
|
127
|
-
const logs =
|
|
128
|
+
const logs = queried.logs.map(entry => requestLogDto(entry));
|
|
128
129
|
const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
|
|
129
130
|
return jsonResponse({
|
|
130
131
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -347,45 +348,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
|
|
|
347
348
|
}
|
|
348
349
|
return jsonResponse(requestedSummary);
|
|
349
350
|
} catch {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
generatedAt: now,
|
|
356
|
-
summary: {
|
|
357
|
-
requests: 0,
|
|
358
|
-
attemptCount: 0,
|
|
359
|
-
measuredRequests: 0,
|
|
360
|
-
reportedRequests: 0,
|
|
361
|
-
unreportedRequests: 0,
|
|
362
|
-
unsupportedRequests: 0,
|
|
363
|
-
estimatedRequests: 0,
|
|
364
|
-
inputTokens: 0,
|
|
365
|
-
outputTokens: 0,
|
|
366
|
-
cachedInputTokens: 0,
|
|
367
|
-
cacheReadInputTokens: 0,
|
|
368
|
-
cacheCreationInputTokens: 0,
|
|
369
|
-
reasoningOutputTokens: 0,
|
|
370
|
-
totalTokens: 0,
|
|
371
|
-
coverageRatio: 0,
|
|
372
|
-
estimatedCostUsd: 0,
|
|
373
|
-
pricedRequests: 0,
|
|
374
|
-
unpricedRequests: 0,
|
|
375
|
-
unmeteredRequests: 0,
|
|
376
|
-
},
|
|
377
|
-
days: [],
|
|
378
|
-
models: [],
|
|
379
|
-
providers: [],
|
|
380
|
-
accounts: [],
|
|
381
|
-
historyTruncated: false,
|
|
382
|
-
truncatedPrefixBytes: 0,
|
|
383
|
-
entriesTruncated: false,
|
|
384
|
-
entriesDropped: 0,
|
|
385
|
-
snapshotWindowStart: null,
|
|
386
|
-
snapshotWindowEnd: null,
|
|
387
|
-
error: "read_failed",
|
|
388
|
-
});
|
|
351
|
+
// A missing ledger is handled as an empty installation by the reader. Reaching this catch
|
|
352
|
+
// therefore means the report is unavailable, not that usage was measured as zero. Keep the
|
|
353
|
+
// transport status aligned with the JEV projection above so every dashboard consumer can
|
|
354
|
+
// retain its last valid snapshot instead of caching fabricated zero totals.
|
|
355
|
+
return jsonResponse({ error: "read_failed" }, 500);
|
|
389
356
|
}
|
|
390
357
|
}
|
|
391
358
|
|
|
@@ -241,6 +241,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
|
|
|
241
241
|
...(row.native ? { native: true } : {}),
|
|
242
242
|
...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}),
|
|
243
243
|
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
|
|
244
|
+
...(row.maxOutputTokens !== undefined ? { maxTokens: row.maxOutputTokens } : {}),
|
|
244
245
|
...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
|
|
245
246
|
...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}),
|
|
246
247
|
...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}),
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
submitManualLoginCode,
|
|
25
25
|
} from "../../oauth";
|
|
26
26
|
import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
|
|
27
|
+
import { cancelKiroDeviceLogin, kiroDeviceConfigBaseline, startKiroDeviceLogin, statusKiroDeviceLogin, type KiroDeviceMethod } from "../../oauth/kiro-device-login";
|
|
27
28
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
28
29
|
import { emailMaskingEnabled } from "../../lib/privacy";
|
|
29
30
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
@@ -186,8 +187,12 @@ export function revokeApiKeyInProcess(config: OcxConfig, id: string): boolean {
|
|
|
186
187
|
return true;
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
function canStartManagementOAuth(provider: string, principal: ManagementContext["principal"]): boolean {
|
|
191
|
+
return provider !== "meta-muse" || principal === "gui-session";
|
|
192
|
+
}
|
|
193
|
+
|
|
189
194
|
function metaMuseConsentRequired(provider: string, principal: ManagementContext["principal"]): Response | null {
|
|
190
|
-
if (provider
|
|
195
|
+
if (canStartManagementOAuth(provider, principal)) return null;
|
|
191
196
|
return jsonResponse({
|
|
192
197
|
error: "Meta Muse login requires acknowledgement in the OpenCodex dashboard.",
|
|
193
198
|
code: "oauth_consent_required",
|
|
@@ -204,7 +209,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
204
209
|
|
|
205
210
|
// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
|
|
206
211
|
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
|
|
207
|
-
|
|
212
|
+
// Discovery reflects this principal's admission; hiding a button is not the
|
|
213
|
+
// consent boundary, which remains independently enforced on both POST routes.
|
|
214
|
+
return jsonResponse({ providers: listOAuthProviders().filter(provider => canStartManagementOAuth(provider, principal)) });
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
// API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
|
|
@@ -216,7 +223,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
216
223
|
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
217
224
|
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
218
225
|
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
219
|
-
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown };
|
|
226
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean; openBrowser?: unknown; method?: unknown };
|
|
220
227
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
221
228
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
222
229
|
// Muse may import a local Keychain credential or start a device grant; add-account
|
|
@@ -229,6 +236,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
229
236
|
if (namespaceCollision) return jsonResponse({ error: namespaceCollision }, 409);
|
|
230
237
|
const accountId = body.accountId?.trim();
|
|
231
238
|
const reauth = body.reauth === true || Boolean(accountId);
|
|
239
|
+
if (provider === "kiro") {
|
|
240
|
+
if (reauth && !accountId) return jsonResponse({ error: "Kiro reauth requires an accountId" }, 400);
|
|
241
|
+
if (body.method !== undefined) {
|
|
242
|
+
if (body.method !== "builder-id" && body.method !== "google" && body.method !== "github") {
|
|
243
|
+
return jsonResponse({ error: "invalid Kiro device method" }, 400);
|
|
244
|
+
}
|
|
245
|
+
if (reauth) return jsonResponse({ error: "native_login_is_add_only" }, 400);
|
|
246
|
+
try {
|
|
247
|
+
return jsonResponse(await startKiroDeviceLogin(body.method as KiroDeviceMethod, principal ?? "admin-token", readConfigDiagnostics().config));
|
|
248
|
+
} catch {
|
|
249
|
+
return jsonResponse({ error: "Kiro device login could not start" }, 409);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
232
253
|
try {
|
|
233
254
|
if (accountId) {
|
|
234
255
|
const { getAccountSet } = await import("../../oauth/store");
|
|
@@ -281,9 +302,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
281
302
|
// Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
|
|
282
303
|
// the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
|
|
283
304
|
if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
|
|
284
|
-
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string };
|
|
305
|
+
const body = await readManagementJsonBodyOr(req, {}) as { provider?: string; flowId?: unknown };
|
|
285
306
|
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
286
307
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
308
|
+
if (provider === "kiro" && body.flowId !== undefined) {
|
|
309
|
+
if (typeof body.flowId !== "string") return jsonResponse({ error: "unknown login flow" }, 404);
|
|
310
|
+
const result = cancelKiroDeviceLogin(body.flowId, principal ?? "admin-token");
|
|
311
|
+
return result ? jsonResponse(result) : jsonResponse({ error: "unknown login flow" }, 404);
|
|
312
|
+
}
|
|
287
313
|
const { cancelLoginFlow } = await import("../../oauth");
|
|
288
314
|
const cancelled = cancelLoginFlow(provider);
|
|
289
315
|
return jsonResponse({ ok: true, cancelled });
|
|
@@ -309,6 +335,17 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
309
335
|
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
310
336
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
311
337
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
338
|
+
if (provider === "kiro" && url.searchParams.has("flowId")) {
|
|
339
|
+
const flowId = url.searchParams.get("flowId") ?? "";
|
|
340
|
+
const baseline = kiroDeviceConfigBaseline(flowId, principal ?? "admin-token");
|
|
341
|
+
const status = await statusKiroDeviceLogin(flowId, principal ?? "admin-token");
|
|
342
|
+
if (!status) return jsonResponse({ error: "unknown login flow" }, 404);
|
|
343
|
+
if (status.state === "done") {
|
|
344
|
+
reconcileLiveConfigFromDisk(config, baseline ?? structuredClone(config));
|
|
345
|
+
reconcileLiveStateStores();
|
|
346
|
+
}
|
|
347
|
+
return jsonResponse(status);
|
|
348
|
+
}
|
|
312
349
|
// Resolved here, at the request boundary that already holds the config, and passed down.
|
|
313
350
|
// getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
|
|
314
351
|
// consumes the already-projected status rather than redacting a second time.
|
|
@@ -347,6 +384,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
347
384
|
const quotaMode = providerOAuthAccountQuotaMode(provider);
|
|
348
385
|
const quotaProvider = config.providers[provider];
|
|
349
386
|
const { getAccountSet } = await import("../../oauth/store");
|
|
387
|
+
const { kiroAutoSelection } = await import("../../oauth/generic-account-failover");
|
|
350
388
|
const {
|
|
351
389
|
oauthAccountHealthFields,
|
|
352
390
|
projectOAuthAccountHealth,
|
|
@@ -365,7 +403,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
365
403
|
needsReauth: summary.needsReauth === true,
|
|
366
404
|
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
|
|
367
405
|
});
|
|
368
|
-
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode
|
|
406
|
+
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
|
|
407
|
+
...(provider === "kiro" && full ? kiroAutoSelection(full) : {}) };
|
|
369
408
|
}),
|
|
370
409
|
};
|
|
371
410
|
};
|
|
@@ -439,14 +478,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
439
478
|
// them that kind honours.
|
|
440
479
|
if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) {
|
|
441
480
|
const {
|
|
442
|
-
poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit,
|
|
481
|
+
poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit, parseKiroAccountCap,
|
|
443
482
|
unifiedPoolSettingsDto,
|
|
444
483
|
} = await import("../../oauth/pool-settings-capability");
|
|
445
484
|
const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {});
|
|
446
485
|
if (req.method !== "GET" && !isPlainRecord(rawBody)) {
|
|
447
486
|
return jsonResponse({ error: "body must be an object" }, 400);
|
|
448
487
|
}
|
|
449
|
-
const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown };
|
|
488
|
+
const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown; maxConcurrentPerAccount?: unknown };
|
|
450
489
|
const provider = req.method === "GET"
|
|
451
490
|
? (url.searchParams.get("provider") ?? "").trim().toLowerCase()
|
|
452
491
|
: (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : "");
|
|
@@ -458,10 +497,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
458
497
|
// sticky limit is refused identically whichever pool is addressed.
|
|
459
498
|
let strategy: string | undefined;
|
|
460
499
|
if (fields.strategy !== undefined) {
|
|
461
|
-
const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy);
|
|
500
|
+
const parsed = kind === "codex" ? parseCodexAccountPoolStrategy(fields.strategy) : parseGenericPoolStrategy(fields.strategy, provider);
|
|
462
501
|
if (parsed === null) return jsonResponse({ error: kind === "codex"
|
|
463
502
|
? "strategy must be one of: quota, round-robin, fill-first, reset-first"
|
|
464
|
-
:
|
|
503
|
+
: `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400);
|
|
465
504
|
strategy = parsed;
|
|
466
505
|
}
|
|
467
506
|
let stickyLimit: number | undefined;
|
|
@@ -489,6 +528,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
489
528
|
if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400);
|
|
490
529
|
if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
491
530
|
}
|
|
531
|
+
let accountCap: number | null | undefined;
|
|
532
|
+
if (fields.maxConcurrentPerAccount !== undefined) {
|
|
533
|
+
if (provider !== "kiro" || kind !== "generic") return jsonResponse({ error: "maxConcurrentPerAccount is only supported for Kiro OAuth" }, 400);
|
|
534
|
+
accountCap = fields.maxConcurrentPerAccount === null ? null : parseKiroAccountCap(fields.maxConcurrentPerAccount);
|
|
535
|
+
if (accountCap === null && fields.maxConcurrentPerAccount !== null) return jsonResponse({ error: "maxConcurrentPerAccount must be an integer 1-100 or null" }, 400);
|
|
536
|
+
}
|
|
492
537
|
|
|
493
538
|
if (req.method !== "GET") {
|
|
494
539
|
if (kind === "codex") {
|
|
@@ -508,6 +553,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
508
553
|
const next = { ...(prov.oauthAccountFailover ?? {}) };
|
|
509
554
|
if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean;
|
|
510
555
|
if (strategy !== undefined) next.strategy = strategy as never;
|
|
556
|
+
if (accountCap === null) delete next.maxConcurrentPerAccount;
|
|
557
|
+
else if (accountCap !== undefined) next.maxConcurrentPerAccount = accountCap;
|
|
511
558
|
if (stickyLimit !== undefined) next.stickyLimit = stickyLimit;
|
|
512
559
|
if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold;
|
|
513
560
|
if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
|
|
@@ -558,12 +605,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
558
605
|
strategy?: unknown;
|
|
559
606
|
stickyLimit?: unknown;
|
|
560
607
|
quotaWindow?: unknown;
|
|
608
|
+
maxConcurrentPerAccount?: unknown;
|
|
561
609
|
};
|
|
562
610
|
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
563
611
|
if (provider !== "anthropic") {
|
|
564
612
|
const {
|
|
565
613
|
poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold,
|
|
566
|
-
parseGenericStickyLimit,
|
|
614
|
+
parseGenericStickyLimit, parseKiroAccountCap,
|
|
567
615
|
} = await import("../../oauth/pool-settings-capability");
|
|
568
616
|
const prov = config.providers[provider];
|
|
569
617
|
if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
|
|
@@ -580,8 +628,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
580
628
|
if (body.strategy !== undefined) {
|
|
581
629
|
if (body.strategy === null) delete next.strategy;
|
|
582
630
|
else {
|
|
583
|
-
const parsed = parseGenericPoolStrategy(body.strategy);
|
|
584
|
-
if (parsed === null) return jsonResponse({ error:
|
|
631
|
+
const parsed = parseGenericPoolStrategy(body.strategy, provider);
|
|
632
|
+
if (parsed === null) return jsonResponse({ error: `strategy must be one of: quota, round-robin, fill-first${provider === "kiro" ? ", least-loaded" : ""}` }, 400);
|
|
585
633
|
next.strategy = parsed;
|
|
586
634
|
}
|
|
587
635
|
}
|
|
@@ -601,6 +649,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
601
649
|
next.stickyLimit = parsed;
|
|
602
650
|
}
|
|
603
651
|
}
|
|
652
|
+
if (body.maxConcurrentPerAccount !== undefined) {
|
|
653
|
+
if (provider !== "kiro") return jsonResponse({ error: "maxConcurrentPerAccount is only supported for Kiro OAuth" }, 400);
|
|
654
|
+
const cap = body.maxConcurrentPerAccount === null ? null : parseKiroAccountCap(body.maxConcurrentPerAccount);
|
|
655
|
+
if (cap === null && body.maxConcurrentPerAccount !== null) return jsonResponse({ error: "maxConcurrentPerAccount must be an integer 1-100 or null" }, 400);
|
|
656
|
+
if (cap === null) delete next.maxConcurrentPerAccount;
|
|
657
|
+
else next.maxConcurrentPerAccount = cap;
|
|
658
|
+
}
|
|
604
659
|
if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
|
|
605
660
|
else delete prov.oauthAccountFailover;
|
|
606
661
|
saveConfigPreservingClaudeCode(config);
|
|
@@ -368,11 +368,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
368
368
|
{ method: "POST", path: "/api/storage/codex-logs/unprotect", module: "server/management/storage-log-guard-routes", mutates: true },
|
|
369
369
|
// server/management/link-routes
|
|
370
370
|
{ method: "GET", path: "/api/link/status", module: "server/management/link-routes", mutates: false },
|
|
371
|
-
{ method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard
|
|
372
|
-
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive
|
|
373
|
-
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the
|
|
374
|
-
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client." } },
|
|
375
|
-
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires
|
|
371
|
+
{ method: "GET", path: "/api/link/candidates", module: "server/management/link-routes", mutates: false, exempt: { reason: "session-only", why: "SSH candidates are a dashboard surface: a paired session, or on a standalone runtime the current loopback dashboard session on trusted loopback ingress; admin-token and Tailscale identity sessions are refused." } },
|
|
372
|
+
{ method: "POST", path: "/api/link/probe", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "SSH probing and host-key presentation are part of the interactive dashboard consent flow (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
373
|
+
{ method: "POST", path: "/api/link/confirm-host", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Persisting a host key requires the dashboard session that saw the fingerprint (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
374
|
+
{ method: "POST", path: "/api/link/join", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Joining a confirmed Home issues a link credential and restarts this standalone runtime as a client on its configured port, which briefly interrupts running Codex turns. It is the dashboard's turn-on flow with a pre-join notice (paired, or standalone loopback dashboard on trusted loopback ingress); admin-token and Tailscale identity sessions are refused." } },
|
|
375
|
+
{ method: "POST", path: "/api/link/apply", module: "server/management/link-routes", mutates: true, exempt: { reason: "session-only", why: "Applying a link issues a data key and starts a remote tunnel, so it requires a dashboard session (paired, or standalone loopback dashboard on trusted loopback ingress)." } },
|
|
376
376
|
{ method: "DELETE", path: "/api/link/{id}", module: "server/management/link-routes", mutates: true, mechanism: "regex" },
|
|
377
377
|
{ method: "POST", path: "/api/link/issue", module: "server/management/link-routes", mutates: true },
|
|
378
378
|
// server/management/remote-workspace-routes
|