@bitkyc08/opencodex 2.83.0 → 2.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +19 -0
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export.ts +46 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +311 -171
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +24 -15
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +14 -14
- package/src/providers/registry/entries-extended.ts +0 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +13 -0
- package/src/server/lifecycle.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +5 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/responses/adapter-dispatch.ts +232 -79
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -4
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +6 -8
- package/src/server/responses/sidecar-execution.ts +11 -2
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/usage/expected-prices.ts +4 -0
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
package/src/responses/parser.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { createToolChoiceResolver, namespacedToolName } from "../types";
|
|
|
15
15
|
import { responsesRequestSchema } from "./schema";
|
|
16
16
|
import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
|
|
17
17
|
import { lookupReplayThoughtSignature } from "./thought-signature-replay";
|
|
18
|
-
import { compactionItemToText, isCompactionItemType } from "./compaction";
|
|
18
|
+
import { CHECKPOINT_CONTINUATION_NOTE, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "./compaction";
|
|
19
19
|
import { previousResponseReplayPrefixLength } from "./state";
|
|
20
20
|
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
21
21
|
import { hasRoutedIdentity, nameRoutedIdentity } from "../adapters/identity";
|
|
@@ -193,6 +193,7 @@ export function parseRequest(
|
|
|
193
193
|
// Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a
|
|
194
194
|
// synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
|
|
195
195
|
let compactionRequest = false;
|
|
196
|
+
let decodedCheckpoint = false;
|
|
196
197
|
let contextCompactionBoundary = false;
|
|
197
198
|
let continuationConversationMessageIndex: number | undefined;
|
|
198
199
|
|
|
@@ -261,6 +262,7 @@ export function parseRequest(
|
|
|
261
262
|
if (inputIndex >= replayedInputPrefixLength) contextCompactionBoundary = true;
|
|
262
263
|
const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
|
|
263
264
|
if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
|
|
265
|
+
if (typeof encrypted === "string" && decodeCompactionSummary(encrypted) !== null) decodedCheckpoint = true;
|
|
264
266
|
pendingReasoning.length = 0;
|
|
265
267
|
messages.push({
|
|
266
268
|
role: "user",
|
|
@@ -586,6 +588,10 @@ export function parseRequest(
|
|
|
586
588
|
.map(t => loadedToolNames.has(namespacedToolName(t.namespace, t.name))
|
|
587
589
|
? { ...t, loadedFromToolSearch: true }
|
|
588
590
|
: t);
|
|
591
|
+
// #6764: the decoded checkpoint is ordinary user text on this route. Say in the system prompt
|
|
592
|
+
// what it is, so a retained earlier request that it reports as completed is not repeated.
|
|
593
|
+
// A compaction request is a summarizer turn and must not be told to continue.
|
|
594
|
+
if (decodedCheckpoint && !compactionRequest) systemPrompt.push(CHECKPOINT_CONTINUATION_NOTE);
|
|
589
595
|
const context: OcxContext = {
|
|
590
596
|
...(systemPrompt.length > 0 ? { systemPrompt } : {}),
|
|
591
597
|
messages,
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -47,6 +47,18 @@ import { googleVertexLocationConfigError } from "../providers/google-vertex-loca
|
|
|
47
47
|
import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
|
|
48
48
|
|
|
49
49
|
let _corsOrigin = "http://localhost:10100";
|
|
50
|
+
// Listener identity is process-local. Never infer this trust from forwarded headers: the
|
|
51
|
+
// request handler marks a Request only after resolving the actual Bun Server instance.
|
|
52
|
+
const trustedHubManagementRequests = new WeakSet<Request>();
|
|
53
|
+
|
|
54
|
+
export function markTrustedHubManagementRequest(req: Request): void {
|
|
55
|
+
trustedHubManagementRequests.add(req);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function inheritTrustedHubManagementRequest(source: Request, target: Request): void {
|
|
59
|
+
if (trustedHubManagementRequests.has(source)) trustedHubManagementRequests.add(target);
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
|
|
51
63
|
/** The proxy's own listening port. No admission check uses it: both loopback predicates key on hostname alone. */
|
|
52
64
|
export function configuredPort(): string {
|
|
@@ -144,6 +156,24 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string
|
|
|
144
156
|
return null;
|
|
145
157
|
}
|
|
146
158
|
}
|
|
159
|
+
if (trustedHubManagementRequests.has(req)) {
|
|
160
|
+
if (config.runtimeRole !== "hub") return null;
|
|
161
|
+
const canonical = canonicalHubManagementOrigin(config.hub?.managementPublicOrigin);
|
|
162
|
+
if (!canonical) return null;
|
|
163
|
+
try {
|
|
164
|
+
// The dedicated listener may receive HTTP from a local TLS terminator, so its URL
|
|
165
|
+
// scheme is not authoritative. Host must still name exactly the configured public
|
|
166
|
+
// authority; forwarded host/proto headers are deliberately ignored.
|
|
167
|
+
const requestAuthority = new URL(`https://${host}`);
|
|
168
|
+
if (requestAuthority.username || requestAuthority.password || requestAuthority.pathname !== "/"
|
|
169
|
+
|| requestAuthority.search || requestAuthority.hash || requestAuthority.host !== new URL(canonical).host) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return canonical;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
147
177
|
if (!isApiAuthRequired(config)) return null;
|
|
148
178
|
if (config.runtimeRole === "hub" && config.hub?.managementPublicOrigin) {
|
|
149
179
|
try {
|
|
@@ -437,6 +467,18 @@ export function resolveDataPlaneAdmissionSecret(
|
|
|
437
467
|
return null;
|
|
438
468
|
}
|
|
439
469
|
|
|
470
|
+
function canonicalHubManagementOrigin(value: unknown): string | null {
|
|
471
|
+
if (typeof value !== "string") return null;
|
|
472
|
+
try {
|
|
473
|
+
const parsed = new URL(value);
|
|
474
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/"
|
|
475
|
+
|| parsed.search || parsed.hash) return null;
|
|
476
|
+
return parsed.origin;
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
440
482
|
/** The principal an authenticated admission belongs to, or undefined for loopback. */
|
|
441
483
|
export function contextPrincipalIdOf(admission: DataPlaneAdmission | undefined): string | undefined {
|
|
442
484
|
return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials this installation is configured with, read when an upstream diagnostic is checked.
|
|
3
|
+
*
|
|
4
|
+
* A gateway can echo a credential it learned before this process ever sent it: an inactive pool
|
|
5
|
+
* key, another provider's key, an OAuth token from an earlier session. Learning only from sends
|
|
6
|
+
* would let such an echo be warned and finalized before the value is first dispatched, and a warning
|
|
7
|
+
* cannot be retracted. So the request log also refuses any diagnostic that repeats a configured
|
|
8
|
+
* value: every provider's apiKey and apiKeyPool keys (keychain and env references resolved), every
|
|
9
|
+
* configured header value, every credential-named provider field, and every token in the OAuth
|
|
10
|
+
* store. The set is rebuilt when the bytes of config.json or auth.json change, a stored keychain
|
|
11
|
+
* secret is replaced in this process, or a referenced environment variable changes.
|
|
12
|
+
*
|
|
13
|
+
* Freshness is decided by content, never by file metadata. Every check reads both stores and
|
|
14
|
+
* compares a digest of the bytes it read; the rebuild parses those same bytes. An inode/size/mtime
|
|
15
|
+
* key missed a same-length rewrite inside one timestamp tick (coarse on Windows) and any writer that
|
|
16
|
+
* preserves or backdates mtime, and then kept serving the previous, complete-looking view (#6923).
|
|
17
|
+
* The cost is two small reads per check; checks run only for retained upstream diagnostics.
|
|
18
|
+
*
|
|
19
|
+
* Reads are observe-only: no hardening, backup, repair or warning. A file that exists but cannot be
|
|
20
|
+
* read, parsed or recognized as the expected shape, or a keychain reference that cannot be resolved,
|
|
21
|
+
* makes coverage incomplete and callers fail closed; an incomplete set is retried after a short delay
|
|
22
|
+
* so a keychain that becomes available restores diagnostics. Values are held in memory only and
|
|
23
|
+
* never serialized or exposed.
|
|
24
|
+
*
|
|
25
|
+
* Not covered: keychain secrets are cached per process (`readKeychain` in
|
|
26
|
+
* src/providers/api-key-resolve.ts), so a secret replaced by another process is seen only after
|
|
27
|
+
* this process re-resolves it.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import { readFileSync, statSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { getConfigDir } from "../config/paths";
|
|
33
|
+
import { resolveEnvValue } from "../config/proxy-env";
|
|
34
|
+
import { isKeychainReference, providerKeyResolutionGeneration, resolveProviderApiKeyQuietly } from "../providers/api-key-resolve";
|
|
35
|
+
import { credentialComponents, credentialSetMatches } from "../lib/outbound-credential-registry";
|
|
36
|
+
import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
|
|
37
|
+
|
|
38
|
+
const CREDENTIAL_FIELD = /(?:key|token|secret|password|passwd|credential|cookie|authorization)$/i;
|
|
39
|
+
/** How long an incomplete set is trusted before its sources are read again. */
|
|
40
|
+
export const INCOMPLETE_RETRY_MS = 30_000;
|
|
41
|
+
|
|
42
|
+
export interface ConfiguredCredentials {
|
|
43
|
+
readonly complete: boolean;
|
|
44
|
+
matches(value: string): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Snapshot {
|
|
48
|
+
/** The only object handed out: cache metadata (including env values) stays private here. */
|
|
49
|
+
readonly view: ConfiguredCredentials;
|
|
50
|
+
readonly signature: string;
|
|
51
|
+
readonly envNames: readonly string[];
|
|
52
|
+
readonly envFingerprint: string;
|
|
53
|
+
readonly builtAt: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let cached: Snapshot | undefined;
|
|
57
|
+
|
|
58
|
+
/** One read of a store: its state for the signature and, for a readable file, the bytes read. */
|
|
59
|
+
interface StoreRead {
|
|
60
|
+
/** "absent", "not-a-file", "unreadable", or "sha256:<hex>" of exactly the bytes in `text`. */
|
|
61
|
+
readonly state: string;
|
|
62
|
+
readonly text?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readStore(path: string): StoreRead {
|
|
66
|
+
try {
|
|
67
|
+
if (!statSync(path).isFile()) return { state: "not-a-file" };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Only a store that is not there at all counts as intentionally absent.
|
|
70
|
+
return { state: (error as NodeJS.ErrnoException)?.code === "ENOENT" ? "absent" : "unreadable" };
|
|
71
|
+
}
|
|
72
|
+
let bytes: Buffer;
|
|
73
|
+
try {
|
|
74
|
+
bytes = readFileSync(path);
|
|
75
|
+
} catch {
|
|
76
|
+
// Present a moment ago, gone or unreadable now (a replacement in progress): fail closed.
|
|
77
|
+
return { state: "unreadable" };
|
|
78
|
+
}
|
|
79
|
+
return { state: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, text: bytes.toString("utf8") };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseStore(read: StoreRead): { ok: boolean; value?: unknown } {
|
|
83
|
+
if (read.state === "absent") return { ok: true };
|
|
84
|
+
if (read.text === undefined) return { ok: false };
|
|
85
|
+
try {
|
|
86
|
+
return { ok: true, value: JSON.parse(read.text.replace(/^\uFEFF/, "")) };
|
|
87
|
+
} catch {
|
|
88
|
+
return { ok: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function envFingerprint(names: readonly string[]): string {
|
|
97
|
+
return names.map(name => process.env[name] ?? "\u0000unset").join("\u0000");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Mirrors resolveEnvValue: a braced or bare dollar reference reads the environment. */
|
|
101
|
+
function envReference(raw: string): string | undefined {
|
|
102
|
+
if (!raw.startsWith("$")) return undefined;
|
|
103
|
+
return /^\$\{(\w+)\}$/.exec(raw)?.[1] ?? raw.slice(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function build(configRead: StoreRead, authRead: StoreRead, signature: string, now: number): Snapshot {
|
|
107
|
+
const values = new Set<string>();
|
|
108
|
+
const envNames = new Set<string>();
|
|
109
|
+
let complete = true;
|
|
110
|
+
const add = (raw: string | undefined) => {
|
|
111
|
+
if (raw) for (const part of credentialComponents(raw)) values.add(part);
|
|
112
|
+
};
|
|
113
|
+
const noteEnv = (raw: string) => {
|
|
114
|
+
const name = envReference(raw);
|
|
115
|
+
if (name) envNames.add(name);
|
|
116
|
+
};
|
|
117
|
+
const addReference = (raw: unknown) => {
|
|
118
|
+
if (typeof raw !== "string" || !raw) return;
|
|
119
|
+
noteEnv(raw);
|
|
120
|
+
const resolved = resolveProviderApiKeyQuietly(raw);
|
|
121
|
+
if (resolved === undefined && isKeychainReference(raw)) complete = false;
|
|
122
|
+
add(resolved);
|
|
123
|
+
};
|
|
124
|
+
const isCredentialField = (field: string) => CREDENTIAL_FIELD.test(field) || SENSITIVE_KEY_PATTERN.test(field);
|
|
125
|
+
const walkProvider = (node: unknown, depth: number) => {
|
|
126
|
+
if (depth > 8) return;
|
|
127
|
+
if (Array.isArray(node)) { for (const item of node) walkProvider(item, depth + 1); return; }
|
|
128
|
+
if (!isRecord(node)) return;
|
|
129
|
+
for (const [field, value] of Object.entries(node)) {
|
|
130
|
+
if (field === "headers" && isRecord(value)) {
|
|
131
|
+
for (const header of Object.values(value)) {
|
|
132
|
+
if (typeof header !== "string") continue;
|
|
133
|
+
noteEnv(header);
|
|
134
|
+
add(resolveEnvValue(header) ?? header);
|
|
135
|
+
}
|
|
136
|
+
} else if (typeof value === "string") {
|
|
137
|
+
if (isCredentialField(field)) addReference(value);
|
|
138
|
+
} else {
|
|
139
|
+
walkProvider(value, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const walkAuth = (node: unknown, depth: number) => {
|
|
144
|
+
if (depth > 8) return;
|
|
145
|
+
if (Array.isArray(node)) { for (const item of node) walkAuth(item, depth + 1); return; }
|
|
146
|
+
if (!isRecord(node)) return;
|
|
147
|
+
for (const [field, value] of Object.entries(node)) {
|
|
148
|
+
if (typeof value === "string") {
|
|
149
|
+
if (field === "access" || field === "refresh" || isCredentialField(field)) add(value);
|
|
150
|
+
} else {
|
|
151
|
+
walkAuth(value, depth + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const configSig = configRead.state;
|
|
157
|
+
const authSig = authRead.state;
|
|
158
|
+
const config = parseStore(configRead);
|
|
159
|
+
const auth = parseStore(authRead);
|
|
160
|
+
if (!config.ok || !auth.ok) complete = false;
|
|
161
|
+
// An existing file must have the shape its loader expects; anything else hides credentials.
|
|
162
|
+
if (configSig !== "absent" && config.ok && (!isRecord(config.value)
|
|
163
|
+
|| (config.value.providers !== undefined && !isRecord(config.value.providers)))) complete = false;
|
|
164
|
+
if (authSig !== "absent" && auth.ok && !isRecord(auth.value)) complete = false;
|
|
165
|
+
if (isRecord(config.value) && isRecord(config.value.providers)) walkProvider(config.value.providers, 0);
|
|
166
|
+
if (isRecord(auth.value)) walkAuth(auth.value, 0);
|
|
167
|
+
|
|
168
|
+
const names = [...envNames].sort();
|
|
169
|
+
const view: ConfiguredCredentials = Object.freeze({
|
|
170
|
+
complete,
|
|
171
|
+
matches: (value: string) => credentialSetMatches(values, candidate => values.has(candidate), value),
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
view,
|
|
175
|
+
signature,
|
|
176
|
+
envNames: names,
|
|
177
|
+
envFingerprint: envFingerprint(names),
|
|
178
|
+
builtAt: now,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The configured credential set for the active config home, rebuilt when any input changes. */
|
|
183
|
+
export function configuredCredentials(now = Date.now()): ConfiguredCredentials {
|
|
184
|
+
const dir = getConfigDir();
|
|
185
|
+
const configRead = readStore(join(dir, "config.json"));
|
|
186
|
+
const authRead = readStore(join(dir, "auth.json"));
|
|
187
|
+
const signature = `${configRead.state}|${authRead.state}|${providerKeyResolutionGeneration()}|${dir}`;
|
|
188
|
+
const stale = !cached
|
|
189
|
+
|| cached.signature !== signature
|
|
190
|
+
|| cached.envFingerprint !== envFingerprint(cached.envNames)
|
|
191
|
+
|| (!cached.view.complete && now - cached.builtAt >= INCOMPLETE_RETRY_MS);
|
|
192
|
+
if (stale) cached = build(configRead, authRead, signature, now);
|
|
193
|
+
return cached!.view;
|
|
194
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Home end of the Remote Link management relay (#6957).
|
|
3
|
+
*
|
|
4
|
+
* Reached only on the hub-link listener, after the request has passed its link-key admission
|
|
5
|
+
* (`resolveApiAuth` restricted to recorded link keys) and the shared route table in
|
|
6
|
+
* `src/link/management-routes.ts`. The management handler sees a fresh request built here: no
|
|
7
|
+
* caller header, cookie, Origin or credential crosses from the link, and the principal is
|
|
8
|
+
* absent, so no consent- or capability-gated branch can be reached. Only allowlisted reads run.
|
|
9
|
+
* They run as passive reads (`src/lib/passive-read.ts`): no discovery, config write or diagnostic (#6959).
|
|
10
|
+
*/
|
|
11
|
+
import type { OcxConfig } from "../../types";
|
|
12
|
+
import { handleManagementAPI, type ManagementApiDeps } from "../management-api";
|
|
13
|
+
import type { ManagementSessionControl } from "../management-auth";
|
|
14
|
+
import { runPassiveRead } from "../../lib/passive-read";
|
|
15
|
+
import { LINK_CODEX_SETTINGS_PATH, linkManagementRouteAllowed, linkManagementTarget } from "../../link/management-routes";
|
|
16
|
+
import { projectHomeCodexSettings } from "../../link/codex-settings";
|
|
17
|
+
import { packageVersion } from "../../lib/package-version";
|
|
18
|
+
|
|
19
|
+
const VERSION = packageVersion("0.0.0");
|
|
20
|
+
const KEPT_RESPONSE_HEADERS = ["content-type", "content-language", "etag", "last-modified"];
|
|
21
|
+
|
|
22
|
+
function notFound(): Response {
|
|
23
|
+
return Response.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleLinkManagementRequest(
|
|
27
|
+
req: Request,
|
|
28
|
+
url: URL,
|
|
29
|
+
config: OcxConfig,
|
|
30
|
+
deps: ManagementApiDeps,
|
|
31
|
+
sessionControl?: ManagementSessionControl,
|
|
32
|
+
): Promise<Response> {
|
|
33
|
+
if (!linkManagementRouteAllowed(url, req)) return notFound();
|
|
34
|
+
if (url.pathname === LINK_CODEX_SETTINGS_PATH) {
|
|
35
|
+
return Response.json(projectHomeCodexSettings(config, VERSION), { headers: { "cache-control": "no-store" } });
|
|
36
|
+
}
|
|
37
|
+
const target = linkManagementTarget(url);
|
|
38
|
+
if (!target) return notFound();
|
|
39
|
+
const local = new URL(`${target.pathname}${target.search}`, "http://127.0.0.1");
|
|
40
|
+
// The handlers branch on GET; an admitted HEAD runs as GET and its body is dropped below.
|
|
41
|
+
const inner = new Request(local.href, {
|
|
42
|
+
method: "GET",
|
|
43
|
+
headers: { accept: "application/json", host: "127.0.0.1" },
|
|
44
|
+
});
|
|
45
|
+
const response = await runPassiveRead(() =>
|
|
46
|
+
handleManagementAPI(inner, local, config, deps, undefined, sessionControl, { trustedLoopback: false }));
|
|
47
|
+
if (!response) return notFound();
|
|
48
|
+
const headers = new Headers({ "cache-control": "no-store" });
|
|
49
|
+
for (const name of KEPT_RESPONSE_HEADERS) {
|
|
50
|
+
const value = response.headers.get(name);
|
|
51
|
+
if (value) headers.set(name, value);
|
|
52
|
+
}
|
|
53
|
+
if (req.method === "HEAD") {
|
|
54
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
55
|
+
return new Response(null, { status: response.status, headers });
|
|
56
|
+
}
|
|
57
|
+
return new Response(response.body, { status: response.status, headers });
|
|
58
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type LinkListenerStatus,
|
|
15
15
|
} from "./link-listener";
|
|
16
16
|
import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor";
|
|
17
|
+
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
17
18
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
18
19
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
19
20
|
export { LINK_INGRESS_HOSTNAME } from "./link-listener";
|
|
@@ -77,7 +78,10 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
77
78
|
if (claudeIntercept.ownsListener(server)) return "claude-intercept";
|
|
78
79
|
return undefined;
|
|
79
80
|
},
|
|
80
|
-
|
|
81
|
+
// The Home's link listener admits the data plane plus the read-only management relay
|
|
82
|
+
// (#6957). The Child's own ingress keeps the data-only table, so a local caller on the Child
|
|
83
|
+
// cannot reach the Home's management reads without the Child's dashboard session.
|
|
84
|
+
linkRouteAllowed: (url, req) => linkRouteAllowed(url, req) || linkManagementRouteAllowed(url, req),
|
|
81
85
|
linkAdmissionKeyIds: () => linkListener.linkAdmissionKeyIds(),
|
|
82
86
|
onAuthenticatedCatalog: listener => linkListener.onAuthenticatedCatalog(listener),
|
|
83
87
|
notifyAuthenticatedCatalog: apiKeyId => linkListener.notifyAuthenticatedCatalog(apiKeyId),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
2
|
import type { StartServerDeps } from "./startup-warnings";
|
|
3
|
+
import { isLinkManagementPath } from "../../link/management-routes";
|
|
3
4
|
import {
|
|
4
5
|
GUI_PAIRING_EXCHANGE_BODY_LIMIT,
|
|
5
6
|
REMOTE_WORKSPACE_PAIRING_BODY_LIMIT,
|
|
@@ -96,6 +97,7 @@ import {
|
|
|
96
97
|
isAllowedManagementOrigin,
|
|
97
98
|
isApiAuthRequired,
|
|
98
99
|
isLoopbackHostname,
|
|
100
|
+
markTrustedHubManagementRequest,
|
|
99
101
|
jsonResponse,
|
|
100
102
|
admissionFields,
|
|
101
103
|
resolveApiAuth,
|
|
@@ -313,6 +315,11 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
313
315
|
maxRequestBodySize: inboundBodyLimitBytes,
|
|
314
316
|
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
315
317
|
const ingress = ingressForServer(requestServer);
|
|
318
|
+
// Bind management-origin trust to the actual Bun listener object resolved above.
|
|
319
|
+
// Neither Host nor any X-Forwarded-* header can manufacture this process-local mark.
|
|
320
|
+
if (ingress === "hub-management" && config.runtimeRole === "hub") {
|
|
321
|
+
markTrustedHubManagementRequest(req);
|
|
322
|
+
}
|
|
316
323
|
const requestUrl = codexCompatibleUrl(req.url);
|
|
317
324
|
const linkPolicyView = ingress === "hub-link" ? linkPolicy() : undefined;
|
|
318
325
|
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
@@ -367,6 +374,12 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
367
374
|
if (requestUrl.pathname === "/v1/catalog" && requestUrl.search === ""
|
|
368
375
|
&& (req.method === "GET" || req.method === "HEAD")
|
|
369
376
|
&& admission.kind === "configured") onAuthenticatedCatalog(admission.keyId);
|
|
377
|
+
// #6957: the Child's read-only view of this Home. Admitted by the link key above and by
|
|
378
|
+
// the shared table in src/link/management-routes.ts; never reachable on another ingress.
|
|
379
|
+
if (isLinkManagementPath(requestUrl.pathname)) {
|
|
380
|
+
const { handleLinkManagementRequest } = await import("./link-management");
|
|
381
|
+
return withCors(await handleLinkManagementRequest(req, requestUrl, config, requestManagementApiDeps, managementSessionControl), req, policy);
|
|
382
|
+
}
|
|
370
383
|
}
|
|
371
384
|
const url = requestUrl;
|
|
372
385
|
const admissionOptions = { linkIngress: policy.linkIngress?.allowedKeyIds };
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "../storage/policy-job";
|
|
9
9
|
import { abortRestoreTrashJobAsync } from "../storage/restore-job";
|
|
10
10
|
import { stopStorageCleanupScheduler } from "../storage/policy-scheduler";
|
|
11
|
-
import { runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
|
|
11
|
+
import { runDrainStartHooks, runOptionalShutdownHooks } from "../lib/optional-shutdown-hooks";
|
|
12
12
|
import { stopStateStoreSweeper } from "../lib/state-store-sweeper";
|
|
13
13
|
import {
|
|
14
14
|
cancelQueuedStorageWorkerSpawns,
|
|
@@ -499,6 +499,7 @@ export async function drainAndShutdown(
|
|
|
499
499
|
// ordinary in-flight turns. A stuck scoped owner must not pin shutdown forever.
|
|
500
500
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
501
501
|
beginShutdownDrain();
|
|
502
|
+
runDrainStartHooks();
|
|
502
503
|
const temporaryDrainsSettled = await waitForTemporaryDrainsUntil(deadline);
|
|
503
504
|
if (!temporaryDrainsSettled) {
|
|
504
505
|
console.warn("Temporary drain lease did not settle before the shutdown deadline; forcing shutdown");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ensureManagementClaudeIntercept, interceptStartRefusal, interceptStatus } from "./claude-intercept-routes";
|
|
2
2
|
import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state";
|
|
3
3
|
import { captureDesktopAppliedMarker, commitDesktopAppliedMarker } from "../../claude/desktop-applied-marker";
|
|
4
|
+
import { isPassiveRead } from "../../lib/passive-read";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { readFileSync } from "node:fs";
|
|
6
7
|
import { dirname } from "node:path";
|
|
@@ -230,6 +231,32 @@ async function injectionModelOptions(config: OcxConfig, models: readonly Catalog
|
|
|
230
231
|
return [...nativeModels, ...routedModels];
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The GET /api/injection-model body. `routed` is the discovered routed catalog, or `null` in a
|
|
236
|
+
* passive read (a Remote Link Child's, #6959). That body lists the native
|
|
237
|
+
* options and the current pick, which is all a read-only Child shows.
|
|
238
|
+
*/
|
|
239
|
+
export async function injectionModelView(config: OcxConfig, routed: readonly CatalogModel[] | null) {
|
|
240
|
+
const { CODEX_REASONING_LEVELS } = await import("../../reasoning-effort");
|
|
241
|
+
const available = await injectionModelOptions(config, routed ?? []);
|
|
242
|
+
const picked = config.injectionModel;
|
|
243
|
+
if (routed === null && picked && !available.some(option => option.namespaced === picked)) {
|
|
244
|
+
const slash = picked.indexOf("/");
|
|
245
|
+
available.push(slash > 0
|
|
246
|
+
? { provider: picked.slice(0, slash), model: picked.slice(slash + 1), namespaced: picked }
|
|
247
|
+
: { provider: "openai", model: picked, namespaced: picked });
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
251
|
+
syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
|
|
252
|
+
model: config.injectionModel ?? null,
|
|
253
|
+
effort: config.injectionEffort ?? null,
|
|
254
|
+
prompt: config.injectionPrompt ?? null,
|
|
255
|
+
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
256
|
+
available,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
233
260
|
export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
234
261
|
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
235
262
|
|
|
@@ -620,16 +647,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
620
647
|
// effort the prompt tells the agent to pass to spawn_agent. GET returns the current
|
|
621
648
|
// picks + available models/efforts; PUT sets or clears them.
|
|
622
649
|
if (url.pathname === "/api/injection-model" && req.method === "GET") {
|
|
623
|
-
|
|
624
|
-
return jsonResponse({
|
|
625
|
-
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
626
|
-
syncCodexSubagentDefaults: subagentDefaultSyncEffective(config),
|
|
627
|
-
model: config.injectionModel ?? null,
|
|
628
|
-
effort: config.injectionEffort ?? null,
|
|
629
|
-
prompt: config.injectionPrompt ?? null,
|
|
630
|
-
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
631
|
-
available: await injectionModelOptions(config, await fetchAllModels(config)),
|
|
632
|
-
});
|
|
650
|
+
return jsonResponse(await injectionModelView(config, isPassiveRead() ? null : await fetchAllModels(config)));
|
|
633
651
|
}
|
|
634
652
|
// Read-only: sizes the described delegated work and proposes a model and effort from the
|
|
635
653
|
// same options GET offers. The page applies an accepted proposal through the PUT below.
|
|
@@ -19,6 +19,7 @@ import { captureAnthropicPhysicalSendOwnership, anthropicPhysicalSendOwnershipIs
|
|
|
19
19
|
import { anthropicCooldownRecoveryFor } from "../../providers/quota/anthropic-cooldown-recovery";
|
|
20
20
|
import { captureProviderAccountQuotaEpoch } from "../../providers/quota/account-cache";
|
|
21
21
|
import { captureOAuthAccountSelection, getAccountSet, getAccountCredentialWithStatus, listAccounts } from "../../oauth/store";
|
|
22
|
+
import { admitAnthropicAccountAfterReset, type AnthropicResetAdmissionEvidence } from "../../providers/anthropic-reset-admission";
|
|
22
23
|
import {
|
|
23
24
|
ANTHROPIC_RESET_GRANT_ID_RE,
|
|
24
25
|
AnthropicResetGrantError,
|
|
@@ -74,6 +75,8 @@ export interface AnthropicResetGrantRouteDeps {
|
|
|
74
75
|
listAccountIds: () => string[];
|
|
75
76
|
activeAccountId: () => string | null;
|
|
76
77
|
accessTokenFor: (accountId: string) => Promise<string>;
|
|
78
|
+
/** Lift local admission after a journaled reset, bounded by its journal evidence. Tests omit it. */
|
|
79
|
+
admitReset?: (accountId: string, evidence: AnthropicResetAdmissionEvidence) => Promise<void>;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId): AnthropicResetGrantRouteDeps | null {
|
|
@@ -109,6 +112,7 @@ function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId):
|
|
|
109
112
|
if (!targetCurrent() || !owner) throw new Error("reset owner changed");
|
|
110
113
|
return snapshot.accessToken;
|
|
111
114
|
},
|
|
115
|
+
admitReset: (accountId, evidence) => admitAnthropicAccountAfterReset(provider, accountId, evidence),
|
|
112
116
|
};
|
|
113
117
|
}
|
|
114
118
|
|
|
@@ -209,6 +213,16 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
|
|
|
209
213
|
}
|
|
210
214
|
if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
|
|
211
215
|
const ledger = { journalPath: deps.journalPath, now: deps.now?.() };
|
|
216
|
+
// Retry-after cooldowns survive a usage probe. A journaled reset, including a
|
|
217
|
+
// replay after the process died before admission, has to lift this account,
|
|
218
|
+
// but only refusals recorded before the claim was journaled and only for the
|
|
219
|
+
// windows the journal says were cleared. A failed reread must not turn a
|
|
220
|
+
// spent grant into an error.
|
|
221
|
+
const admitJournaledReset = async (code: string, evidence: AnthropicResetAdmissionEvidence): Promise<void> => {
|
|
222
|
+
if (code !== "reset" || deps.isCurrent?.() === false) return;
|
|
223
|
+
try { await deps.admitReset?.(accountId, evidence); }
|
|
224
|
+
catch { /* The grant is already spent. */ }
|
|
225
|
+
};
|
|
212
226
|
|
|
213
227
|
// A new operation must pass the spend gate on a fresh read. A same-id retry of
|
|
214
228
|
// an open record skips it: that attempt was already gated, and upstream
|
|
@@ -242,6 +256,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
|
|
|
242
256
|
}
|
|
243
257
|
switch (begin.kind) {
|
|
244
258
|
case "replay":
|
|
259
|
+
await admitJournaledReset(begin.code, { claimedAt: begin.claimedAt, cleared: begin.cleared });
|
|
245
260
|
return jsonResponse(
|
|
246
261
|
{ provider, code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
|
|
247
262
|
200, ctx.req, ctx.config,
|
|
@@ -268,7 +283,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
|
|
|
268
283
|
let settled;
|
|
269
284
|
try {
|
|
270
285
|
settled = settleAnthropicResetOperation(
|
|
271
|
-
{ operationId, code: answer.code, resetsLeft: answer.resetsLeft },
|
|
286
|
+
{ operationId, code: answer.code, resetsLeft: answer.resetsLeft, cleared: answer.cleared },
|
|
272
287
|
{ journalPath: deps.journalPath, now: deps.now?.() },
|
|
273
288
|
);
|
|
274
289
|
} catch {
|
|
@@ -278,6 +293,7 @@ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGr
|
|
|
278
293
|
// Report what the journal holds. An overlapping same-id attempt may have
|
|
279
294
|
// settled first; its answer is the canonical one.
|
|
280
295
|
const replayed = settled.code !== answer.code || settled.resetsLeft !== answer.resetsLeft;
|
|
296
|
+
await admitJournaledReset(settled.code, { claimedAt: settled.claimedAt, cleared: settled.cleared });
|
|
281
297
|
return jsonResponse(
|
|
282
298
|
{
|
|
283
299
|
provider,
|
|
@@ -96,12 +96,18 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis
|
|
|
96
96
|
return jsonResponse({ error: "invalid JSON body" }, 400, req, config);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// An omitted model leaves the role's model pin (or its inheritance) alone, an omitted effort
|
|
100
|
+
// keeps the effort, and an effort of null clears it. A body with neither changes nothing, which
|
|
101
|
+
// is a client mistake rather than a save, so it is refused before any role file is read.
|
|
102
|
+
if (body?.model === undefined && body?.effort === undefined) {
|
|
103
|
+
return jsonResponse({ error: "send a model, an effort, or both", code: "empty_update" }, 400, req, config);
|
|
104
|
+
}
|
|
105
|
+
let model: string | undefined;
|
|
106
|
+
let effort: string | null | undefined;
|
|
101
107
|
let toml: { status: "written" | "unchanged" };
|
|
102
108
|
try {
|
|
103
|
-
model = roles.validateAgentRoleModel(body
|
|
104
|
-
effort = body?.effort === undefined
|
|
109
|
+
model = body?.model === undefined ? undefined : roles.validateAgentRoleModel(body.model);
|
|
110
|
+
effort = body?.effort === undefined ? undefined : body.effort === null ? null : roles.validateAgentRoleEffort(body.effort);
|
|
105
111
|
toml = roles.writeCodexAgentRoleModel(role, model, codexHome, effort);
|
|
106
112
|
} catch (error) {
|
|
107
113
|
if (error instanceof roles.AgentRoleModelError) {
|
|
@@ -115,10 +121,17 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis
|
|
|
115
121
|
// The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow.
|
|
116
122
|
let omoStatus: ReturnType<typeof omo.writeOmoRoleModel> | "write_failed";
|
|
117
123
|
try {
|
|
118
|
-
const reasoning = effort === undefined ? undefined : omo.omoReasoningFor(effort);
|
|
124
|
+
const reasoning = effort === undefined ? undefined : effort === null ? null : omo.omoReasoningFor(effort);
|
|
119
125
|
omoStatus = omo.writeOmoRoleModel(role, model, omo.omoJsoncPath(), reasoning);
|
|
120
126
|
} catch {
|
|
121
127
|
omoStatus = "write_failed";
|
|
122
128
|
}
|
|
123
|
-
return jsonResponse({
|
|
129
|
+
return jsonResponse({
|
|
130
|
+
ok: true,
|
|
131
|
+
role,
|
|
132
|
+
...(model !== undefined ? { model } : {}),
|
|
133
|
+
...(effort !== undefined ? { effort } : {}),
|
|
134
|
+
toml,
|
|
135
|
+
omoJsonc: { status: omoStatus },
|
|
136
|
+
}, 200, req, config);
|
|
124
137
|
}
|