@bitkyc08/opencodex 2.78.0 → 2.79.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-BOUa-ek3.js +54 -0
- package/gui/dist/assets/App-CSJvmjNs.css +1 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-DwHNDF4s.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/devin.ts +22 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/intercept/local-ca-files.ts +265 -0
- package/src/claude/intercept/local-ca-windows.ts +73 -0
- package/src/claude/intercept/local-ca.ts +12 -24
- package/src/claude/intercept/picker-ca-persistence.ts +106 -0
- package/src/claude/intercept/picker-ca-startup.ts +16 -0
- package/src/claude/intercept/picker-ca-store.ts +70 -0
- package/src/claude/intercept/picker-ca.ts +43 -9
- package/src/claude/intercept/picker-runtime.ts +14 -4
- package/src/claude/intercept/runtime.ts +32 -23
- package/src/cli/ensure-readiness.ts +48 -0
- package/src/cli/gui-pair-client.ts +15 -7
- package/src/cli/gui.ts +4 -1
- package/src/cli/hub.ts +1 -0
- package/src/cli/index.ts +10 -11
- package/src/cli/status.ts +2 -2
- package/src/cli/update-restart-child.ts +4 -1
- package/src/codex/account-credit-use.ts +8 -2
- package/src/codex/auth-api/account-pause-group.ts +113 -0
- package/src/codex/auth-api/routes.ts +18 -12
- package/src/codex/auth-context.ts +45 -3
- package/src/codex/autostart-health.ts +10 -0
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/generated/compatibility-version.json +106 -46
- package/src/lib/gui-pair-capability.ts +1 -1
- package/src/lib/gui-pair-intent.ts +170 -0
- package/src/lib/windows-owner-acl.ts +143 -0
- package/src/providers/openai-sidecar-credit.ts +26 -0
- package/src/providers/openai-sidecar.ts +35 -0
- package/src/server/claude-messages.ts +1 -0
- package/src/server/gui-pair-delivery.ts +23 -0
- package/src/server/index/serve-options.ts +5 -8
- package/src/server/live-native-calls.ts +94 -0
- package/src/server/live.ts +28 -5
- package/src/server/local-management-read-client.ts +15 -11
- package/src/server/management/oauth-account-routes.ts +15 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/shared.ts +9 -3
- package/src/server/messages-native-scope.ts +57 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/compact.ts +17 -2
- package/src/server/responses/core-auth.ts +18 -1
- package/src/server/responses/core-codex-account.ts +18 -8
- package/src/server/responses/non-replayable-error.ts +48 -0
- package/src/server/responses/passthrough-delivery.ts +5 -1
- package/src/server/responses/passthrough-dispatch.ts +21 -19
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/sidecar-execution.ts +1 -0
- package/src/server/search.ts +4 -0
- package/src/server/startup-health-cache.ts +2 -6
- package/src/vision/describe.ts +17 -10
- package/src/vision/index.ts +1 -0
- package/src/web-search/executor.ts +17 -10
- package/src/web-search/format-result.ts +304 -20
- package/src/web-search/loop.ts +2 -1
- package/src/web-search/passthrough-bridge.ts +1 -0
- package/src/web-search/run-turn-loop.ts +1 -1
- package/gui/dist/assets/App-BtfG5id-.js +0 -54
- package/gui/dist/assets/App-lCIslZ1L.css +0 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";
|
|
2
|
+
|
|
3
|
+
export interface WindowsPrivateEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
directory: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface WindowsOwnerAclResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
timedOut: boolean;
|
|
10
|
+
stdout: string | Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
export type WindowsOwnerAclRunner = (entries: readonly WindowsPrivateEntry[], timeoutMs: number) => WindowsOwnerAclResult;
|
|
13
|
+
|
|
14
|
+
export const WINDOWS_OWNER_ACL_TIMEOUT_MS = 5_000;
|
|
15
|
+
const SID = /^S-1-\d+(?:-\d+)+$/i;
|
|
16
|
+
const ADMINISTRATORS = "S-1-5-32-544";
|
|
17
|
+
const PRIVILEGED_GRANTEES = new Set(["S-1-5-18", ADMINISTRATORS]); // LocalSystem, BUILTIN\Administrators
|
|
18
|
+
const PRIVATE_DACL_FLAGS = 0x1004; // DiscretionaryAclPresent | DiscretionaryAclProtected
|
|
19
|
+
const FULL_CONTROL = 2032127;
|
|
20
|
+
|
|
21
|
+
// This script is constant. Literal paths are environment data, never PowerShell source.
|
|
22
|
+
// Read the descriptor through .NET (Owner, Group and Access, as Get-Acl does). Get-Acl is a module-autoloaded
|
|
23
|
+
// cmdlet: PowerShell 5.1 resolves it through the per-profile module analysis cache, and with a fresh or
|
|
24
|
+
// redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can outlast the inspection budget
|
|
25
|
+
// (hosted Windows runners: the pairing CLI timed out at 30 s). Every call below is a .NET member, so nothing
|
|
26
|
+
// autoloads and no module on PSModulePath can shadow the read. GetAccessRules projects away unsupported ACE details, so the
|
|
27
|
+
// ACEs are read from the serialized descriptor instead. .NET canonicalizes it first: compatible entries for the
|
|
28
|
+
// same principal merge and entries that grant nothing drop, but another principal's entry is never folded into
|
|
29
|
+
// the user's, and the owner is untouched. The policy is therefore the effective DACL, not the on-disk bytes.
|
|
30
|
+
const ACL_SCRIPT = String.raw`
|
|
31
|
+
$ErrorActionPreference='Stop'
|
|
32
|
+
try {
|
|
33
|
+
# ASCII has no preamble, so a UTF-8 console code page cannot prefix the protocol with a BOM.
|
|
34
|
+
try { [Console]::OutputEncoding=[System.Text.Encoding]::ASCII } catch { $encodingUnchanged=$true }
|
|
35
|
+
$countText=[Environment]::GetEnvironmentVariable('OCX_ACL_COUNT')
|
|
36
|
+
if ($countText -notmatch '^[1-9][0-9]*$') { throw 'count' }
|
|
37
|
+
$count=[int]$countText
|
|
38
|
+
$identity=[System.Security.Principal.WindowsIdentity]::GetCurrent()
|
|
39
|
+
$principal=[System.Security.Principal.WindowsPrincipal]::new($identity)
|
|
40
|
+
$administrators=[System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
|
41
|
+
$enabled=$principal.IsInRole($administrators)
|
|
42
|
+
[Console]::WriteLine(('U|{0}|{1}|{2}' -f $identity.User.Value,$identity.Owner.Value,$enabled))
|
|
43
|
+
$sections=[System.Security.AccessControl.AccessControlSections]'Owner,Group,Access'
|
|
44
|
+
for ($i=0; $i -lt $count; $i++) {
|
|
45
|
+
$path=[Environment]::GetEnvironmentVariable(('OCX_ACL_PATH_{0}' -f $i))
|
|
46
|
+
if ([string]::IsNullOrEmpty($path)) { throw 'path' }
|
|
47
|
+
if ([System.IO.Directory]::Exists($path)) { $acl=[System.Security.AccessControl.DirectorySecurity]::new($path, $sections) }
|
|
48
|
+
else { $acl=[System.Security.AccessControl.FileSecurity]::new($path, $sections) }
|
|
49
|
+
$acls=@($acl)
|
|
50
|
+
if ($acls.Count -ne 1) { throw 'acl' }
|
|
51
|
+
$bytes=$acls[0].GetSecurityDescriptorBinaryForm()
|
|
52
|
+
$raw=[System.Security.AccessControl.RawSecurityDescriptor]::new([byte[]]$bytes, 0)
|
|
53
|
+
$aceCount=-1
|
|
54
|
+
if ($null -ne $raw.DiscretionaryAcl) { $aceCount=$raw.DiscretionaryAcl.Count }
|
|
55
|
+
[Console]::WriteLine(('E|{0}|{1}|{2}|{3}' -f $i,$raw.Owner.Value,[int]$raw.ControlFlags,$aceCount))
|
|
56
|
+
if ($null -ne $raw.DiscretionaryAcl) {
|
|
57
|
+
foreach ($ace in $raw.DiscretionaryAcl) {
|
|
58
|
+
if ($ace -is [System.Security.AccessControl.CommonAce]) {
|
|
59
|
+
[Console]::WriteLine(('A|{0}|{1}|{2}|{3}|{4}|{5}' -f $i,[int]$ace.AceType,[int]$ace.AceFlags,[int]$ace.AccessMask,$ace.SecurityIdentifier.Value,$ace.IsCallback))
|
|
60
|
+
} else {
|
|
61
|
+
[Console]::WriteLine(('A|{0}|{1}|X' -f $i,[int]$ace.AceType))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
[Console]::WriteLine('END')
|
|
67
|
+
exit 0
|
|
68
|
+
} catch { exit 3 }
|
|
69
|
+
`;
|
|
70
|
+
const ENCODED_ACL_SCRIPT = Buffer.from(ACL_SCRIPT, "utf16le").toString("base64");
|
|
71
|
+
|
|
72
|
+
function defaultWindowsOwnerAclRunner(entries: readonly WindowsPrivateEntry[], timeoutMs: number): WindowsOwnerAclResult {
|
|
73
|
+
const env: Record<string, string | undefined> = { ...process.env, OCX_ACL_COUNT: String(entries.length) };
|
|
74
|
+
for (let i = 0; i < entries.length; i++) env[`OCX_ACL_PATH_${i}`] = entries[i]!.path;
|
|
75
|
+
const result = Bun.spawnSync([
|
|
76
|
+
resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive",
|
|
77
|
+
"-ExecutionPolicy", "Bypass", "-EncodedCommand", ENCODED_ACL_SCRIPT,
|
|
78
|
+
], {
|
|
79
|
+
env, stdin: "ignore", stdout: "pipe", stderr: "ignore",
|
|
80
|
+
timeout: timeoutMs, windowsHide: true,
|
|
81
|
+
});
|
|
82
|
+
return { success: result.success, timedOut: result.exitedDueToTimeout ?? false, stdout: result.stdout ?? new Uint8Array() };
|
|
83
|
+
}
|
|
84
|
+
let ownerAclRunner: WindowsOwnerAclRunner = defaultWindowsOwnerAclRunner;
|
|
85
|
+
/** Test-only access to the real subprocess runner, for native diagnostics. */
|
|
86
|
+
export const windowsOwnerAclDefaultRunnerForTests: WindowsOwnerAclRunner = defaultWindowsOwnerAclRunner;
|
|
87
|
+
|
|
88
|
+
export function setWindowsOwnerAclRunnerForTests(runner: WindowsOwnerAclRunner | null): void {
|
|
89
|
+
ownerAclRunner = runner ?? defaultWindowsOwnerAclRunner;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Pure, strict line-protocol parser and owner/effective-DACL policy. No account names or SDDL. */
|
|
93
|
+
export function windowsPrivateEntriesAclMatches(stdout: string | Uint8Array, entries: readonly WindowsPrivateEntry[]): boolean {
|
|
94
|
+
if (entries.length === 0) return false;
|
|
95
|
+
// The script emits ASCII only. Reject BOMs, NULs, non-ASCII bytes and replacement decoding.
|
|
96
|
+
if (typeof stdout !== "string" && stdout.some(byte => byte > 127)) return false;
|
|
97
|
+
const text = typeof stdout === "string" ? stdout : Buffer.from(stdout).toString("ascii");
|
|
98
|
+
if (/[^\x20-\x7e\r\n]/.test(text)) return false;
|
|
99
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
100
|
+
if (normalized.includes("\r")) return false;
|
|
101
|
+
const lines = (normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized).split("\n");
|
|
102
|
+
if (lines.at(-1) !== "END") return false;
|
|
103
|
+
const token = lines[0]!.split("|");
|
|
104
|
+
if (token.length !== 4 || token[0] !== "U" || !SID.test(token[1]!) || !SID.test(token[2]!)
|
|
105
|
+
|| (token[3] !== "True" && token[3] !== "False")) return false;
|
|
106
|
+
const user = token[1]!.toUpperCase();
|
|
107
|
+
const acceptsAdministrators = token[2]!.toUpperCase() === ADMINISTRATORS && token[3] === "True";
|
|
108
|
+
let at = 1;
|
|
109
|
+
for (let i = 0; i < entries.length; i++) {
|
|
110
|
+
const entry = (lines[at++] ?? "").split("|");
|
|
111
|
+
if (entry.length !== 5 || entry[0] !== "E" || entry[1] !== String(i) || !SID.test(entry[2]!)
|
|
112
|
+
|| !/^(0|[1-9]\d{0,4})$/.test(entry[3]!) || !/^[1-3]$/.test(entry[4]!)) return false;
|
|
113
|
+
const flags = Number(entry[3]);
|
|
114
|
+
if (flags > 0xffff || (flags & PRIVATE_DACL_FLAGS) !== PRIVATE_DACL_FLAGS) return false;
|
|
115
|
+
const owner = entry[2]!.toUpperCase();
|
|
116
|
+
if (owner !== user && !(owner === ADMINISTRATORS && acceptsAdministrators)) return false;
|
|
117
|
+
// The serving account needs its own Full Control entry. SYSTEM and Administrators may also hold plain
|
|
118
|
+
// allow entries: LocalSystem and an elevated administrator can already take ownership of any object, and a
|
|
119
|
+
// filtered administrator token holds Administrators only as deny-only, so the allow entry grants it nothing.
|
|
120
|
+
const grantees = new Set<string>();
|
|
121
|
+
for (let k = Number(entry[4]); k > 0; k--) {
|
|
122
|
+
const ace = (lines[at++] ?? "").split("|");
|
|
123
|
+
if (ace.length !== 7 || ace[0] !== "A" || ace[1] !== String(i) || ace[2] !== "0"
|
|
124
|
+
|| ace[3] !== (entries[i]!.directory ? "3" : "0") || !/^[1-9]\d{0,9}$/.test(ace[4]!)
|
|
125
|
+
|| !SID.test(ace[5]!) || ace[6] !== "False") return false;
|
|
126
|
+
const grantee = ace[5]!.toUpperCase();
|
|
127
|
+
if (grantees.has(grantee)) return false;
|
|
128
|
+
grantees.add(grantee);
|
|
129
|
+
if (grantee === user ? ace[4] !== String(FULL_CONTROL) : !PRIVILEGED_GRANTEES.has(grantee)) return false;
|
|
130
|
+
}
|
|
131
|
+
if (!grantees.has(user)) return false;
|
|
132
|
+
}
|
|
133
|
+
return at === lines.length - 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Fresh, read-only verification; runner failures never disclose paths, SIDs or ACL output. */
|
|
137
|
+
export function verifyWindowsPrivateEntries(entries: readonly WindowsPrivateEntry[], timeoutMs: number): boolean {
|
|
138
|
+
if (entries.length === 0 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return false;
|
|
139
|
+
try {
|
|
140
|
+
const result = ownerAclRunner(entries, Math.max(1, Math.min(WINDOWS_OWNER_ACL_TIMEOUT_MS, Math.floor(timeoutMs))));
|
|
141
|
+
return result.success && !result.timedOut && windowsPrivateEntriesAclMatches(result.stdout, entries);
|
|
142
|
+
} catch { return false; }
|
|
143
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CodexPoolAccountCreditsOffError,
|
|
3
|
+
releaseCodexAuthContextProbeLease,
|
|
4
|
+
unwrapUpstreamRetryEvidenceError,
|
|
5
|
+
type CodexAuthContext,
|
|
6
|
+
type CodexAuthPolicyConfig,
|
|
7
|
+
} from "../codex/auth-context";
|
|
8
|
+
import { poolContextCreditHoldResetAt } from "../codex/pool-credit-policy";
|
|
9
|
+
|
|
10
|
+
export function createOpenAiSidecarCreditGuard(
|
|
11
|
+
context: CodexAuthContext,
|
|
12
|
+
policy: CodexAuthPolicyConfig,
|
|
13
|
+
): (() => void) | undefined {
|
|
14
|
+
if (context.kind !== "pool") return undefined;
|
|
15
|
+
return () => {
|
|
16
|
+
const resetAt = poolContextCreditHoldResetAt(context, policy);
|
|
17
|
+
if (resetAt === undefined) return;
|
|
18
|
+
releaseCodexAuthContextProbeLease(context);
|
|
19
|
+
throw new CodexPoolAccountCreditsOffError(context.accountId, resetAt);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function openAiSidecarCreditRefusal(error: unknown): CodexPoolAccountCreditsOffError | undefined {
|
|
24
|
+
const cause = unwrapUpstreamRetryEvidenceError(error);
|
|
25
|
+
return cause instanceof CodexPoolAccountCreditsOffError ? cause : undefined;
|
|
26
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolveProviderApiKey } from "./key-store";
|
|
2
|
+
import { createOpenAiSidecarCreditGuard } from "./openai-sidecar-credit";
|
|
2
3
|
import {
|
|
3
4
|
CodexPoolAuthenticationError,
|
|
4
5
|
headersForCodexAuthContext,
|
|
@@ -31,6 +32,7 @@ export interface OpenAiForwardSidecarCandidate {
|
|
|
31
32
|
export interface ResolvedOpenAiForwardSidecar extends OpenAiForwardSidecarCandidate {
|
|
32
33
|
authContext: CodexAuthContext;
|
|
33
34
|
headers: Headers;
|
|
35
|
+
beforeDispatch?: () => void;
|
|
34
36
|
recordOutcome?: (outcome: CodexUpstreamOutcome) => void;
|
|
35
37
|
/** Hand back an acquired recovery probe when no sidecar request reached upstream. */
|
|
36
38
|
releaseProbeLease?: () => void;
|
|
@@ -123,6 +125,37 @@ function directSidecarHeaders(
|
|
|
123
125
|
return selected;
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The caller's own ChatGPT credential for a call the caller created itself.
|
|
130
|
+
*
|
|
131
|
+
* A V3 `existingCall` sideband join attaches to a WebRTC call that ChatGPT voice or Codex Desktop
|
|
132
|
+
* created with the caller's login, so only that login can join it; a Pool-selected account is a
|
|
133
|
+
* different ChatGPT account and the upstream refuses the handshake. This hands the join the same
|
|
134
|
+
* caller-owned headers Codex Direct mode forwards: an explicit bearer whose token claim matches
|
|
135
|
+
* its `chatgpt-account-id`, never the proxy admission secret, materialized through the same main
|
|
136
|
+
* policy (hard lock, credits), whose refusals surface as `CodexAccountCooldownError`.
|
|
137
|
+
* Returns undefined when the caller presents no such credential.
|
|
138
|
+
*/
|
|
139
|
+
export function resolveCallerOwnedOpenAiSidecar(
|
|
140
|
+
candidates: readonly OpenAiForwardSidecarCandidate[],
|
|
141
|
+
incomingHeaders: Headers,
|
|
142
|
+
config: OcxConfig,
|
|
143
|
+
options: { admission?: Pick<DataPlaneAdmission, "source">; codexAuthPolicy?: CodexAuthPolicyConfig } = {},
|
|
144
|
+
): ResolvedOpenAiForwardSidecar | undefined {
|
|
145
|
+
const candidate = candidates[0];
|
|
146
|
+
if (!candidate) return undefined;
|
|
147
|
+
try {
|
|
148
|
+
validateForwardAdmissionCredential(incomingHeaders, config);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof ForwardAdmissionCredentialError) return undefined;
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (!hasCallerCodexBearer(incomingHeaders)) return undefined;
|
|
154
|
+
const headers = directSidecarHeaders(incomingHeaders, options.codexAuthPolicy ?? config, options.admission);
|
|
155
|
+
if (!headers) return undefined;
|
|
156
|
+
return { ...candidate, authContext: { kind: "main", accountId: null }, headers };
|
|
157
|
+
}
|
|
158
|
+
|
|
126
159
|
export async function resolveFirstUsableOpenAiSidecar(
|
|
127
160
|
candidates: readonly OpenAiForwardSidecarCandidate[],
|
|
128
161
|
incomingHeaders: Headers,
|
|
@@ -176,6 +209,7 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
176
209
|
...candidate,
|
|
177
210
|
authContext,
|
|
178
211
|
headers: selectedHeaders,
|
|
212
|
+
beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
|
|
179
213
|
recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
|
|
180
214
|
config,
|
|
181
215
|
authContext.accountId,
|
|
@@ -226,6 +260,7 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
226
260
|
...candidate,
|
|
227
261
|
authContext,
|
|
228
262
|
headers: selectedHeaders,
|
|
263
|
+
beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
|
|
229
264
|
...(authContext.kind === "pool" || authContext.kind === "main-pool"
|
|
230
265
|
? {
|
|
231
266
|
recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
|
|
@@ -1205,6 +1205,7 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1205
1205
|
return await handleNativeMessages({
|
|
1206
1206
|
req, config, logCtx, ...(logIds ? { logIds } : {}),
|
|
1207
1207
|
route: nativeMessagesRoute, body: nativeBody, requestedModel, translatorBudget, selector: nativeSelector,
|
|
1208
|
+
modelScope: resolveAdmissionModelScope(config, logIds?.admission),
|
|
1208
1209
|
// Compatibility identity is an opaque request-local handle, separate from credentials.
|
|
1209
1210
|
clientIdentity: captureAnthropicClientIdentity(req.headers),
|
|
1210
1211
|
callerAnthropicBeta: req.headers.get("anthropic-beta"),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import { GUI_PAIR_BROWSER_ORIGIN_HEADER, GUI_PAIR_CAPABILITY_HEADER, standaloneGuiPairingOrigin } from "../lib/gui-pair-capability";
|
|
3
|
+
import { consumeGuiPairIntent, GUI_PAIR_INTENT_HEADER } from "../lib/gui-pair-intent";
|
|
4
|
+
import { createGuiPairingGrant, type GuiSessionState } from "./gui-session";
|
|
5
|
+
|
|
6
|
+
export class GuiPairingIntentRequiredError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("Standalone GUI pairing requires a fresh local CLI intent");
|
|
9
|
+
this.name = "GuiPairingIntentRequiredError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The serving route must authenticate the existing process-bound capability first. */
|
|
14
|
+
export function deliverGuiPairingGrant(req: Request, config: OcxConfig, state: GuiSessionState) {
|
|
15
|
+
const origin = req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "";
|
|
16
|
+
if (config.runtimeRole !== "hub") {
|
|
17
|
+
if (standaloneGuiPairingOrigin(config) !== origin
|
|
18
|
+
|| !consumeGuiPairIntent(req.headers.get(GUI_PAIR_CAPABILITY_HEADER), req.headers.get(GUI_PAIR_INTENT_HEADER))) {
|
|
19
|
+
throw new GuiPairingIntentRequiredError();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return createGuiPairingGrant(origin, config, state);
|
|
23
|
+
}
|
|
@@ -174,15 +174,14 @@ import { LOCAL_MANAGEMENT_NONCE_HEADER } from "../../lib/local-management-capabi
|
|
|
174
174
|
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
|
|
175
175
|
import { LOCAL_ASIDE_SYNC_CAPABILITY_VERSION } from "../../lib/local-aside-sync-contract";
|
|
176
176
|
import {
|
|
177
|
-
GUI_PAIR_BROWSER_ORIGIN_HEADER,
|
|
178
177
|
GUI_PAIR_CAPABILITY_VERSION,
|
|
179
178
|
GUI_PAIR_PATH,
|
|
180
179
|
} from "../../lib/gui-pair-capability";
|
|
181
180
|
import {
|
|
182
181
|
GuiPairingGrantRateLimitError,
|
|
183
182
|
consumeGuiPairingGrant,
|
|
184
|
-
createGuiPairingGrant,
|
|
185
183
|
} from "../gui-session";
|
|
184
|
+
import { deliverGuiPairingGrant, GuiPairingIntentRequiredError } from "../gui-pair-delivery";
|
|
186
185
|
import { recordCursorSeen } from "../../integrations/cursor-seen";
|
|
187
186
|
import { detectCursorInstalls } from "../../integrations/cursor-detect";
|
|
188
187
|
import { loadCursorEffortTable } from "../../integrations/cursor-effort-table";
|
|
@@ -681,18 +680,16 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
681
680
|
return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
|
|
682
681
|
}
|
|
683
682
|
try {
|
|
684
|
-
const grant =
|
|
685
|
-
req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
|
|
686
|
-
config,
|
|
687
|
-
managementAuth,
|
|
688
|
-
);
|
|
683
|
+
const grant = deliverGuiPairingGrant(req, config, managementAuth);
|
|
689
684
|
return withManagementCors(Response.json(grant, {
|
|
690
685
|
status: 201,
|
|
691
686
|
headers: { "Cache-Control": "no-store" },
|
|
692
687
|
}), req, config);
|
|
693
688
|
} catch (error) {
|
|
694
689
|
const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
|
|
695
|
-
return withManagementCors(Response.json({ error: "GUI pairing grant refused"
|
|
690
|
+
return withManagementCors(Response.json({ error: "GUI pairing grant refused",
|
|
691
|
+
...(error instanceof GuiPairingIntentRequiredError ? { code: "local_pairing_intent_required" } : {}),
|
|
692
|
+
}, {
|
|
696
693
|
status,
|
|
697
694
|
...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
|
|
698
695
|
}), req, config);
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream call ids created through the native voice call-create path (`handleLive`).
|
|
3
|
+
*
|
|
4
|
+
* A sideband join names only a call id, and two kinds of call reach the same join routes. A call
|
|
5
|
+
* this process created was negotiated under the account the Pool selected, and its join must stay
|
|
6
|
+
* on that account (openai/codex #35830; thread affinity carries it). A call the client created
|
|
7
|
+
* itself — ChatGPT voice handing a call to a Codex thread, or Codex Desktop when its renderer owns
|
|
8
|
+
* the call — belongs to the caller's own ChatGPT login and arrives as a V3 `existingCall` join with
|
|
9
|
+
* no create on this proxy. Membership here is what tells the two apart.
|
|
10
|
+
*
|
|
11
|
+
* Ids only: no account, credential, model, or caller is stored. Process-local and bounded, so a
|
|
12
|
+
* restart, the TTL, or eviction past capacity turns a created call into an unknown one; a later
|
|
13
|
+
* rejoin is then authenticated as the caller (see `resolveLiveRelay`).
|
|
14
|
+
*/
|
|
15
|
+
export const NATIVE_LIVE_CALL_TTL_MS = 6 * 60 * 60_000;
|
|
16
|
+
export const MAX_NATIVE_LIVE_CALLS = 1024;
|
|
17
|
+
const MAX_LOCATION_LENGTH = 4096;
|
|
18
|
+
const MAX_CALL_ID_LENGTH = 128;
|
|
19
|
+
const RTC_CALL_ID_RE = /^rtc_[A-Za-z0-9_-]+$/;
|
|
20
|
+
const UUID_CALL_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
21
|
+
|
|
22
|
+
function isRealtimeCallIdSegment(segment: string): boolean {
|
|
23
|
+
if (segment.length > MAX_CALL_ID_LENGTH) return false;
|
|
24
|
+
return RTC_CALL_ID_RE.test(segment) || UUID_CALL_ID_RE.test(segment);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The call id a client reads from a call-create Location, extracted the way openai/codex does
|
|
29
|
+
* (`decode_call_id_from_location`, codex-api realtime_call.rs): drop the query, scan the path
|
|
30
|
+
* segments from the end, and take the first `rtc_…` or hyphenated-UUID segment. The backend
|
|
31
|
+
* answers `/v1/realtime/calls/calls/rtc_…`, the API `/v1/live/rtc_…`; both yield the id the client
|
|
32
|
+
* then joins with.
|
|
33
|
+
*/
|
|
34
|
+
export function liveCallIdFromLocation(location: string | null | undefined): string | null {
|
|
35
|
+
if (!location || location.length > MAX_LOCATION_LENGTH) return null;
|
|
36
|
+
const path = location.split("?")[0] ?? "";
|
|
37
|
+
for (const segment of path.split("/").reverse()) {
|
|
38
|
+
if (isRealtimeCallIdSegment(segment)) return segment;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class NativeLiveCalls {
|
|
44
|
+
/** Insertion order is creation order, so the first key is always the oldest entry. */
|
|
45
|
+
private readonly entries = new Map<string, number>();
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly now: () => number = Date.now,
|
|
49
|
+
private readonly ttlMs: number = NATIVE_LIVE_CALL_TTL_MS,
|
|
50
|
+
private readonly capacity: number = MAX_NATIVE_LIVE_CALLS,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
/** Record the call a successful create's Location names; returns the id, or null when none. */
|
|
54
|
+
record(location: string | null | undefined): string | null {
|
|
55
|
+
const id = liveCallIdFromLocation(location);
|
|
56
|
+
if (!id) return null;
|
|
57
|
+
this.prune();
|
|
58
|
+
this.entries.delete(id);
|
|
59
|
+
while (this.entries.size >= this.capacity) {
|
|
60
|
+
const oldest = this.entries.keys().next().value;
|
|
61
|
+
if (oldest === undefined) break;
|
|
62
|
+
this.entries.delete(oldest);
|
|
63
|
+
}
|
|
64
|
+
this.entries.set(id, this.now() + this.ttlMs);
|
|
65
|
+
return id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
has(id: string): boolean {
|
|
69
|
+
const expiresAt = this.entries.get(id);
|
|
70
|
+
if (expiresAt === undefined) return false;
|
|
71
|
+
if (expiresAt <= this.now()) {
|
|
72
|
+
this.entries.delete(id);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get size(): number {
|
|
79
|
+
this.prune();
|
|
80
|
+
return this.entries.size;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clear(): void {
|
|
84
|
+
this.entries.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private prune(): void {
|
|
88
|
+
const now = this.now();
|
|
89
|
+
for (const [id, expiresAt] of this.entries) if (expiresAt <= now) this.entries.delete(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The process-wide registry `handleLive` writes and native sideband joins read. */
|
|
94
|
+
export const nativeLiveCalls = new NativeLiveCalls();
|
package/src/server/live.ts
CHANGED
|
@@ -44,9 +44,14 @@ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
|
|
|
44
44
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
45
45
|
import { hardenSecretPath } from "../lib/windows-secret-acl";
|
|
46
46
|
import type { OcxConfig } from "../types";
|
|
47
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
resolveCallerOwnedOpenAiSidecar,
|
|
49
|
+
resolveFirstUsableOpenAiSidecar,
|
|
50
|
+
selectOpenAiImagesProvider,
|
|
51
|
+
} from "../providers/openai-sidecar";
|
|
48
52
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
|
|
49
53
|
import { admissionScopeDenial } from "./admission-model-scope";
|
|
54
|
+
import { nativeLiveCalls } from "./live-native-calls";
|
|
50
55
|
import type { RequestLogContext } from "./request-log";
|
|
51
56
|
import { codexLogAccountId } from "./responses";
|
|
52
57
|
import type { AdmissionLease } from "../lib/admission";
|
|
@@ -613,6 +618,12 @@ async function readRequestBodyCapped(req: Request, maxBytes: number): Promise<Ar
|
|
|
613
618
|
export interface LiveScopeDestination {
|
|
614
619
|
admission?: DataPlaneAdmission;
|
|
615
620
|
model: string | undefined;
|
|
621
|
+
/**
|
|
622
|
+
* The call a sideband join attaches to. When this process did not create it (no entry in
|
|
623
|
+
* `nativeLiveCalls`), the client created it with its own ChatGPT login — a V3 `existingCall` —
|
|
624
|
+
* and the join is authenticated as that caller instead of a Pool-selected account.
|
|
625
|
+
*/
|
|
626
|
+
callerOwnedCallId?: string;
|
|
616
627
|
}
|
|
617
628
|
|
|
618
629
|
/**
|
|
@@ -691,9 +702,14 @@ export async function resolveLiveRelay(
|
|
|
691
702
|
let forwardAuthError: Response | undefined;
|
|
692
703
|
if (candidates.forwardCandidates.length > 0) {
|
|
693
704
|
try {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
705
|
+
const joinsForeignCall = destination?.callerOwnedCallId !== undefined
|
|
706
|
+
&& !nativeLiveCalls.has(destination.callerOwnedCallId);
|
|
707
|
+
forward = (joinsForeignCall
|
|
708
|
+
? resolveCallerOwnedOpenAiSidecar(candidates.forwardCandidates, req.headers, config)
|
|
709
|
+
: undefined)
|
|
710
|
+
?? await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config, {
|
|
711
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
712
|
+
});
|
|
697
713
|
if (forward) {
|
|
698
714
|
logCtx.provider = formatCodexProviderForLog(
|
|
699
715
|
forward.providerName,
|
|
@@ -756,7 +772,8 @@ export async function resolveLiveRelay(
|
|
|
756
772
|
providerBaseUrl: provider.baseUrl,
|
|
757
773
|
usesBackendShape: isChatGptBackendBaseUrl(provider.baseUrl),
|
|
758
774
|
keyed: false,
|
|
759
|
-
|
|
775
|
+
// A caller-owned or Direct credential selected no Pool account, so there is nothing to record.
|
|
776
|
+
...(forward.recordOutcome ? { recordOutcome: (status: number | "timeout" | "connect_error") => forward.recordOutcome?.(status) } : {}),
|
|
760
777
|
};
|
|
761
778
|
}
|
|
762
779
|
if (forwardAuthError) return forwardAuthError;
|
|
@@ -864,6 +881,11 @@ export async function handleLive(
|
|
|
864
881
|
detachBodyGuard();
|
|
865
882
|
}
|
|
866
883
|
if (payload instanceof Response) return payload;
|
|
884
|
+
// Remember the calls this proxy created, so their joins keep the Pool account that created
|
|
885
|
+
// them while a join for any other call is authenticated as its caller (`resolveLiveRelay`).
|
|
886
|
+
if (upstreamResponse.status >= 200 && upstreamResponse.status < 300) {
|
|
887
|
+
nativeLiveCalls.record(upstreamResponse.headers.get("location"));
|
|
888
|
+
}
|
|
867
889
|
const relayHeaders: Record<string, string> = {};
|
|
868
890
|
for (const name of LIVE_RELAY_HEADERS) {
|
|
869
891
|
const value = upstreamResponse.headers.get(name);
|
|
@@ -902,6 +924,7 @@ export async function resolveLiveSidebandUpgrade(
|
|
|
902
924
|
const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, {
|
|
903
925
|
admission,
|
|
904
926
|
model: liveSidebandModel(target),
|
|
927
|
+
...("callId" in target ? { callerOwnedCallId: target.callId } : {}),
|
|
905
928
|
});
|
|
906
929
|
if (relay instanceof Response) return relay;
|
|
907
930
|
return {
|
|
@@ -25,6 +25,8 @@ export type LocalManagementReadResult =
|
|
|
25
25
|
|
|
26
26
|
export interface LocalManagementReadDeps {
|
|
27
27
|
fetchImpl?: typeof fetch;
|
|
28
|
+
/** Default transport seam; receives the caller deadline. */
|
|
29
|
+
directFetch?: typeof directLocalHttpFetch;
|
|
28
30
|
readRuntime?: (pid: number) => RuntimePortState | null;
|
|
29
31
|
createNonce?: () => string;
|
|
30
32
|
now?: () => number;
|
|
@@ -84,18 +86,20 @@ export async function fetchBoundLocalManagementRead(
|
|
|
84
86
|
if (!capability) return { kind: "unavailable", reason: "capability-unavailable" };
|
|
85
87
|
|
|
86
88
|
try {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
},
|
|
96
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? 4_000),
|
|
89
|
+
const timeoutMs = deps.timeoutMs ?? 4_000;
|
|
90
|
+
const url = `http://${probeHostname(target.hostname)}:${target.port}${path}`;
|
|
91
|
+
const init: RequestInit = {
|
|
92
|
+
headers: {
|
|
93
|
+
[LOCAL_MANAGEMENT_EXPECTED_PID_HEADER]: String(target.pid),
|
|
94
|
+
[LOCAL_MANAGEMENT_NONCE_HEADER]: nonce,
|
|
95
|
+
[LOCAL_MANAGEMENT_CAPABILITY_EXPIRES_AT_HEADER]: String(expiresAt),
|
|
96
|
+
[LOCAL_MANAGEMENT_CAPABILITY_HEADER]: capability,
|
|
97
97
|
},
|
|
98
|
-
|
|
98
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
99
|
+
};
|
|
100
|
+
const response = deps.fetchImpl
|
|
101
|
+
? await deps.fetchImpl(url, init)
|
|
102
|
+
: await (deps.directFetch ?? directLocalHttpFetch)(url, init, { timeoutMs });
|
|
99
103
|
if (deps.requireResponseProof && !verifyLocalAttestationProof(
|
|
100
104
|
runtime.attestationSecret, nonce, target.pid, target.port, response.headers.get(LOCAL_ATTESTATION_PROOF_HEADER),
|
|
101
105
|
)) {
|
|
@@ -1164,6 +1164,21 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
1164
1164
|
}, 200, req, config);
|
|
1165
1165
|
}
|
|
1166
1166
|
|
|
1167
|
+
if (url.pathname === "/api/keys/reveal" && req.method === "POST") {
|
|
1168
|
+
if (principal !== "gui-session") {
|
|
1169
|
+
return jsonResponse({ error: "dashboard session required" }, 403, req, config);
|
|
1170
|
+
}
|
|
1171
|
+
const body = await readJsonBody(req);
|
|
1172
|
+
if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
|
|
1173
|
+
return jsonResponse({ error: "invalid body" }, 400, req, config);
|
|
1174
|
+
}
|
|
1175
|
+
const entry = config.apiKeys?.find(key => key.id === body.id);
|
|
1176
|
+
if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
|
|
1177
|
+
const response = jsonResponse({ key: entry.key }, 200, req, config);
|
|
1178
|
+
response.headers.set("Cache-Control", "no-store");
|
|
1179
|
+
return response;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1167
1182
|
if (url.pathname === "/api/keys/rotate" && req.method === "POST") {
|
|
1168
1183
|
const body = await readJsonBody(req);
|
|
1169
1184
|
if (!body || Object.keys(body).length !== 1 || typeof body.id !== "string" || !body.id) {
|
|
@@ -324,6 +324,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
324
324
|
{ method: "PATCH", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
|
|
325
325
|
{ method: "PATCH", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
|
|
326
326
|
{ method: "POST", path: "/api/keys", module: "server/management/oauth-account-routes", mutates: true },
|
|
327
|
+
{ method: "POST", path: "/api/keys/reveal", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "session-only", why: "Revealing a data key requires the gui-session principal; the admin token is refused." } },
|
|
327
328
|
{ method: "POST", path: "/api/keys/rotate", module: "server/management/oauth-account-routes", mutates: true },
|
|
328
329
|
{ method: "POST", path: "/api/keys/rotate/commit", module: "server/management/oauth-account-routes", mutates: true },
|
|
329
330
|
{ method: "POST", path: "/api/oauth/accounts/clear-cooldown", module: "server/management/oauth-account-routes", mutates: true },
|
|
@@ -89,6 +89,11 @@ export type TokPerSecondResult =
|
|
|
89
89
|
| { kind: "value"; value: number; estimated: boolean }
|
|
90
90
|
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
91
91
|
|
|
92
|
+
export type DecodeTimingBasis = "generation-window" | "legacy-post-visible-output";
|
|
93
|
+
export type DecodeTokPerSecondResult =
|
|
94
|
+
| (Extract<TokPerSecondResult, { kind: "value" }> & { timingBasis: DecodeTimingBasis })
|
|
95
|
+
| Extract<TokPerSecondResult, { kind: "unavailable" }>;
|
|
96
|
+
|
|
92
97
|
export type CostEstimateReason =
|
|
93
98
|
| "usage_estimated"
|
|
94
99
|
| "cache_detail_missing"
|
|
@@ -149,7 +154,7 @@ export const MIN_DECODE_WINDOW_MS = 1_000;
|
|
|
149
154
|
*/
|
|
150
155
|
export function decodeTokPerSecondResult(
|
|
151
156
|
entry: Pick<MetricSource, "durationMs" | "firstOutputMs" | "genStartMs" | "lastOutputMs" | "usageStatus" | "usage">,
|
|
152
|
-
):
|
|
157
|
+
): DecodeTokPerSecondResult {
|
|
153
158
|
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
154
159
|
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
155
160
|
if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" };
|
|
@@ -159,7 +164,8 @@ export function decodeTokPerSecondResult(
|
|
|
159
164
|
if (generationMs <= 0) return { kind: "unavailable", reason: "invalid_duration" };
|
|
160
165
|
if (generationMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
|
|
161
166
|
const value = tokensPerSecond(entry.usage.outputTokens, generationMs);
|
|
162
|
-
return value === null ? { kind: "unavailable", reason: "invalid_duration" }
|
|
167
|
+
return value === null ? { kind: "unavailable", reason: "invalid_duration" }
|
|
168
|
+
: { kind: "value", value, estimated: true, timingBasis: "generation-window" };
|
|
163
169
|
}
|
|
164
170
|
// A row that predates TTFT capture, or a non-streaming turn that never recorded one, has no
|
|
165
171
|
// window to measure. That is a different fact from a bad duration, so it gets its own reason.
|
|
@@ -173,7 +179,7 @@ export function decodeTokPerSecondResult(
|
|
|
173
179
|
if (windowMs < MIN_DECODE_WINDOW_MS) return { kind: "unavailable", reason: "decode_window_too_short" };
|
|
174
180
|
const value = tokensPerSecond(entry.usage.outputTokens, windowMs);
|
|
175
181
|
if (value === null) return { kind: "unavailable", reason: "invalid_duration" };
|
|
176
|
-
return { kind: "value", value, estimated: true };
|
|
182
|
+
return { kind: "value", value, estimated: true, timingBasis: "legacy-post-visible-output" };
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AdmissionModelDeniedError, routeAllowedByScope, UNNAMED_DESTINATION_MODEL, type AdmissionModelScope,
|
|
3
|
+
} from "./admission-model-scope";
|
|
4
|
+
|
|
5
|
+
type Rec = Record<string, unknown>;
|
|
6
|
+
const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
const isAdvisor = (tool: Rec): boolean => typeof tool.type === "string" && /^advisor_\d{8}$/.test(tool.type);
|
|
8
|
+
|
|
9
|
+
/** Only executable declarations are policy inputs; schemas, arguments and results stay opaque. */
|
|
10
|
+
export function nativeMessagesToolScopeDenial(
|
|
11
|
+
scope: AdmissionModelScope | undefined,
|
|
12
|
+
providerName: string,
|
|
13
|
+
requestedModel: string,
|
|
14
|
+
body: Readonly<Rec>,
|
|
15
|
+
): AdmissionModelDeniedError | undefined {
|
|
16
|
+
if (!scope || scope.models.length === 0) return undefined;
|
|
17
|
+
const definitions = new Map<string, Rec[]>();
|
|
18
|
+
const withdrawn = new Set<string>();
|
|
19
|
+
const anonymous: Rec[] = [];
|
|
20
|
+
if (Array.isArray(body.tools)) for (const tool of body.tools) {
|
|
21
|
+
if (!isRec(tool)) continue;
|
|
22
|
+
if (typeof tool.name !== "string") { anonymous.push(tool); continue; }
|
|
23
|
+
const declarations = definitions.get(tool.name) ?? [];
|
|
24
|
+
declarations.push(tool);
|
|
25
|
+
definitions.set(tool.name, declarations);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(body.messages)) for (const message of body.messages) {
|
|
28
|
+
if (!isRec(message) || message.role !== "system" || !Array.isArray(message.content)) continue;
|
|
29
|
+
// Upstream rejects tool changes in turn-scoped messages; do not depend on that. An addition
|
|
30
|
+
// always counts, and only a permanent removal withdraws a declaration (fail closed).
|
|
31
|
+
const permanent = message.clear_at === undefined || message.clear_at === "never";
|
|
32
|
+
for (const block of message.content) {
|
|
33
|
+
if (!isRec(block) || !isRec(block.tool)) continue;
|
|
34
|
+
const tool = block.tool;
|
|
35
|
+
if (block.type === "tool_addition" && tool.type === "tool_definition" && isRec(tool.definition)) {
|
|
36
|
+
const definition = tool.definition;
|
|
37
|
+
if (typeof definition.name !== "string") { anonymous.push(definition); continue; }
|
|
38
|
+
// A temporary replacement keeps the declaration it shadows in scope as well.
|
|
39
|
+
definitions.set(definition.name, permanent ? [definition] : [...definitions.get(definition.name) ?? [], definition]);
|
|
40
|
+
withdrawn.delete(definition.name);
|
|
41
|
+
} else if (tool.type === "tool_reference" && typeof tool.name === "string") {
|
|
42
|
+
if (block.type === "tool_removal" && permanent) withdrawn.add(tool.name);
|
|
43
|
+
if (block.type === "tool_addition") withdrawn.delete(tool.name);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Deferred definitions can still be loaded by tool search in the current turn.
|
|
48
|
+
const active = [...anonymous, ...[...definitions].flatMap(([name, tools]) => withdrawn.has(name) ? [] : tools)];
|
|
49
|
+
for (const tool of active) {
|
|
50
|
+
if (!isAdvisor(tool)) continue;
|
|
51
|
+
const modelId = typeof tool.model === "string" && tool.model.trim() ? tool.model : undefined;
|
|
52
|
+
if (modelId === undefined || !routeAllowedByScope(scope, { providerName, modelId })) {
|
|
53
|
+
return new AdmissionModelDeniedError(requestedModel, { providerName, modelId: modelId ?? UNNAMED_DESTINATION_MODEL });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|