Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/codex/desktop-compatibility/usage-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export class UsageActivation {
const beforeVerification=this.generation,binding=this.binding;
let verified:string|null;
try{verified=await this.verifyIdentity();}catch{verified=null;}
if(verified!==binding||this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()};
if(this.binding!==binding||this.generation!==beforeVerification)return{accepted:false,reason:'identity-not-verified',...this.snapshot()};
if(verified!==binding){this.invalidateIdentity();return{accepted:false,reason:'identity-not-verified',...this.snapshot()};}
if(this.mode==='apply')return{accepted:false,reason:'activation-already-pending',...this.snapshot()};
const age=this.latest?this.clock()-this.latest.at:Infinity;
if(!this.latest||this.latest.kind!=='exhausted'||age<0||age>300000)return{accepted:false,reason:'no-fresh-eligible-exhaustion',...this.snapshot()};
Expand Down
8 changes: 7 additions & 1 deletion src/codex/desktop-compatibility/usage-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,13 @@ export class UsageRelayController {
|| !Number.isSafeInteger(envelope.sequence) || Number(envelope.sequence) <= 0))) { stream?.exclude(); return null; }
const original = envelope ? envelope.usage : parsed;
if (!object(original) || original.account_id !== this.account.id || original.user_id !== this.account.userId
|| original.plan_type !== this.account.plan) { stream?.exclude(); return null; }
|| original.plan_type !== this.account.plan) {
if (object(original) && [[original.account_id, this.account.id], [original.user_id, this.account.userId],
[original.plan_type, this.account.plan]].some(([actual, expected]) => typeof actual === 'string' && actual.length > 0 && actual !== expected)) {
this.activation.invalidateIdentity();
}
stream?.exclude(); return null;
}
const rate = original.rate_limit;
if (!object(rate) || typeof rate.allowed !== 'boolean' || typeof rate.limit_reached !== 'boolean') {
stream?.exclude(); return null;
Expand Down
5 changes: 5 additions & 0 deletions structure/clients/codex-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ when the ordinary rate limit is exhausted. Their original snapshots disarm Apply
Reserve schemas pass unchanged. Neither observation nor correction changes entitlement state.
Later exhaustion does not resume that trial; it requires another explicit activation.
Pending response checks from the previous generation cannot emit a correction after recovery.
An explicit non-empty foreign account, user or plan in a usage record invalidates the trial,
clears its observation and outputs, and excludes that stream from refresh. A failed fresh
activation identity check also invalidates the current trial. A verification result from an
older binding or generation cannot invalidate a newer trial. Resuming correction requires
another matched exhaustion observation and explicit activation; native cache refresh remains unconfirmed.
Native identity verification also binds an opaque reader-local credential generation from a stable file-stat/content snapshot. Replacement, token rotation and A-to-B-to-A restoration invalidate pending identity checks and response correction; they require a fresh observation runtime. Neither credential hashes nor tokens appear in public status. `tests/clients/desktop-compatibility-native-identity.test.ts` exercises delayed verification and build-check races with synthetic auth files.
Fresh identity checks, generation changes, unknown schemas and elapsed deadlines refuse
correction. `usage-sse-controller.ts` preserves event metadata and original sequence IDs;
Expand Down
93 changes: 93 additions & 0 deletions tests/clients/desktop-compatibility-native-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,96 @@ test("A to B to A during the async build check cannot publish a corrected old re
expect(await correcting).toBeNull(); expect(controller.snapshot().mode).toBe("observe"); expect(controller.snapshot().outputs).toBe(0);
upstream.verify(["synthetic-access-A", "synthetic-access-A"]);
});

for (const pathname of [exchange.pathname, `${exchange.pathname}/stream`]) {
for (const field of ['account_id', 'user_id', 'plan_type'] as const) {
test(`explicit foreign ${field} revokes the trial on ${pathname}`, async () => {
const account = { id: usage.account_id, userId: usage.user_id, plan: 'pro' as const, structure: 'personal' as const };
const ctl = new UsageRelayController(account, async () => account, async () => account, () => 1000, 600000);
const route = { ...exchange, pathname };
const encode = (record: unknown) => JSON.stringify(pathname.endsWith('/stream')
? { version: 1, stream_id: 'fixture', sequence: 1, usage: record } : record);
await ctl.rewriteJson(encode(usage), route);
expect((await ctl.activate(consent)).accepted).toBe(true);
const stream = ctl.registerStream(route, async () => {});
expect(await ctl.rewriteJson(encode(usage), route, stream)).not.toBeNull();
expect(ctl.snapshot().observation.validatedActiveStreams).toBe(pathname.endsWith('/stream') ? 1 : 0);
const generation = ctl.snapshot().generation;
expect(await ctl.rewriteJson(encode({ ...usage, [field]: 'foreign' }), route, stream)).toBeNull();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expect(ctl.snapshot()).toMatchObject({ mode: 'observe', phase: 'identity-invalidated', outputs: 0 });
expect(ctl.snapshot().observation.validatedActiveStreams).toBe(0);
expect(ctl.snapshot().generation).toBeGreaterThan(generation);
expect((await ctl.activate(consent)).accepted).toBe(false);
expect(await ctl.rewriteJson(encode(usage), route)).toBeNull();
expect((await ctl.activate(consent)).accepted).toBe(true);
expect(await ctl.rewriteJson(encode(usage), route)).not.toBeNull();
stream?.release();
});
}
}

for (const failure of ['mismatch', 'null', 'throw'] as const) {
test(`fresh activation identity ${failure} revokes existing consent`, async () => {
const account = { id: usage.account_id, userId: usage.user_id, plan: 'pro' as const, structure: 'personal' as const };
let fail = false;
const ctl = new UsageRelayController(account, async () => account, async () => {
if (!fail) return account;
if (failure === 'throw') throw new Error('synthetic verification failure');
return failure === 'null' ? null : { ...account, userId: 'foreign' };
}, () => 1000, 600000);
await ctl.rewriteJson(JSON.stringify(usage), exchange);
expect((await ctl.activate(consent)).accepted).toBe(true);
fail = true;
expect(await ctl.activate(consent)).toMatchObject({ accepted: false, reason: 'identity-not-verified', mode: 'observe' });
fail = false;
expect((await ctl.activate(consent)).accepted).toBe(false);
expect(await ctl.rewriteJson(JSON.stringify(usage), exchange)).toBeNull();
expect((await ctl.activate(consent)).accepted).toBe(true);
});
}

for (const failure of ['foreign usage', 'failed reauthentication'] as const) {
test(`${failure} cancels a response waiting for its final context check`, async () => {
const account = { id: usage.account_id, userId: usage.user_id, plan: 'pro' as const, structure: 'personal' as const };
let verify = true, waiting = false, entered!: () => void, release!: (valid: boolean) => void;
const started = new Promise<void>(resolve => { entered = resolve; });
const ctl = new UsageRelayController(account, async () => account, async () => verify ? account : null,
() => 1000, 600000, 180000, () => waiting ? (entered(), new Promise<boolean>(resolve => { release = resolve; })) : true);
await ctl.rewriteJson(JSON.stringify(usage), exchange); await ctl.activate(consent);
waiting = true; const pending = ctl.rewriteJson(JSON.stringify(usage), exchange); await started;
waiting = false;
try {
if (failure === 'foreign usage') await ctl.rewriteJson(JSON.stringify({ ...usage, account_id: 'foreign' }), exchange);
else { verify = false; await ctl.activate(consent); }
} finally { release(true); }
expect(await pending).toBeNull();
expect(ctl.snapshot()).toMatchObject({ mode: 'observe', outputs: 0 });
});
}

for (const failure of ['mismatch', 'null', 'throw'] as const) {
test(`stale activation ${failure} cannot cancel a newer trial`, async () => {
const account = { id: usage.account_id, userId: usage.user_id, plan: 'pro' as const, structure: 'personal' as const };
let deferred = false, entered!: () => void, release!: () => void;
const started = new Promise<void>(resolve => { entered = resolve; });
const gate = new Promise<void>(resolve => { release = resolve; });
const ctl = new UsageRelayController(account, async () => account, async () => {
if (!deferred) return account;
entered(); await gate;
if (failure === 'throw') throw new Error('synthetic stale verification failure');
return failure === 'null' ? null : { ...account, userId: 'foreign' };
}, () => 1000, 600000);
await ctl.rewriteJson(JSON.stringify(usage), exchange); await ctl.activate(consent);
deferred = true; const pending = ctl.activate(consent); await started;
deferred = false;
try {
await ctl.observeOnly();
await ctl.rewriteJson(JSON.stringify(usage), exchange);
expect((await ctl.activate(consent)).accepted).toBe(true);
} finally { release(); }
const current = ctl.snapshot();
expect(await pending).toMatchObject({ accepted: false, reason: 'identity-not-verified' });
expect(ctl.snapshot()).toEqual(current);
expect(await ctl.rewriteJson(JSON.stringify(usage), exchange)).not.toBeNull();
});
}
Loading