@bitkyc08/opencodex 2.78.0 → 2.80.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-CSJvmjNs.css +1 -0
- package/gui/dist/assets/App-MbDVfUeC.js +54 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-UJt6npXk.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/cursor/live-transport.ts +5 -3
- package/src/adapters/cursor/native-exec-fs.ts +12 -9
- package/src/adapters/cursor/native-exec.ts +13 -5
- package/src/adapters/cursor/protobuf-request.ts +13 -7
- package/src/adapters/cursor/request-builder.ts +23 -10
- package/src/adapters/cursor/tool-definitions.ts +9 -6
- package/src/adapters/cursor/tool-guidance.ts +29 -8
- package/src/adapters/cursor/tool-naming.ts +40 -7
- package/src/adapters/cursor/tool-wording.ts +46 -0
- package/src/adapters/cursor.ts +3 -0
- package/src/adapters/devin.ts +37 -2
- package/src/adapters/openai-chat/messages.ts +3 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/inbound.ts +9 -0
- 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 +13 -3
- package/src/codex/journal.ts +36 -5
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/codex/shim-overlay.ts +9 -1
- package/src/combos/jev.ts +3 -1
- package/src/generated/compatibility-version.json +135 -63
- 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/index.ts +12 -14
- 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 +60 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/combo-requested-effort.ts +14 -0
- 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/core-combo.ts +5 -7
- 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/service/desktop-startup.ts +100 -3
- 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/header-deadline.ts +67 -0
- package/src/web-search/loop.ts +7 -6
- 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,170 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, rmdirSync, unlinkSync, writeFileSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getConfigDir } from "../config/paths";
|
|
5
|
+
import { assertNotRealHomeUnderTest } from "./test-home-guard";
|
|
6
|
+
import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath, windowsSecretAclApplies } from "./windows-secret-acl";
|
|
7
|
+
|
|
8
|
+
import { verifyWindowsPrivateEntries, WINDOWS_OWNER_ACL_TIMEOUT_MS } from "./windows-owner-acl";
|
|
9
|
+
|
|
10
|
+
export const GUI_PAIR_INTENT_HEADER = "x-opencodex-gui-pair-intent";
|
|
11
|
+
const TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
12
|
+
const INTENT_NAME = /^[0-9a-f]{64}$/;
|
|
13
|
+
// Records outlive their ~10s capability only when the publishing CLI died; a wide
|
|
14
|
+
// margin keeps an active command's fresh record from ever looking stale.
|
|
15
|
+
const STALE_INTENT_MS = 60_000;
|
|
16
|
+
const STALE_INTENT_SWEEP_LIMIT = 256;
|
|
17
|
+
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
18
|
+
|
|
19
|
+
function owned(stat: BigIntStats): boolean {
|
|
20
|
+
return process.platform === "win32" || (stat.uid === BigInt(process.getuid!()) && (stat.mode & 0o022n) === 0n);
|
|
21
|
+
}
|
|
22
|
+
function same(a: BigIntStats, b: BigIntStats): boolean {
|
|
23
|
+
return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs;
|
|
24
|
+
}
|
|
25
|
+
function directory(path: string): BigIntStats {
|
|
26
|
+
const stat = lstatSync(path, { bigint: true });
|
|
27
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !owned(stat)) throw new Error("Unsafe GUI pairing intent directory");
|
|
28
|
+
return stat;
|
|
29
|
+
}
|
|
30
|
+
function location(capability: string, configDir: string): { dir: string; path: string } {
|
|
31
|
+
if (!TOKEN.test(capability)) throw new Error("Invalid GUI pairing capability");
|
|
32
|
+
const dir = join(configDir, "gui-pair-intents");
|
|
33
|
+
return { dir, path: join(dir, digest(`opencodex-gui-pair-intent-v1\n${capability}`)) };
|
|
34
|
+
}
|
|
35
|
+
function releaseConsumeLock(path: string, identity: BigIntStats): boolean {
|
|
36
|
+
try {
|
|
37
|
+
if (!same(identity, lstatSync(path, { bigint: true }))) return false;
|
|
38
|
+
rmdirSync(path);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function removeOwned(path: string, identity: BigIntStats): void {
|
|
46
|
+
try {
|
|
47
|
+
if (same(identity, lstatSync(path, { bigint: true }))) unlinkSync(path);
|
|
48
|
+
} catch { /* expired or already consumed; never remove a replacement */ }
|
|
49
|
+
forgetEphemeralSecretPath(path);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Best-effort stale cleanup; bound per-entry work before creating the next expiring intent. */
|
|
53
|
+
function sweepStaleIntents(dir: string): void {
|
|
54
|
+
let names: string[];
|
|
55
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
// Count every entry, including foreign and fresh ones, rather than only successful deletes.
|
|
58
|
+
// This caps metadata/deletion work, not readdirSync's array allocation or syscall duration.
|
|
59
|
+
// Bun 1.4.0 also snapshots names behind opendirSync, so an iterator alone is not a memory cap.
|
|
60
|
+
const limit = Math.min(names.length, STALE_INTENT_SWEEP_LIMIT);
|
|
61
|
+
for (let index = 0; index < limit; index++) {
|
|
62
|
+
const name = names[index]!;
|
|
63
|
+
if (!INTENT_NAME.test(name)) continue;
|
|
64
|
+
const path = join(dir, name);
|
|
65
|
+
try {
|
|
66
|
+
const stat = lstatSync(path, { bigint: true });
|
|
67
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !owned(stat) || stat.nlink !== 1n) continue;
|
|
68
|
+
if (now - Number(stat.mtimeMs) < STALE_INTENT_MS) continue;
|
|
69
|
+
removeOwned(path, stat);
|
|
70
|
+
} catch { /* leave unreadable entries alone */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface GuiPairIntent {
|
|
75
|
+
proof: string;
|
|
76
|
+
dispose(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Prove write access to the private configuration home, separately from runtime-state reads.
|
|
81
|
+
* Only a SHA-256 commitment goes to disk; the random verifier remains in the requesting CLI.
|
|
82
|
+
* This is an owner-write boundary, NOT proof of human presence or protection from a fully
|
|
83
|
+
* privileged same-user process. The existing HMAC binds PID, port, origin, nonce and expiry.
|
|
84
|
+
*/
|
|
85
|
+
export function createGuiPairIntent(capability: string, configDir = getConfigDir()): GuiPairIntent {
|
|
86
|
+
assertNotRealHomeUnderTest(configDir);
|
|
87
|
+
directory(configDir);
|
|
88
|
+
const { dir, path } = location(capability, configDir);
|
|
89
|
+
try { mkdirSync(dir, { mode: 0o700 }); }
|
|
90
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
91
|
+
directory(dir);
|
|
92
|
+
if (!hardenSecretDir(dir, { required: true, deadlineMs: 2_000 }).ok) throw new Error("GUI pairing intent ACL refused");
|
|
93
|
+
sweepStaleIntents(dir);
|
|
94
|
+
const parent = directory(dir);
|
|
95
|
+
const proof = randomBytes(32).toString("base64url");
|
|
96
|
+
const fd = openSync(path, "wx", 0o600);
|
|
97
|
+
let identity = fstatSync(fd, { bigint: true });
|
|
98
|
+
try {
|
|
99
|
+
writeFileSync(fd, `${digest(proof)}\n`, "utf8");
|
|
100
|
+
if (!hardenSecretPath(path, { required: true, deadlineMs: 2_000 }).ok) throw new Error("GUI pairing intent ACL refused");
|
|
101
|
+
identity = fstatSync(fd, { bigint: true });
|
|
102
|
+
if (!same(identity, lstatSync(path, { bigint: true })) || !owned(identity)
|
|
103
|
+
|| identity.nlink !== 1n || parent.dev !== directory(dir).dev || parent.ino !== directory(dir).ino) {
|
|
104
|
+
throw new Error("GUI pairing intent changed during publication");
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
identity = fstatSync(fd, { bigint: true });
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
removeOwned(path, identity);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
closeSync(fd);
|
|
113
|
+
// Disposal consumes the record: the same directory, identity and content checks as
|
|
114
|
+
// redemption, so a corrupted or replaced commitment is retained on every platform.
|
|
115
|
+
const dispose = (): void => { consumeGuiPairIntent(capability, proof, configDir); };
|
|
116
|
+
return { proof, dispose };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Called only after the existing process-bound, expiring capability passed authorization. */
|
|
120
|
+
export function consumeGuiPairIntent(capability: string | null, proof: string | null, configDir = getConfigDir()): boolean {
|
|
121
|
+
if (!capability || !proof || !TOKEN.test(capability) || !TOKEN.test(proof)) return false;
|
|
122
|
+
let fd: number | undefined;
|
|
123
|
+
let consumeLock: string | undefined;
|
|
124
|
+
let consumeLockIdentity: BigIntStats | undefined;
|
|
125
|
+
try {
|
|
126
|
+
assertNotRealHomeUnderTest(configDir);
|
|
127
|
+
directory(configDir);
|
|
128
|
+
const { dir, path } = location(capability, configDir);
|
|
129
|
+
const parent = directory(dir);
|
|
130
|
+
consumeLock = `${path}.consuming`;
|
|
131
|
+
mkdirSync(consumeLock, { mode: 0o700 });
|
|
132
|
+
consumeLockIdentity = directory(consumeLock);
|
|
133
|
+
const lockedParent = directory(dir);
|
|
134
|
+
if (parent.dev !== lockedParent.dev || parent.ino !== lockedParent.ino) return false;
|
|
135
|
+
const before = lstatSync(path, { bigint: true });
|
|
136
|
+
if (!before.isFile() || before.isSymbolicLink() || !owned(before) || before.nlink !== 1n || before.size !== 65n) return false;
|
|
137
|
+
// Do not block on a substituted FIFO. Windows retains descriptor/path identity checks.
|
|
138
|
+
const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
139
|
+
fd = openSync(path, flags);
|
|
140
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
141
|
+
if (!same(before, opened) || !opened.isFile()) return false;
|
|
142
|
+
const bytes = Buffer.alloc(66);
|
|
143
|
+
const size = readSync(fd, bytes, 0, bytes.length, 0);
|
|
144
|
+
if (size !== 65 || !timingSafeEqual(bytes.subarray(0, size), Buffer.from(`${digest(proof)}\n`))) return false;
|
|
145
|
+
const after = fstatSync(fd, { bigint: true });
|
|
146
|
+
if (!same(opened, after) || !same(after, lstatSync(path, { bigint: true }))
|
|
147
|
+
|| parent.dev !== directory(dir).dev || parent.ino !== directory(dir).ino) return false;
|
|
148
|
+
closeSync(fd); fd = undefined;
|
|
149
|
+
// Verify only a matching commitment, with its descriptor closed and lock held.
|
|
150
|
+
// Redemption must never harden an untrusted record into acceptable authority.
|
|
151
|
+
if (windowsSecretAclApplies() && !verifyWindowsPrivateEntries([
|
|
152
|
+
{ path: dir, directory: true }, { path, directory: false },
|
|
153
|
+
], WINDOWS_OWNER_ACL_TIMEOUT_MS)) return false;
|
|
154
|
+
const verifiedParent = directory(dir);
|
|
155
|
+
if (parent.dev !== verifiedParent.dev || parent.ino !== verifiedParent.ino
|
|
156
|
+
|| !same(after, lstatSync(path, { bigint: true }))) return false;
|
|
157
|
+
unlinkSync(path);
|
|
158
|
+
forgetEphemeralSecretPath(path);
|
|
159
|
+
return true;
|
|
160
|
+
} catch { return false; }
|
|
161
|
+
finally {
|
|
162
|
+
try {
|
|
163
|
+
if (fd !== undefined) closeSync(fd);
|
|
164
|
+
} finally {
|
|
165
|
+
if (consumeLock && consumeLockIdentity) {
|
|
166
|
+
releaseConsumeLock(consumeLock, consumeLockIdentity);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -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);
|
package/src/server/index.ts
CHANGED
|
@@ -463,21 +463,19 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
463
463
|
return serveGuiFile(rawPath) !== null;
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
-
// Codex
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
// back.
|
|
466
|
+
// Codex maps 503 + server_is_overloaded to ServerOverloaded ("model at capacity"), retried
|
|
467
|
+
// only with retry advice that older clients ignore (#6642); server_restarting is a
|
|
468
|
+
// retryable UnexpectedStatus in every version and never reads as model capacity.
|
|
469
|
+
// Keep JSON (empty / non-JSON 503 means "Unknown error", #452) and Retry-After.
|
|
470
|
+
// Drain and busy run BEFORE auth/origin checks: use the receiving listener's policy
|
|
471
|
+
// (#1102), because shared public `config` could echo a rebinding origin on loopback.
|
|
473
472
|
function drainingResponse(req: Request, policy: RequestPolicyView): Response {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
return new Response(response.body, { status: 503, headers });
|
|
473
|
+
return withCors(new Response(JSON.stringify({
|
|
474
|
+
error: { type: "server_error", code: "server_restarting", message: "OpenCodex is restarting; retry this request." },
|
|
475
|
+
}), {
|
|
476
|
+
status: 503,
|
|
477
|
+
headers: { "Content-Type": "application/json", "Retry-After": "5" },
|
|
478
|
+
}), req, policy);
|
|
481
479
|
}
|
|
482
480
|
|
|
483
481
|
function serverBusyResponse(req: Request, resource: string, policy: RequestPolicyView): Response {
|
|
@@ -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();
|