@modelprofile.com/authswitch 2.1.2

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.
Files changed (95) hide show
  1. package/.smartconfig.json +34 -0
  2. package/cli.js +21 -0
  3. package/dist_ts/00_commitinfo_data.d.ts +8 -0
  4. package/dist_ts/00_commitinfo_data.js +9 -0
  5. package/dist_ts/accounts.d.ts +31 -0
  6. package/dist_ts/accounts.js +74 -0
  7. package/dist_ts/classes.accountlist.d.ts +17 -0
  8. package/dist_ts/classes.accountlist.js +214 -0
  9. package/dist_ts/classes.claudecodeharness.d.ts +34 -0
  10. package/dist_ts/classes.claudecodeharness.js +123 -0
  11. package/dist_ts/classes.claudestatus.d.ts +8 -0
  12. package/dist_ts/classes.claudestatus.js +139 -0
  13. package/dist_ts/classes.cli.d.ts +31 -0
  14. package/dist_ts/classes.cli.js +584 -0
  15. package/dist_ts/classes.codexauth.d.ts +35 -0
  16. package/dist_ts/classes.codexauth.js +184 -0
  17. package/dist_ts/classes.codexdaemon.d.ts +38 -0
  18. package/dist_ts/classes.codexdaemon.js +122 -0
  19. package/dist_ts/classes.codexharness.d.ts +35 -0
  20. package/dist_ts/classes.codexharness.js +199 -0
  21. package/dist_ts/classes.codexhome.d.ts +25 -0
  22. package/dist_ts/classes.codexhome.js +60 -0
  23. package/dist_ts/classes.codexpreuse.d.ts +8 -0
  24. package/dist_ts/classes.codexpreuse.js +90 -0
  25. package/dist_ts/classes.codexstatus.d.ts +20 -0
  26. package/dist_ts/classes.codexstatus.js +304 -0
  27. package/dist_ts/classes.codexswitcher.d.ts +61 -0
  28. package/dist_ts/classes.codexswitcher.js +226 -0
  29. package/dist_ts/classes.credentialstore.d.ts +32 -0
  30. package/dist_ts/classes.credentialstore.js +115 -0
  31. package/dist_ts/classes.fileharness.d.ts +47 -0
  32. package/dist_ts/classes.fileharness.js +119 -0
  33. package/dist_ts/classes.login.d.ts +42 -0
  34. package/dist_ts/classes.login.js +102 -0
  35. package/dist_ts/classes.opencodeharness.d.ts +34 -0
  36. package/dist_ts/classes.opencodeharness.js +125 -0
  37. package/dist_ts/classes.operations.d.ts +44 -0
  38. package/dist_ts/classes.operations.js +97 -0
  39. package/dist_ts/classes.remotecontrol.d.ts +46 -0
  40. package/dist_ts/classes.remotecontrol.js +169 -0
  41. package/dist_ts/classes.service.d.ts +47 -0
  42. package/dist_ts/classes.service.js +142 -0
  43. package/dist_ts/classes.stashstore.d.ts +63 -0
  44. package/dist_ts/classes.stashstore.js +192 -0
  45. package/dist_ts/classes.tui.d.ts +11 -0
  46. package/dist_ts/classes.tui.js +195 -0
  47. package/dist_ts/formatting.d.ts +8 -0
  48. package/dist_ts/formatting.js +13 -0
  49. package/dist_ts/helpers.d.ts +39 -0
  50. package/dist_ts/helpers.js +92 -0
  51. package/dist_ts/index.d.ts +22 -0
  52. package/dist_ts/index.js +27 -0
  53. package/dist_ts/interfaces.d.ts +145 -0
  54. package/dist_ts/interfaces.harness.d.ts +130 -0
  55. package/dist_ts/interfaces.harness.js +2 -0
  56. package/dist_ts/interfaces.js +2 -0
  57. package/dist_ts/interfaces.list.d.ts +19 -0
  58. package/dist_ts/interfaces.list.js +2 -0
  59. package/dist_ts/plugins.d.ts +14 -0
  60. package/dist_ts/plugins.js +18 -0
  61. package/dist_ts/preuse.d.ts +6 -0
  62. package/dist_ts/preuse.js +13 -0
  63. package/license.md +21 -0
  64. package/package.json +53 -0
  65. package/readme.md +502 -0
  66. package/ts/00_commitinfo_data.ts +8 -0
  67. package/ts/accounts.ts +78 -0
  68. package/ts/classes.accountlist.ts +211 -0
  69. package/ts/classes.claudecodeharness.ts +111 -0
  70. package/ts/classes.claudestatus.ts +99 -0
  71. package/ts/classes.cli.ts +479 -0
  72. package/ts/classes.codexauth.ts +199 -0
  73. package/ts/classes.codexdaemon.ts +137 -0
  74. package/ts/classes.codexharness.ts +233 -0
  75. package/ts/classes.codexhome.ts +68 -0
  76. package/ts/classes.codexpreuse.ts +76 -0
  77. package/ts/classes.codexstatus.ts +257 -0
  78. package/ts/classes.codexswitcher.ts +272 -0
  79. package/ts/classes.credentialstore.ts +101 -0
  80. package/ts/classes.fileharness.ts +120 -0
  81. package/ts/classes.login.ts +103 -0
  82. package/ts/classes.opencodeharness.ts +114 -0
  83. package/ts/classes.operations.ts +101 -0
  84. package/ts/classes.remotecontrol.ts +208 -0
  85. package/ts/classes.service.ts +152 -0
  86. package/ts/classes.stashstore.ts +223 -0
  87. package/ts/classes.tui.ts +138 -0
  88. package/ts/formatting.ts +14 -0
  89. package/ts/helpers.ts +109 -0
  90. package/ts/index.ts +28 -0
  91. package/ts/interfaces.harness.ts +112 -0
  92. package/ts/interfaces.list.ts +19 -0
  93. package/ts/interfaces.ts +158 -0
  94. package/ts/plugins.ts +20 -0
  95. package/ts/preuse.ts +15 -0
@@ -0,0 +1,76 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { ICodexIdentity } from './interfaces.js';
3
+ import type { IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
4
+ import { PreuseError, validatePreuseOptions } from './preuse.js';
5
+
6
+ /** One tool-free request through the FlexHarness ChatGPT provider, with no credential writes. */
7
+ export class CodexPreuse {
8
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
9
+
10
+ public async run(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {
11
+ validatePreuseOptions(optionsArg);
12
+ if (identityArg.kind !== 'chatgpt') throw new PreuseError('Codex preuse requires a ChatGPT subscription login; API-key logins have no subscription reset window.');
13
+ if (!identityArg.accountId) throw new PreuseError('This login has no account ID for preuse.');
14
+ const signal = optionsArg.signal
15
+ ? AbortSignal.any([optionsArg.signal, AbortSignal.timeout(180_000)])
16
+ : AbortSignal.timeout(180_000);
17
+ let credentials: plugins.flexAuth.IOpenAiChatGptAuthCredentials;
18
+ let oauth: plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
19
+ try {
20
+ const tokens = JSON.parse(rawArg).tokens;
21
+ oauth = { kind: 'chatgptOAuth', providerId: 'openai', accessToken: tokens.access_token, refreshToken: tokens.refresh_token, idToken: tokens.id_token };
22
+ credentials = plugins.flexAccounts.toOpenAiChatGptAuthCredentials(oauth);
23
+ if (credentials.accountId !== identityArg.accountId) throw new Error('Identity mismatch');
24
+ } catch {
25
+ throw new PreuseError('No complete, matching Codex login is available for preuse. Log in again with Codex and save the account.');
26
+ }
27
+ let model = optionsArg.model;
28
+ let reasoningEffort: string | undefined;
29
+ if (!model) {
30
+ const adapter = new plugins.flexAccounts.OpenAiProviderAdapter({ fetch: this.fetcher });
31
+ try {
32
+ const catalog = await adapter.listModels(oauth, { signal, timeoutMs: 10_000 });
33
+ if (!catalog.success) throw new PreuseError(catalog.errorCode === 'CREDENTIAL_REFRESH_REQUIRED'
34
+ ? 'The saved login has expired. Log in again with Codex and save it before preuse.'
35
+ : 'Could not read the account model catalog. No preuse prompt was sent.');
36
+ const selected = catalog.models.find(item => item.isDefault && item.inputModalities.includes('text'));
37
+ if (!selected) throw new PreuseError('The account catalog has no default text model. Specify --model explicitly.');
38
+ model = selected.wireModel;
39
+ reasoningEffort = ['none', 'minimal', 'low'].find(effort => selected.reasoningEfforts.some(item => item.effort === effort)) ?? selected.defaultReasoningEffort;
40
+ } catch (error) {
41
+ if (error instanceof PreuseError) throw error;
42
+ throw new PreuseError('Could not read the account model catalog. No preuse prompt was sent.');
43
+ } finally { await adapter.dispose(); }
44
+ }
45
+ let requestStarted = false;
46
+ try {
47
+ signal.throwIfAborted();
48
+ const connection = plugins.flexAuth.createOpenAiChatGptModelConnection(credentials);
49
+ connection.settings.fetch = this.fetcher;
50
+ const languageModel = plugins.flexOpenAi.createOpenAiModelProvider().getModel({ provider: 'openai', model, connection });
51
+ requestStarted = true;
52
+ const stream = plugins.flexModels.streamText({
53
+ model: languageModel,
54
+ instructions: 'Answer the user prompt with plain text. Do not use tools.',
55
+ prompt: optionsArg.prompt,
56
+ providerOptions: { openai: { store: false, ...(reasoningEffort ? { reasoningEffort } : {}) } },
57
+ abortSignal: signal,
58
+ maxRetries: 0,
59
+ // Handle errors here without the SDK's default raw-error console output.
60
+ onError: () => {},
61
+ });
62
+ // Drain the stream; generated prose is neither printed nor persisted.
63
+ for await (const part of stream.fullStream) {
64
+ if (part.type === 'error' || part.type === 'abort') throw new Error('Inference did not complete');
65
+ }
66
+ const [finishReason, usage] = await Promise.all([stream.finishReason, stream.totalUsage]);
67
+ if (finishReason !== 'stop' && finishReason !== 'length') throw new Error('Inference did not complete');
68
+ const count = (value: number | undefined) => Number.isSafeInteger(value) && value! >= 0 ? value : undefined;
69
+ return { model, inputTokens: count(usage.inputTokens), outputTokens: count(usage.outputTokens), totalTokens: count(usage.totalTokens) };
70
+ } catch {
71
+ throw new PreuseError(requestStarted
72
+ ? 'Preuse did not complete. Tokens may already have been consumed; no automatic retry was made. Check the account status before trying again.'
73
+ : 'Preuse was cancelled before the prompt was sent.');
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,257 @@
1
+ import type { ICodexIdentity } from './interfaces.js';
2
+ import type { IHarnessAccountStatus, IHarnessStatusSummary } from './interfaces.harness.js';
3
+ import { commitinfo } from './00_commitinfo_data.js';
4
+
5
+ // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
6
+ const billingUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
7
+
8
+ const record = (valueArg: unknown): Record<string, unknown> => {
9
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) throw new Error('Unexpected status response.');
10
+ return valueArg as Record<string, unknown>;
11
+ };
12
+ const numeric = (valueArg: unknown): number => {
13
+ if (typeof valueArg !== 'number' || !Number.isFinite(valueArg) || valueArg < 0) throw new Error('Unexpected numeric status field.');
14
+ return valueArg;
15
+ };
16
+ const text = (valueArg: unknown): string => {
17
+ if (typeof valueArg !== 'string' || !valueArg.trim()) throw new Error('Unexpected text status field.');
18
+ return valueArg;
19
+ };
20
+ const boolean = (valueArg: unknown): boolean => {
21
+ if (typeof valueArg !== 'boolean') throw new Error('Unexpected boolean status field.');
22
+ return valueArg;
23
+ };
24
+ const timestamp = (secondsArg: unknown): string => new Date(numeric(secondsArg) * 1000).toISOString();
25
+ const count = (valueArg: unknown): number => {
26
+ const value = numeric(valueArg);
27
+ if (!Number.isSafeInteger(value)) throw new Error('Invalid status count.');
28
+ return value;
29
+ };
30
+
31
+ /** Only fixed, local diagnostics may cross the HTTP error boundary. */
32
+ class StatusRequestError extends Error {}
33
+
34
+ /**
35
+ * Read-only ChatGPT status contract verified against Codex 0.154.0.
36
+ * These source-defined backend routes are isolated here. No token refresh,
37
+ * credential writes, app-server lifecycle changes or reset consumption occur.
38
+ */
39
+ export class CodexAccountStatus {
40
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
41
+
42
+ public async read(rawArg: string, identityArg: ICodexIdentity): Promise<IHarnessAccountStatus> {
43
+ if (identityArg.kind !== 'chatgpt') return {
44
+ facts: [{ label: 'Authentication', value: 'API key' }, { label: 'Subscription and usage', value: 'Not exposed by Codex for API-key logins' }], problems: [],
45
+ };
46
+ let accessToken: string;
47
+ try { accessToken = text(record(record(JSON.parse(rawArg)).tokens).access_token); }
48
+ catch { return { facts: [], problems: ['The login has no usable access token. Log in again with Codex, then save the account.'] }; }
49
+ if (!identityArg.accountId) return { facts: [], problems: ['The login has no account ID for a status lookup.'] };
50
+ return this.readCredential(accessToken, identityArg.accountId, identityArg.planType);
51
+ }
52
+
53
+ /** Read the ChatGPT account directly, independent of a harness's on-disk format. */
54
+ public async readCredential(accessToken: string, accountId: string, planType?: string | null): Promise<IHarnessAccountStatus> {
55
+ const result: IHarnessAccountStatus = { facts: [], problems: [] };
56
+ const identityArg = { accountId, planType };
57
+ const sections = await Promise.allSettled([
58
+ this.get('wham/usage', accessToken, identityArg.accountId),
59
+ this.get('wham/rate-limit-reset-credits', accessToken, identityArg.accountId),
60
+ this.get('wham/profiles/me', accessToken, identityArg.accountId),
61
+ this.get('accounts/check/v4-2023-04-27', accessToken, identityArg.accountId),
62
+ ]);
63
+ const usage = sections[0];
64
+ if (usage.status === 'fulfilled' && usage.value.account_id != null && usage.value.account_id !== identityArg.accountId) {
65
+ return { facts: [], problems: ['The status service returned a different account ID; no account data was displayed.'] };
66
+ }
67
+ const parsers = [
68
+ (bodyArg: Record<string, unknown>) => this.limits(bodyArg, identityArg.accountId!),
69
+ (bodyArg: Record<string, unknown>) => this.resets(bodyArg),
70
+ (bodyArg: Record<string, unknown>) => ({ facts: this.activity(bodyArg), summary: undefined }),
71
+ (bodyArg: Record<string, unknown>) => this.billing(bodyArg, identityArg.accountId!),
72
+ ];
73
+ const labels = ['Subscription and limits', 'Earned reset details', 'Token usage', 'Billing'];
74
+ for (let index = 0; index < sections.length; index++) {
75
+ const section = sections[index];
76
+ if (section.status === 'rejected') {
77
+ result.problems.push(`${labels[index]}: ${section.reason instanceof Error ? section.reason.message : 'Request failed.'}`);
78
+ continue;
79
+ }
80
+ try {
81
+ // Each parser validates its whole section before returning any facts or summary.
82
+ const parsed = parsers[index](section.value);
83
+ result.facts.push(...parsed.facts);
84
+ if (parsed.summary) result.summary = { ...result.summary, ...parsed.summary };
85
+ }
86
+ catch { result.problems.push(`${labels[index]}: the service returned an unsupported response; no values were inferred.`); }
87
+ }
88
+ if (!result.summary?.subscription && identityArg.planType) {
89
+ result.facts.unshift({ section: 'Subscription', summaryKey: 'subscription', label: 'Last known plan', value: `${identityArg.planType} (from stored login; not verified live)` });
90
+ result.summary = { ...result.summary, subscription: { plan: identityArg.planType, source: 'stored' } };
91
+ }
92
+ return result;
93
+ }
94
+
95
+ private async get(pathArg: string, tokenArg: string, accountIdArg: string): Promise<Record<string, unknown>> {
96
+ const signal = AbortSignal.timeout(10_000);
97
+ try {
98
+ const response = await this.fetcher(`https://chatgpt.com/backend-api/${pathArg}`, {
99
+ method: 'GET', redirect: 'error', signal,
100
+ headers: { Authorization: `Bearer ${tokenArg}`, 'ChatGPT-Account-ID': accountIdArg,
101
+ 'User-Agent': `authswitch/${commitinfo.version}`, Accept: 'application/json',
102
+ ...(pathArg === 'accounts/check/v4-2023-04-27' ? { originator: 'Codex Desktop', 'OAI-Language': 'en', 'User-Agent': billingUserAgent } : {}) },
103
+ });
104
+ if (!response.ok) {
105
+ await response.body?.cancel();
106
+ if (response.headers.get('cf-mitigated') === 'challenge') throw new StatusRequestError('Blocked by a service verification challenge (Cloudflare); this lookup is unavailable to the current HTTP client.');
107
+ if (response.status === 401) throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
108
+ if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
109
+ throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
110
+ }
111
+ const reader = response.body?.getReader();
112
+ if (!reader) throw new StatusRequestError('The service returned an empty response.');
113
+ const chunks: Uint8Array[] = [];
114
+ let size = 0;
115
+ try {
116
+ while (true) {
117
+ const part = await reader.read();
118
+ if (part.done) break;
119
+ size += part.value.byteLength;
120
+ if (size > 1024 * 1024) { await reader.cancel(); throw new StatusRequestError('The status response is too large.'); }
121
+ chunks.push(part.value);
122
+ }
123
+ } finally { reader.releaseLock(); }
124
+ try { return record(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
125
+ catch { throw new StatusRequestError('The service returned invalid status data.'); }
126
+ } catch (errorArg) {
127
+ // Never surface remote bodies, request headers, token strings or arbitrary fetch errors.
128
+ if (signal.aborted) throw new Error('The status request timed out.');
129
+ if (errorArg instanceof StatusRequestError) throw errorArg;
130
+ throw new Error('The status service could not be reached.');
131
+ }
132
+ }
133
+
134
+ /** Desktop 26.908.40834 account-check contract; select the credential's exact account. */
135
+ private billing(bodyArg: Record<string, unknown>, accountIdArg: string): Pick<IHarnessAccountStatus, 'facts' | 'summary'> {
136
+ const accounts = record(bodyArg.accounts);
137
+ const match = (valueArg: unknown) => {
138
+ const value = record(valueArg);
139
+ return record(value.account).account_id === accountIdArg;
140
+ };
141
+ // A default workspace must never supply another account's billing information.
142
+ const selected = Object.hasOwn(accounts, accountIdArg) ? accounts[accountIdArg]
143
+ : Object.values(accounts).find(value => match(value));
144
+ if (selected === undefined || !match(selected)) throw new Error('No matching billing account.');
145
+ const account = record(selected);
146
+ const entitlement = record(account.entitlement);
147
+ const subscription = record(account.last_active_subscription);
148
+ const billing: NonNullable<IHarnessStatusSummary['billing']> = {};
149
+ if (entitlement.has_active_subscription != null) billing.hasActiveSubscription = boolean(entitlement.has_active_subscription);
150
+ if (subscription.will_renew != null) billing.autoRenew = boolean(subscription.will_renew) && entitlement.cancels_at == null;
151
+ for (const [field, key] of [['renews_at', 'renewsAt'], ['cancels_at', 'cancelsAt'], ['expires_at', 'expiresAt']] as const) {
152
+ if (entitlement[field] != null) billing[key] = new Date(text(entitlement[field])).toISOString();
153
+ }
154
+ if (!Object.keys(billing).length) throw new Error('No billing fields returned.');
155
+ const facts: IHarnessAccountStatus['facts'] = [];
156
+ const append = (label: string, value: string) => facts.push({ section: 'Billing', summaryKey: 'billing', label, value });
157
+ if (billing.hasActiveSubscription !== undefined) append('Active subscription', billing.hasActiveSubscription ? 'yes' : 'no');
158
+ if (billing.autoRenew !== undefined) append('Automatic renewal', billing.autoRenew ? 'on' : 'off');
159
+ if (billing.renewsAt) append('Renewal date (UTC)', billing.renewsAt);
160
+ if (billing.cancelsAt) append('Cancellation date (UTC)', billing.cancelsAt);
161
+ if (billing.expiresAt) append('Subscription expiry (UTC)', billing.expiresAt);
162
+ return { facts, summary: { billing } };
163
+ }
164
+
165
+ private limits(bodyArg: Record<string, unknown>, accountIdArg: string): Pick<IHarnessAccountStatus, 'facts' | 'summary'> {
166
+ if (bodyArg.account_id != null && bodyArg.account_id !== accountIdArg) throw new Error('Account mismatch.');
167
+ const plan = text(bodyArg.plan_type);
168
+ const facts: IHarnessAccountStatus['facts'] = [{ section: 'Subscription', summaryKey: 'subscription', label: 'Subscription plan', value: `${plan} (live)` }];
169
+ const usageWindows: NonNullable<IHarnessStatusSummary['usageWindows']> = [];
170
+ const summary: IHarnessStatusSummary = { subscription: { plan, source: 'live' }, usageWindows };
171
+ const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
172
+ if (valueArg == null) return;
173
+ const limit = record(valueArg);
174
+ facts.push({ section: 'Limits & credits', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
175
+ facts.push({ section: 'Limits & credits', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
176
+ for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
177
+ if (limit[key] == null) continue;
178
+ const window = record(limit[key]);
179
+ const used = numeric(window.used_percent);
180
+ const durationSeconds = numeric(window.limit_window_seconds);
181
+ const minutes = durationSeconds / 60;
182
+ const duration = minutes % 1440 === 0 ? `${minutes / 1440}d` : minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
183
+ const resetAt = timestamp(window.reset_at);
184
+ usageWindows.push({ label: `${nameArg} ${name}`, scope: scopeArg, durationSeconds, usedPercent: used, resetAt });
185
+ facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: `${nameArg} ${name} (${duration})`, value: `${used}% used, ${Math.max(0, 100 - used)}% remaining; resets ${resetAt}` });
186
+ }
187
+ };
188
+ appendLimit('Codex', bodyArg.rate_limit, 'account');
189
+ if (bodyArg.additional_rate_limits != null) {
190
+ if (!Array.isArray(bodyArg.additional_rate_limits)) throw new Error('Invalid additional limits.');
191
+ for (const value of bodyArg.additional_rate_limits) {
192
+ const limit = record(value);
193
+ appendLimit(text(limit.limit_name), limit.rate_limit, 'feature');
194
+ }
195
+ }
196
+ if (bodyArg.credits != null) {
197
+ const credits = record(bodyArg.credits);
198
+ const unlimited = boolean(credits.unlimited);
199
+ const hasCredits = boolean(credits.has_credits);
200
+ facts.push({ section: 'Limits & credits', label: 'Credits', value: unlimited ? 'unlimited' : credits.balance != null ? text(credits.balance) : hasCredits ? 'available (balance not returned)' : 'none available' });
201
+ }
202
+ if (bodyArg.spend_control != null) {
203
+ const spend = record(bodyArg.spend_control);
204
+ facts.push({ section: 'Limits & credits', label: 'Spend limit reached', value: boolean(spend.reached) ? 'yes' : 'no' });
205
+ if (spend.individual_limit != null) {
206
+ const limit = record(spend.individual_limit);
207
+ facts.push({ section: 'Limits & credits', label: 'Individual spend', value: `${text(limit.used)} used of ${text(limit.limit)}; ${text(limit.remaining)} remaining; resets ${timestamp(limit.reset_at)}` });
208
+ }
209
+ }
210
+ if (bodyArg.rate_limit_reached_type != null) facts.push({ section: 'Limits & credits', label: 'Limit state', value: text(record(bodyArg.rate_limit_reached_type).type) });
211
+ if (bodyArg.rate_limit_reset_credits != null) {
212
+ summary.resets = { available: count(record(bodyArg.rate_limit_reset_credits).available_count) };
213
+ facts.push({ section: 'Earned resets', summaryKey: 'resets', label: 'Available earned resets', value: String(summary.resets.available) });
214
+ }
215
+ else facts.push({ section: 'Earned resets', summaryKey: 'resets', label: 'Available earned resets', value: 'Not returned by the limits service' });
216
+ return { facts, summary };
217
+ }
218
+
219
+ private resets(bodyArg: Record<string, unknown>): Pick<IHarnessAccountStatus, 'facts' | 'summary'> {
220
+ const availableCount = count(bodyArg.available_count);
221
+ if (!Array.isArray(bodyArg.credits)) throw new Error('Invalid reset details.');
222
+ const facts: IHarnessAccountStatus['facts'] = [{ section: 'Earned resets', summaryKey: 'resets', label: 'Earned resets (detail lookup)', value: `${availableCount} available` }];
223
+ const details: NonNullable<NonNullable<IHarnessStatusSummary['resets']>['details']> = [];
224
+ for (const raw of bodyArg.credits) {
225
+ const credit = record(raw);
226
+ const expiresAt = credit.expires_at == null ? null : new Date(text(credit.expires_at)).toISOString();
227
+ const status = text(credit.status);
228
+ const kind = text(credit.reset_type);
229
+ details.push({ status, kind, expiresAt });
230
+ facts.push({ section: 'Earned resets', summaryKey: 'resetDetails', label: 'Earned reset', value: `${status}; ${kind}; ${expiresAt === null ? 'no expiry reported' : 'expires ' + expiresAt}` });
231
+ }
232
+ if (bodyArg.credits.length < availableCount) facts.push({ section: 'Earned resets', label: 'Reset details', value: `${bodyArg.credits.length} shown; the available count includes all credits` });
233
+ // A successful detail lookup supersedes the count from the limits response.
234
+ return { facts, summary: { resets: { available: availableCount, details } } };
235
+ }
236
+
237
+ private activity(bodyArg: Record<string, unknown>): IHarnessAccountStatus['facts'] {
238
+ const stats = record(bodyArg.stats);
239
+ const facts: IHarnessAccountStatus['facts'] = [];
240
+ for (const [key, label] of [
241
+ ['lifetime_tokens', 'Lifetime tokens'], ['peak_daily_tokens', 'Peak daily tokens'],
242
+ ['longest_running_turn_sec', 'Longest turn (seconds)'], ['current_streak_days', 'Current streak (days)'], ['longest_streak_days', 'Longest streak (days)'],
243
+ ]) facts.push({ section: 'Activity', label, value: stats[key] == null ? 'Not returned' : numeric(stats[key]).toLocaleString('en-US') });
244
+ if (stats.daily_usage_buckets != null) {
245
+ if (!Array.isArray(stats.daily_usage_buckets)) throw new Error('Invalid daily usage.');
246
+ const days = stats.daily_usage_buckets.map((valueArg) => {
247
+ const day = record(valueArg);
248
+ const date = text(day.start_date);
249
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error('Invalid daily date.');
250
+ return { date, tokens: numeric(day.tokens) };
251
+ }).sort((leftArg, rightArg) => rightArg.date.localeCompare(leftArg.date));
252
+ for (const day of days.slice(0, 7)) facts.push({ section: 'Daily tokens', label: `Tokens ${day.date}`, value: day.tokens.toLocaleString('en-US') });
253
+ if (days.length > 7) facts.push({ section: 'Daily tokens', label: 'Daily activity', value: `Showing the latest 7 of ${days.length} reported days` });
254
+ }
255
+ return facts;
256
+ }
257
+ }
@@ -0,0 +1,272 @@
1
+ import type {
2
+ IActiveAuth,
3
+ ICodexSwitcherOptions,
4
+ IStashResult,
5
+ IStashListing,
6
+ ISwitchResult,
7
+ } from './interfaces.js';
8
+ import { CodexAuth } from './classes.codexauth.js';
9
+ import { CodexDaemon } from './classes.codexdaemon.js';
10
+ import { CodexHome } from './classes.codexhome.js';
11
+ import { RemoteControlStore } from './classes.remotecontrol.js';
12
+ import { StashStore } from './classes.stashstore.js';
13
+ import { countPaired } from './helpers.js';
14
+
15
+ const KEYRING_MESSAGE =
16
+ 'the active Codex credential is held in an OS keyring, which authswitch cannot move.\n' +
17
+ 'Set `auth_credentials_store_mode` to file storage in ~/.codex/config.toml and log in again to use authswitch.';
18
+
19
+ /**
20
+ * Orchestrates stashing and restoring Codex credentials.
21
+ *
22
+ * Two invariants this class defends:
23
+ *
24
+ * 1. A credential is never destroyed or overwritten until a copy has been
25
+ * written to the stash *and read back*.
26
+ * 2. Every operation that stops the app-server starts it again, including when
27
+ * the operation fails partway. Leaving remote control down would turn a
28
+ * failed switch into a broken machine.
29
+ *
30
+ * Both stash and use quiesce the app-server first. It refreshes tokens on its
31
+ * own schedule, so reading or writing `auth.json` while it runs races that
32
+ * refresh: the copy taken could be a token the daemon has already rotated away,
33
+ * and a credential the daemon wrote after the copy would be destroyed unstashed.
34
+ */
35
+ export class CodexSwitcher {
36
+ public readonly home: CodexHome;
37
+ public readonly auth: CodexAuth;
38
+ public readonly remoteControl: RemoteControlStore;
39
+ public readonly stashes: StashStore;
40
+ public readonly daemon: CodexDaemon;
41
+
42
+ constructor(optionsArg: ICodexSwitcherOptions = {}) {
43
+ this.home = new CodexHome(optionsArg.codexHome);
44
+ this.auth = new CodexAuth(this.home);
45
+ this.remoteControl = new RemoteControlStore(this.home);
46
+ this.stashes = new StashStore('codex', optionsArg.stashRoot);
47
+ this.daemon = new CodexDaemon(this.home, optionsArg.codexBinary);
48
+ }
49
+
50
+ public readActive(): IActiveAuth {
51
+ return this.auth.readActive();
52
+ }
53
+
54
+ /**
55
+ * Saves the active credential under its account email, together with the
56
+ * remote-control enrollments belonging to that account, then clears the active
57
+ * slot so a different account can be logged in.
58
+ *
59
+ * `keepActive` saves without clearing, for taking a checkpoint.
60
+ */
61
+ public stash(optionsArg: { keepActive?: boolean } = {}): IStashResult {
62
+ const notes: string[] = [];
63
+ const problems: string[] = [];
64
+
65
+ const before = this.auth.readActive();
66
+ this.assertNotKeyring(before);
67
+ if (before.source === 'none') {
68
+ // Nothing on disk means nothing to save, and a daemon holding no
69
+ // credential cannot produce one. Fail here rather than bouncing the
70
+ // app-server -- and remote control with it -- to learn the same thing.
71
+ throw new Error(
72
+ `no active Codex credential found at ${before.authFilePath}; run \`codex login\` first`,
73
+ );
74
+ }
75
+
76
+ const daemonWasRunning = this.quiesceDaemon(notes);
77
+ let daemonRestarted = false;
78
+ let entry;
79
+ try {
80
+ // Re-read only now that the daemon is down: whatever it may have refreshed
81
+ // between the check above and the stop is the credential we must save.
82
+ const active = this.auth.readActive();
83
+ this.assertNotKeyring(active);
84
+ if (!active.raw) {
85
+ throw new Error(`no active Codex credential found at ${active.authFilePath}`);
86
+ }
87
+ if (!active.identity) {
88
+ throw new Error(
89
+ `could not identify the account behind ${active.authFilePath}; refusing to stash an unlabelled credential`,
90
+ );
91
+ }
92
+
93
+ const enrollments = this.remoteControl.read(active.identity.accountId);
94
+ entry = this.stashes.write(active.identity, active.raw, enrollments, this.home.dir);
95
+
96
+ if (!this.stashes.readBack(entry.email, active.raw)) {
97
+ throw new Error(
98
+ `the stash for ${entry.email} could not be read back from ${this.stashes.entryDir(entry.email)}; leaving the active credential in place`,
99
+ );
100
+ }
101
+
102
+ if (!optionsArg.keepActive) {
103
+ this.auth.clearActive();
104
+ }
105
+ if (enrollments.length === 0) {
106
+ notes.push('no remote-control enrollment found for this account');
107
+ }
108
+ } finally {
109
+ daemonRestarted = this.resumeDaemon(daemonWasRunning, notes, problems);
110
+ }
111
+
112
+ return {
113
+ entry,
114
+ daemonRestarted,
115
+ complete: problems.length === 0,
116
+ notes,
117
+ problems,
118
+ };
119
+ }
120
+
121
+ /** Every stash, annotated with whether it is the account currently in use. */
122
+ public list(): IStashListing[] {
123
+ const active = this.auth.readActive();
124
+ return this.stashes.list().map((entryArg) => {
125
+ const paired = countPaired(entryArg.enrollments);
126
+ return {
127
+ ...entryArg,
128
+ slug: this.stashes.slugFor(entryArg.email),
129
+ isActive: active.identity !== null && active.identity.email === entryArg.email && active.identity.accountId === entryArg.accountId && active.identity.kind === entryArg.kind,
130
+ enrollmentsTotal: entryArg.enrollments.length,
131
+ enrollmentsPaired: paired,
132
+ hasRemoteControl: paired > 0,
133
+ };
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Makes a stashed account the active one.
139
+ *
140
+ * Re-running it for the account that is already active is not a no-op: it
141
+ * restores that account's enrollments, which is the repair path when remote
142
+ * control has stopped working for the current login.
143
+ */
144
+ public use(emailArg: string): ISwitchResult {
145
+ const target = this.stashes.readMeta(emailArg);
146
+ let targetAuth = this.stashes.readAuth(emailArg);
147
+ if (!target || targetAuth === null) {
148
+ throw new Error(`no stash for "${emailArg}"`);
149
+ }
150
+ const targetIdentity = this.auth.identityFromRaw(targetAuth);
151
+ if (!targetIdentity || targetIdentity.email !== target.email || targetIdentity.accountId !== target.accountId || targetIdentity.kind !== target.kind) {
152
+ throw new Error('the saved Codex credential does not match its metadata; refusing to switch');
153
+ }
154
+
155
+ const notes: string[] = [];
156
+ const problems: string[] = [];
157
+
158
+ this.assertNotKeyring(this.auth.readActive());
159
+
160
+ const daemonWasRunning = this.quiesceDaemon(notes);
161
+ let enrollmentsRestored = 0;
162
+ let fromEmail: string | null = null;
163
+ let daemonRestarted = false;
164
+
165
+ try {
166
+ // Everything below reads the credential state only after the daemon is
167
+ // down, so nothing it refreshed mid-command is missed or overwritten.
168
+ const active = this.auth.readActive();
169
+ this.assertNotKeyring(active);
170
+ fromEmail = active.identity?.email ?? null;
171
+
172
+ if (fromEmail !== null && fromEmail !== target.email) {
173
+ // Never drop a credential on the floor: check the outgoing one into the
174
+ // stash, and prove it landed, before the incoming one overwrites it.
175
+ const outgoingEnrollments = this.remoteControl.read(active.identity!.accountId);
176
+ this.stashes.write(active.identity!, active.raw!, outgoingEnrollments, this.home.dir);
177
+ if (!this.stashes.readBack(fromEmail, active.raw!)) {
178
+ throw new Error(
179
+ `could not save the outgoing credential for ${fromEmail}; aborting before it would be overwritten`,
180
+ );
181
+ }
182
+ notes.push(`stashed the outgoing credential for ${fromEmail}`);
183
+ } else if (fromEmail === null && active.raw) {
184
+ throw new Error(
185
+ `refusing to overwrite the unidentifiable credential at ${active.authFilePath}; move it aside manually first`,
186
+ );
187
+ }
188
+
189
+ if (fromEmail === target.email) {
190
+ if (active.identity!.accountId !== target.accountId || active.identity!.kind !== target.kind) {
191
+ throw new Error('the active and saved Codex accounts share an email but have different identities; refusing to overwrite either credential');
192
+ }
193
+ // A daemon refresh may have made the saved tokens obsolete. Keep the current
194
+ // login while restoring the saved pairing, and verify its updated stash.
195
+ targetAuth = active.raw!;
196
+ this.stashes.write(active.identity!, targetAuth, target.enrollments, this.home.dir);
197
+ if (!this.stashes.readBack(target.email, targetAuth)) throw new Error('could not verify the current credential in its stash; refusing to overwrite it');
198
+ notes.push(`${target.email} was already active; refreshing its remote-control enrollments`);
199
+ }
200
+
201
+ this.auth.writeActive(targetAuth);
202
+
203
+ if (target.enrollments.length > 0) {
204
+ enrollmentsRestored = this.remoteControl.restore(target.enrollments);
205
+ notes.push(`restored ${enrollmentsRestored} remote-control enrollment(s)`);
206
+ } else {
207
+ notes.push('no remote-control enrollment was stashed for this account');
208
+ }
209
+ } finally {
210
+ daemonRestarted = this.resumeDaemon(daemonWasRunning, notes, problems);
211
+ }
212
+
213
+ return {
214
+ from: fromEmail,
215
+ to: target.email,
216
+ enrollmentsRestored,
217
+ daemonRestarted,
218
+ complete: problems.length === 0,
219
+ notes,
220
+ problems,
221
+ };
222
+ }
223
+
224
+ public drop(emailArg: string): boolean {
225
+ return this.stashes.remove(emailArg);
226
+ }
227
+
228
+ private assertNotKeyring(activeArg: IActiveAuth): void {
229
+ if (activeArg.source === 'keyring') {
230
+ throw new Error(KEYRING_MESSAGE);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Stops the app-server if it is up. Returns whether it was running, which is
236
+ * also the signal for whether to start it again afterwards -- a daemon that
237
+ * was down before a command stays down after it.
238
+ */
239
+ private quiesceDaemon(notesArg: string[]): boolean {
240
+ if (!this.daemon.isDaemonRunning()) {
241
+ notesArg.push('app-server was not running');
242
+ return false;
243
+ }
244
+ const stopResult = this.daemon.stop();
245
+ if (!stopResult.ok) {
246
+ throw new Error(
247
+ `could not stop the Codex app-server, refusing to touch credentials underneath it: ${this.daemon.describeFailure(stopResult)}`,
248
+ );
249
+ }
250
+ return true;
251
+ }
252
+
253
+ /** Brings the app-server back, on the success path and the failure path alike. */
254
+ private resumeDaemon(
255
+ wasRunningArg: boolean,
256
+ notesArg: string[],
257
+ problemsArg: string[],
258
+ ): boolean {
259
+ if (!wasRunningArg) {
260
+ return false;
261
+ }
262
+ const startResult = this.daemon.start();
263
+ if (!startResult.ok) {
264
+ problemsArg.push(
265
+ `the app-server did not come back up (${this.daemon.describeFailure(startResult)}); remote control is down until you run \`codex app-server daemon start\``,
266
+ );
267
+ return false;
268
+ }
269
+ notesArg.push('app-server restarted');
270
+ return true;
271
+ }
272
+ }