@modelprofile.com/authswitch 3.3.0 → 5.0.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.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +3 -3
  2. package/dist_ts/accounts.d.ts +52 -15
  3. package/dist_ts/accounts.js +110 -27
  4. package/dist_ts/classes.accountlist.d.ts +0 -25
  5. package/dist_ts/classes.accountlist.js +2 -216
  6. package/dist_ts/classes.claudecodeharness.d.ts +81 -2
  7. package/dist_ts/classes.claudecodeharness.js +208 -14
  8. package/dist_ts/classes.claudecodelocks.d.ts +38 -0
  9. package/dist_ts/classes.claudecodelocks.js +118 -0
  10. package/dist_ts/classes.claudestatus.d.ts +15 -2
  11. package/dist_ts/classes.claudestatus.js +100 -83
  12. package/dist_ts/classes.claudetokenrefresh.d.ts +32 -0
  13. package/dist_ts/classes.claudetokenrefresh.js +77 -0
  14. package/dist_ts/classes.cli.d.ts +14 -7
  15. package/dist_ts/classes.cli.js +126 -66
  16. package/dist_ts/classes.codexharness.d.ts +4 -0
  17. package/dist_ts/classes.codexharness.js +5 -1
  18. package/dist_ts/classes.codexstatus.d.ts +7 -2
  19. package/dist_ts/classes.codexstatus.js +49 -21
  20. package/dist_ts/classes.credentialstore.d.ts +43 -2
  21. package/dist_ts/classes.credentialstore.js +60 -13
  22. package/dist_ts/classes.fileharness.d.ts +45 -21
  23. package/dist_ts/classes.fileharness.js +64 -26
  24. package/dist_ts/classes.limits.d.ts +46 -7
  25. package/dist_ts/classes.limits.js +94 -36
  26. package/dist_ts/classes.listrenderer.d.ts +17 -0
  27. package/dist_ts/classes.listrenderer.js +313 -0
  28. package/dist_ts/classes.login.d.ts +1 -1
  29. package/dist_ts/classes.login.js +1 -1
  30. package/dist_ts/classes.opencodeharness.d.ts +4 -0
  31. package/dist_ts/classes.opencodeharness.js +8 -4
  32. package/dist_ts/classes.operations.js +3 -2
  33. package/dist_ts/classes.tui.js +4 -3
  34. package/dist_ts/classes.watch.d.ts +108 -0
  35. package/dist_ts/classes.watch.js +219 -0
  36. package/dist_ts/classes.watchlock.d.ts +33 -0
  37. package/dist_ts/classes.watchlock.js +118 -0
  38. package/dist_ts/claudehttp.d.ts +39 -0
  39. package/dist_ts/claudehttp.js +83 -0
  40. package/dist_ts/cliargs.d.ts +36 -0
  41. package/dist_ts/cliargs.js +60 -0
  42. package/dist_ts/consoletable.d.ts +21 -0
  43. package/dist_ts/consoletable.js +63 -0
  44. package/dist_ts/helpers.d.ts +7 -0
  45. package/dist_ts/helpers.js +16 -1
  46. package/dist_ts/index.d.ts +3 -0
  47. package/dist_ts/index.js +4 -1
  48. package/dist_ts/interfaces.harness.d.ts +60 -11
  49. package/dist_ts/interfaces.list.d.ts +3 -1
  50. package/dist_ts/plugins.d.ts +7 -0
  51. package/dist_ts/plugins.js +6 -1
  52. package/dist_ts/ratelimit.d.ts +8 -0
  53. package/dist_ts/ratelimit.js +13 -0
  54. package/dist_ts/watchpolicy.d.ts +44 -0
  55. package/dist_ts/watchpolicy.js +82 -0
  56. package/package.json +5 -3
  57. package/readme.md +349 -115
  58. package/ts/00_commitinfo_data.ts +3 -3
  59. package/ts/accounts.ts +122 -35
  60. package/ts/classes.accountlist.ts +2 -219
  61. package/ts/classes.claudecodeharness.ts +199 -13
  62. package/ts/classes.claudecodelocks.ts +133 -0
  63. package/ts/classes.claudestatus.ts +100 -65
  64. package/ts/classes.claudetokenrefresh.ts +85 -0
  65. package/ts/classes.cli.ts +114 -54
  66. package/ts/classes.codexharness.ts +4 -0
  67. package/ts/classes.codexstatus.ts +40 -18
  68. package/ts/classes.credentialstore.ts +78 -10
  69. package/ts/classes.fileharness.ts +76 -34
  70. package/ts/classes.limits.ts +110 -36
  71. package/ts/classes.listrenderer.ts +328 -0
  72. package/ts/classes.login.ts +1 -1
  73. package/ts/classes.opencodeharness.ts +7 -3
  74. package/ts/classes.operations.ts +2 -1
  75. package/ts/classes.tui.ts +3 -2
  76. package/ts/classes.watch.ts +263 -0
  77. package/ts/classes.watchlock.ts +100 -0
  78. package/ts/claudehttp.ts +92 -0
  79. package/ts/cliargs.ts +71 -0
  80. package/ts/consoletable.ts +62 -0
  81. package/ts/helpers.ts +14 -0
  82. package/ts/index.ts +3 -0
  83. package/ts/interfaces.harness.ts +60 -5
  84. package/ts/interfaces.list.ts +3 -1
  85. package/ts/plugins.ts +9 -0
  86. package/ts/ratelimit.ts +14 -0
  87. package/ts/watchpolicy.ts +121 -0
@@ -1,6 +1,8 @@
1
1
  import type { ICodexIdentity } from './interfaces.js';
2
2
  import type { IHarnessAccountStatus, IHarnessStatusOptions, IHarnessStatusSummary } from './interfaces.harness.js';
3
3
  import { commitinfo } from './00_commitinfo_data.js';
4
+ import { usageWindowPeriod } from './accounts.js';
5
+ import { latestRetryAt, retryAtFrom } from './ratelimit.js';
4
6
 
5
7
  // Verified Chromium-compatible billing request profile, independent of any locally installed browser.
6
8
  const billingUserAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
@@ -31,13 +33,22 @@ const count = (valueArg: unknown): number => {
31
33
  /** Only fixed, local diagnostics may cross the HTTP error boundary. */
32
34
  class StatusRequestError extends Error {}
33
35
 
36
+ /** The service refused a section for too many requests; that says nothing about the account's usage. */
37
+ class StatusRateLimitError extends StatusRequestError {
38
+ constructor(public readonly retryAt: string | null) { super('The service is rate limiting requests (HTTP 429); try again later.'); }
39
+ }
40
+
34
41
  /**
35
- * Read-only ChatGPT status contract verified against Codex 0.154.0.
42
+ * Read-only ChatGPT status contract verified against Codex 0.155.0.
36
43
  * These source-defined backend routes are isolated here. No token refresh,
37
44
  * credential writes, app-server lifecycle changes or reset consumption occur.
45
+ *
46
+ * The service decides how many usage windows an account has: it sends the ones it keeps for that
47
+ * account, leaving the other slot `null`, so a login whose general quota is a single weekly window is
48
+ * reported with that one window and no five-hour limit is invented to fill the gap.
38
49
  */
39
50
  export class CodexAccountStatus {
40
- constructor(private readonly fetcher: typeof fetch = globalThis.fetch) {}
51
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
41
52
 
42
53
  public async read(rawArg: string, identityArg: ICodexIdentity, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
43
54
  if (identityArg.kind !== 'chatgpt') return {
@@ -85,6 +96,8 @@ export class CodexAccountStatus {
85
96
  }
86
97
  catch { result.problems.push(`${labels[index]}: the service returned an unsupported response; no values were inferred.`); }
87
98
  }
99
+ const limited = sections.flatMap(section => section.status === 'rejected' && section.reason instanceof StatusRateLimitError ? [section.reason] : []);
100
+ if (limited.length) result.rateLimit = { retryAt: latestRetryAt(limited.map(error => error.retryAt)) };
88
101
  if (!result.summary?.subscription && identityArg.planType) {
89
102
  result.facts.unshift({ section: 'Subscription', summaryKey: 'subscription', label: 'Last known plan', value: `${identityArg.planType} (from stored login; not verified live)` });
90
103
  result.summary = { ...result.summary, subscription: { plan: identityArg.planType, source: 'stored' } };
@@ -105,6 +118,7 @@ export class CodexAccountStatus {
105
118
  if (!response.ok) {
106
119
  await response.body?.cancel();
107
120
  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.');
121
+ if (response.status === 429) throw new StatusRateLimitError(retryAtFrom(response.headers.get('retry-after'), this.now()));
108
122
  if (response.status === 401) throw new StatusRequestError('Login expired or was rejected. Log in again with the account’s harness, then save the account.');
109
123
  if (response.status === 403) throw new StatusRequestError('Access denied by the service (HTTP 403).');
110
124
  throw new StatusRequestError(`Service returned HTTP ${response.status}.`);
@@ -157,9 +171,9 @@ export class CodexAccountStatus {
157
171
  const append = (label: string, value: string) => facts.push({ section: 'Billing', summaryKey: 'billing', label, value });
158
172
  if (billing.hasActiveSubscription !== undefined) append('Active subscription', billing.hasActiveSubscription ? 'yes' : 'no');
159
173
  if (billing.autoRenew !== undefined) append('Automatic renewal', billing.autoRenew ? 'on' : 'off');
160
- if (billing.renewsAt) append('Renewal date (UTC)', billing.renewsAt);
161
- if (billing.cancelsAt) append('Cancellation date (UTC)', billing.cancelsAt);
162
- if (billing.expiresAt) append('Subscription expiry (UTC)', billing.expiresAt);
174
+ if (billing.renewsAt) append('Renewal date', billing.renewsAt);
175
+ if (billing.cancelsAt) append('Cancellation date', billing.cancelsAt);
176
+ if (billing.expiresAt) append('Subscription expiry', billing.expiresAt);
163
177
  return { facts, summary: { billing } };
164
178
  }
165
179
 
@@ -172,18 +186,26 @@ export class CodexAccountStatus {
172
186
  const appendLimit = (nameArg: string, valueArg: unknown, scopeArg: 'account' | 'feature') => {
173
187
  if (valueArg == null) return;
174
188
  const limit = record(valueArg);
175
- facts.push({ section: 'Limits & credits', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
176
- facts.push({ section: 'Limits & credits', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
177
- for (const [key, name] of [['primary_window', 'primary'], ['secondary_window', 'secondary']]) {
178
- if (limit[key] == null) continue;
179
- const window = record(limit[key]);
180
- const used = numeric(window.used_percent);
181
- const durationSeconds = numeric(window.limit_window_seconds);
182
- const minutes = durationSeconds / 60;
183
- const duration = minutes % 1440 === 0 ? `${minutes / 1440}d` : minutes % 60 === 0 ? `${minutes / 60}h` : `${minutes}m`;
184
- const resetAt = timestamp(window.reset_at);
185
- usageWindows.push({ label: `${nameArg} ${name}`, scope: scopeArg, durationSeconds, usedPercent: used, resetAt });
186
- facts.push({ section: 'Usage', summaryKey: 'usageWindows', label: `${nameArg} ${name} (${duration})`, value: `${used}% used, ${Math.max(0, 100 - used)}% remaining; resets ${resetAt}` });
189
+ // The service's own reading of the same windows, so a view that shows the windows need not repeat it.
190
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} usage allowed`, value: boolean(limit.allowed) ? 'yes' : 'no' });
191
+ facts.push({ section: 'Limits & credits', summaryKey: 'usageWindows', label: `${nameArg} limit reached`, value: boolean(limit.limit_reached) ? 'yes' : 'no' });
192
+ // Both slots are read before either is named: only a second window of the same length makes the
193
+ // vendor's slot worth showing, and that is decided from the pair.
194
+ const windows = (['primary_window', 'secondary_window'] as const).flatMap((slotArg) => {
195
+ if (limit[slotArg] == null) return [];
196
+ const window = record(limit[slotArg]);
197
+ return [{
198
+ slot: slotArg === 'primary_window' ? 'primary' : 'secondary',
199
+ usedPercent: numeric(window.used_percent), durationSeconds: numeric(window.limit_window_seconds), resetAt: timestamp(window.reset_at),
200
+ }];
201
+ });
202
+ for (const window of windows) {
203
+ // Named by its length, as Codex names its own windows: a slot carries no length, and which
204
+ // window the service puts in it varies by plan and by which limit is currently binding.
205
+ const shared = windows.some(other => other !== window && other.durationSeconds === window.durationSeconds);
206
+ const label = `${nameArg} ${usageWindowPeriod(window.durationSeconds)}${shared ? ` (${window.slot})` : ''}`;
207
+ usageWindows.push({ label, scope: scopeArg, durationSeconds: window.durationSeconds, usedPercent: window.usedPercent, resetAt: window.resetAt });
208
+ facts.push({ section: 'Usage', summaryKey: 'usageWindows', label, value: `${window.usedPercent}% used, ${Math.max(0, 100 - window.usedPercent)}% remaining; resets ${window.resetAt}` });
187
209
  }
188
210
  };
189
211
  appendLimit('Codex', bodyArg.rate_limit, 'account');
@@ -251,7 +273,7 @@ export class CodexAccountStatus {
251
273
  return { date, tokens: numeric(day.tokens) };
252
274
  }).sort((leftArg, rightArg) => rightArg.date.localeCompare(leftArg.date));
253
275
  for (const day of days.slice(0, 7)) facts.push({ section: 'Daily tokens', label: `Tokens ${day.date}`, value: day.tokens.toLocaleString('en-US') });
254
- if (days.length > 7) facts.push({ section: 'Daily tokens', label: 'Daily activity', value: `Showing the latest 7 of ${days.length} reported days` });
276
+ facts.push({ section: 'Daily tokens', label: 'Reported days', value: String(days.length) });
255
277
  }
256
278
  return facts;
257
279
  }
@@ -1,5 +1,5 @@
1
1
  import * as plugins from './plugins.js';
2
- import { writeSecretFileAtomically } from './helpers.js';
2
+ import { errorCode, pause, writeSecretFileAtomically } from './helpers.js';
3
3
 
4
4
  export const credentialRecord = (value: unknown): Record<string, unknown> => {
5
5
  if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Unsupported credential document.');
@@ -62,17 +62,45 @@ export interface IStoredCredential {
62
62
  savedAt: string;
63
63
  /** Harness-owned payload. This object must never cross the public account interface. */
64
64
  credential: Record<string, unknown>;
65
+ /**
66
+ * A refresh token of this credential that the provider refused, as a hash -- never the token.
67
+ *
68
+ * A grant the provider rejected stays rejected, so replaying it only adds traffic to an auth endpoint, once per
69
+ * status read for as long as the record holds that token. Remembering it in the record instead of in memory is
70
+ * what makes that true for the next process too. Every write that replaces the credential drops it.
71
+ */
72
+ rejectedGrant?: string;
65
73
  }
66
74
 
75
+ /** The root of authswitch's own state: `AUTHSWITCH_HOME`, or `~/.authswitch`. */
76
+ export const authSwitchHome = (envArg: NodeJS.ProcessEnv = process.env): string =>
77
+ envArg.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch');
78
+
79
+ export interface ICredentialStoreOptions {
80
+ /**
81
+ * How long an operation waits for another authswitch operation on the same store, 45 seconds by default: longer
82
+ * than the longest operation that holds the lock -- a Claude Code login refresh (30 s) plus the 15 s a native
83
+ * write waits for Claude Code's own locks -- so such operations queue instead of being told the store is locked.
84
+ */
85
+ lockTimeoutMs?: number;
86
+ }
87
+
88
+ const DEFAULT_LOCK_TIMEOUT_MS = 45_000;
89
+ const LOCK_RETRY_MS = 50;
90
+
67
91
  /** A single atomic, owner-only record keeps the saved credential and its identity together. */
68
92
  export class CredentialStore {
69
93
  public readonly dir: string;
70
- constructor(harness: string, root = process.env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')) {
94
+ private readonly lockTimeoutMs: number;
95
+ constructor(harness: string, root = authSwitchHome(), optionsArg: ICredentialStoreOptions = {}) {
71
96
  this.dir = plugins.path.join(root, harness);
97
+ this.lockTimeoutMs = optionsArg.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
72
98
  }
73
99
  public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
100
+ /** Whether a value names a credential hash: a rejected grant, or an account id. */
101
+ private isHash(value: unknown): boolean { return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); }
74
102
  private file(id: string): string {
75
- if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid saved account ID.');
103
+ if (!this.isHash(id)) throw new Error('Invalid saved account ID.');
76
104
  return plugins.path.join(this.dir, `${id}.json`);
77
105
  }
78
106
  public read(id: string): IStoredCredential {
@@ -81,6 +109,9 @@ export class CredentialStore {
81
109
  || !credentialText(value.label) || typeof value.savedAt !== 'string' || !Number.isFinite(Date.parse(value.savedAt))
82
110
  || this.id(value.slotId as string, value.identity as string) !== id) throw new Error('Saved account metadata is invalid; restore it from a verified backup.');
83
111
  credentialRecord(value.credential);
112
+ // Advisory state: a rejected grant that is not a hash says nothing about the login and is dropped, never made
113
+ // the record's problem -- the grant is sent once more instead, which is what a record without one means anyway.
114
+ if (!this.isHash(value.rejectedGrant)) delete value.rejectedGrant;
84
115
  return value as unknown as IStoredCredential;
85
116
  }
86
117
  public list(): IStoredCredential[] {
@@ -89,12 +120,33 @@ export class CredentialStore {
89
120
  .map(name => this.read(name.slice(0, -5))).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
90
121
  }
91
122
  public save(entry: Omit<IStoredCredential, 'schemaVersion' | 'id' | 'savedAt'>): IStoredCredential {
92
- const record: IStoredCredential = { ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() };
123
+ return this.write({ ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() },
124
+ 'Saved credential verification failed; the active login was preserved.');
125
+ }
126
+ /**
127
+ * Replaces a saved login's credential in place, such as with refreshed tokens: the account, its identity and when
128
+ * it was saved stay as they are. The caller holds the lock and has checked the record it replaces.
129
+ */
130
+ public replaceCredential(id: string, credential: Record<string, unknown>): IStoredCredential {
131
+ const record = this.read(id);
132
+ // A remembered rejection belongs to the credential it was taken from; the new one has not been refused.
133
+ delete record.rejectedGrant;
134
+ return this.write({ ...record, credential }, 'The refreshed login could not be verified after saving it.');
135
+ }
136
+ /**
137
+ * Remembers that the provider refused the refresh token this record holds, as `grantHashArg`. The caller holds the
138
+ * lock and has checked that the record still holds the token it sent.
139
+ */
140
+ public rememberRejectedGrant(id: string, grantHashArg: string): IStoredCredential {
141
+ if (!this.isHash(grantHashArg)) throw new Error('A rejected grant is remembered as a credential hash.');
142
+ return this.write({ ...this.read(id), rejectedGrant: grantHashArg }, 'The refused sign-in could not be recorded.');
143
+ }
144
+ private write(record: IStoredCredential, failureArg: string): IStoredCredential {
93
145
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
94
146
  plugins.fs.chmodSync(this.dir, 0o700);
95
147
  const raw = JSON.stringify(record, null, 2) + '\n';
96
148
  writeSecretFileAtomically(this.file(record.id), raw);
97
- if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error('Saved credential verification failed; the active login was preserved.');
149
+ if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error(failureArg);
98
150
  return this.read(record.id);
99
151
  }
100
152
  public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
@@ -124,13 +176,29 @@ export class CredentialStore {
124
176
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
125
177
  writeSecretFileAtomically(this.switchFile, JSON.stringify({ schemaVersion: 1, switches: records }, null, 2) + '\n');
126
178
  }
127
- public locked<T>(action: () => T): T {
179
+ /**
180
+ * Runs `action` as the only authswitch operation on this store; the lock is held until an asynchronous action settles.
181
+ *
182
+ * An operation that finds the store locked waits for it, up to the store's lock timeout, so concurrent operations
183
+ * (a manual switch and a watch refreshing a saved login) run one after the other instead of failing. The signal
184
+ * ends the wait early; an action that has started is never interrupted by it.
185
+ */
186
+ public async locked<T>(action: () => T | Promise<T>, optionsArg: { signal?: AbortSignal } = {}): Promise<T> {
128
187
  plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
129
188
  const file = plugins.path.join(this.dir, '.lock');
130
- let fd: number;
131
- try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
132
- catch { throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`); }
133
- try { return action(); }
189
+ const deadline = Date.now() + this.lockTimeoutMs;
190
+ let fd: number | undefined;
191
+ while (fd === undefined) {
192
+ optionsArg.signal?.throwIfAborted();
193
+ try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
194
+ catch (error) {
195
+ if (errorCode(error) !== 'EEXIST' || Date.now() >= deadline) {
196
+ throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`);
197
+ }
198
+ await pause(Math.min(LOCK_RETRY_MS, deadline - Date.now()), optionsArg.signal);
199
+ }
200
+ }
201
+ try { return await action(); }
134
202
  finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
135
203
  }
136
204
  }
@@ -1,4 +1,4 @@
1
- import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions } from './interfaces.harness.js';
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessCredentialDrift, IHarnessOutcome, IHarnessProcessControl, IHarnessState, IHarnessStatusOptions, THarnessResult } from './interfaces.harness.js';
2
2
  import { credentialHash, CredentialStore, type IStoredCredential, type ISwitchRecord } from './classes.credentialstore.js';
3
3
  import { pauseSynchronously } from './helpers.js';
4
4
  import { plainText } from './formatting.js';
@@ -25,22 +25,34 @@ export abstract class FileHarness implements IAuthHarness {
25
25
  protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
26
26
  /** The harness's own running instances. Detection informs the caller; it never blocks a write. */
27
27
  public abstract readonly processes: IHarnessProcessControl;
28
+ /** Whether running instances pick up a swapped login by themselves; see `IAuthHarness.liveSwap`. */
29
+ public abstract readonly liveSwap: boolean;
30
+ /** Whether `authswitch watch` may switch this harness by itself; see `IAuthHarness.autoSwitch`. */
31
+ public abstract readonly autoSwitch: boolean;
28
32
  /** Raw bytes of the native files a snapshot's credentials are taken from, in a fixed order. */
29
33
  protected abstract nativeSources(): (string | null)[];
30
34
  protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
31
35
  public abstract readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
32
36
 
37
+ /**
38
+ * Runs a read and write of the native login under the harness's own write locks, so it never interleaves
39
+ * with a running instance's token refresh or save. A harness without such a protocol runs the action as it is.
40
+ */
41
+ protected async nativeTransaction(actionArg: () => string[]): Promise<string[]> {
42
+ return actionArg();
43
+ }
44
+
33
45
  /**
34
46
  * A snapshot proven not to have been taken across a native write.
35
47
  *
36
48
  * Saving a login without replacing it writes no native file, so it must not require the harness
37
- * to be stopped. A running harness does rotate its tokens on its own schedule, and its fallback
38
- * writer truncates the file in place, so reading the same sources before and after the snapshot
39
- * and requiring identical bytes is what keeps a torn or half-rotated credential out of the stash.
40
- * Both the retry count and the pause between attempts are bounded; an unstable file is reported,
41
- * never guessed at.
49
+ * to be stopped. A running harness does rotate its tokens on its own schedule, and a native writer
50
+ * that is not transactional can leave a partial file for a moment, so reading the same sources
51
+ * before and after the snapshot and requiring identical bytes is what keeps a torn or half-rotated
52
+ * credential out of the stash. Both the retry count and the pause between attempts are bounded; an
53
+ * unstable file is reported, never guessed at.
42
54
  */
43
- private stableSnapshot(): IFileHarnessSnapshot {
55
+ protected stableSnapshot(): IFileHarnessSnapshot {
44
56
  let failure: unknown;
45
57
  for (let attempt = 0; attempt < 5; attempt++) {
46
58
  if (attempt) pauseSynchronously(20);
@@ -60,7 +72,11 @@ export abstract class FileHarness implements IAuthHarness {
60
72
  if (account.identity !== entry.identity) throw new Error('Saved credential does not match its account identity.');
61
73
  return account;
62
74
  }
63
- public readState(): IHarnessState {
75
+ /**
76
+ * The harness's accounts. A subclass may have to bring its own state up to date first -- the Claude Code adapter
77
+ * mirrors the active login into its saved copy -- which is why this is the interface's `THarnessResult`.
78
+ */
79
+ public readState(): THarnessResult<IHarnessState> {
64
80
  const snapshot = this.snapshot();
65
81
  const saved = this.store.list();
66
82
  const accounts: IHarnessState['accounts'] = saved.map(entry => {
@@ -82,11 +98,11 @@ export abstract class FileHarness implements IAuthHarness {
82
98
  /**
83
99
  * Credential slots that no longer hold the account the last switch wrote.
84
100
  *
85
- * A running harness refreshes its login on its own schedule and writes the result to the same
86
- * file, so a switch performed underneath one can be undone minutes later without any error. The
87
- * recorded hash detects that the file changed at all; comparing the account distinguishes the
88
- * benign case -- the switched-in account rotating its own token -- from the previous login coming
89
- * back. Detection is advisory and never turns a read into a failure.
101
+ * The login can change outside authswitch -- a new native login, or, for a harness that does not pick
102
+ * up swaps live, a running instance writing its previous login back at its next refresh. The recorded
103
+ * hash detects that the file changed at all; comparing the account distinguishes the benign case -- the
104
+ * switched-in account rotating its own token -- from a different account. Detection is advisory and
105
+ * never turns a read into a failure.
90
106
  */
91
107
  private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
92
108
  let records: ISwitchRecord[];
@@ -111,8 +127,9 @@ export abstract class FileHarness implements IAuthHarness {
111
127
  private savedLabel(accountIdArg: string): string | null {
112
128
  try { return this.store.read(accountIdArg).label; } catch { return null; }
113
129
  }
114
- /** Instances that keep a replaced or cleared login in memory until they are restarted. */
130
+ /** Instances that keep a replaced or cleared login in memory until they are restarted; none for a live-swap harness. */
115
131
  private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
132
+ if (this.liveSwap) return [];
116
133
  let count: number;
117
134
  try { count = this.processes.list().length; }
118
135
  catch { return [`Could not check for running ${this.label} processes. If any are running, restart them: an unrestarted instance can write its previous login back.`]; }
@@ -137,16 +154,24 @@ export abstract class FileHarness implements IAuthHarness {
137
154
  return [];
138
155
  } catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
139
156
  }
157
+ /** The active login with this account id, read from the native files; undefined when the account is not active. */
158
+ protected activeCredential(id: string): IFileAccount | undefined {
159
+ return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
160
+ }
161
+ /** The saved copy of this account, verified against its identity. */
162
+ protected savedCredential(id: string): IFileAccount {
163
+ return this.saved(this.store.read(id));
164
+ }
165
+ /** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
140
166
  protected readCredential(id: string): IFileAccount {
141
- const active = this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
142
- return active ?? this.saved(this.store.read(id));
167
+ return this.activeCredential(id) ?? this.savedCredential(id);
143
168
  }
144
169
  /** Save a newly authenticated credential without changing a native harness's login. */
145
- public importCredential(slotId: string, credential: Record<string, unknown>): { accountId: string } {
170
+ public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
146
171
  return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
147
172
  }
148
173
  /** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
149
- public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
174
+ public activateCredential(slotId: string, credential: Record<string, unknown>): Promise<IHarnessOutcome> {
150
175
  return this.outcome(() => {
151
176
  const target = this.inspect(credential, slotId);
152
177
  const snapshot = this.snapshot();
@@ -154,9 +179,15 @@ export abstract class FileHarness implements IAuthHarness {
154
179
  const previous = snapshot.accounts.find(account => account.slotId === slotId);
155
180
  if (previous) this.store.save(previous);
156
181
  this.apply(slotId, previous, target);
157
- return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`,
182
+ return [`Activated ${plainText(target.label)} (${plainText(slotId)}).`, ...this.liveSwapNote('replaced'),
158
183
  ...this.recordActivation(slotId, this.store.id(slotId, target.identity)), ...this.runningCaveat('replaced')];
159
- });
184
+ }, true);
185
+ }
186
+ /** What a live-swap harness's running sessions do with the change; they need no restart either way. */
187
+ private liveSwapNote(kindArg: 'replaced' | 'cleared'): string[] {
188
+ if (!this.liveSwap) return [];
189
+ return [kindArg === 'replaced' ? `Running ${this.label} sessions use it from their next request.`
190
+ : `Running ${this.label} sessions have no login from their next request.`];
160
191
  }
161
192
  public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
162
193
  const accounts = this.store.list();
@@ -165,11 +196,18 @@ export abstract class FileHarness implements IAuthHarness {
165
196
  const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
166
197
  return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
167
198
  }
168
- private outcome(action: () => string | string[]): IHarnessOutcome {
169
- try { return this.store.locked(() => { const lines = action(); return { lines: Array.isArray(lines) ? lines : [lines], problems: [] }; }); }
170
- catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
199
+ /**
200
+ * Runs one credential operation as the only authswitch operation on this harness's store. An operation that reads
201
+ * or writes the native login (`nativeArg`) also runs inside the harness's native transaction.
202
+ */
203
+ private async outcome(actionArg: () => string | string[], nativeArg: boolean): Promise<IHarnessOutcome> {
204
+ const run = (): string[] => [actionArg()].flat();
205
+ try {
206
+ const lines = await this.store.locked(() => nativeArg ? this.nativeTransaction(run) : run());
207
+ return { lines, problems: [] };
208
+ } catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
171
209
  }
172
- public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
210
+ public saveCurrent(options: { keepActive: boolean; accountId?: string }): Promise<IHarnessOutcome> {
173
211
  return this.outcome(() => {
174
212
  const snapshot = this.stableSnapshot();
175
213
  if (snapshot.unavailable) throw new Error(snapshot.unavailable);
@@ -182,10 +220,13 @@ export abstract class FileHarness implements IAuthHarness {
182
220
  const cleared: string[] = [];
183
221
  try { this.store.clearSwitch(active.slotId); }
184
222
  catch { cleared.push('The login was cleared, but its switch record could not be removed.'); }
185
- return [`Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`, ...cleared, ...this.runningCaveat('cleared')];
186
- });
223
+ return [this.liveSwap
224
+ ? `Saved ${plainText(active.label)} (${plainText(active.slotId)}) and cleared the active login. ${this.loginHint}`
225
+ : `Saved ${plainText(active.label)} (${plainText(active.slotId)}). Start ${this.label} to log in again.`,
226
+ ...this.liveSwapNote('cleared'), ...cleared, ...this.runningCaveat('cleared')];
227
+ }, true);
187
228
  }
188
- public switchAccount(id: string): IHarnessOutcome {
229
+ public switchAccount(id: string): Promise<IHarnessOutcome> {
189
230
  return this.outcome(() => {
190
231
  const target = this.saved(this.store.read(id));
191
232
  const snapshot = this.snapshot();
@@ -198,16 +239,17 @@ export abstract class FileHarness implements IAuthHarness {
198
239
  return [`${plainText(target.label)} is already active; its latest credential is saved.`, ...this.recordActivation(target.slotId, id)];
199
240
  }
200
241
  this.apply(target.slotId, previous, target);
201
- return [`Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`,
202
- ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
203
- });
242
+ const activated = `Activated ${plainText(target.label)} (${plainText(target.slotId)}).`;
243
+ return [this.liveSwap ? activated : `${activated} Start ${this.label} to use it.`,
244
+ ...this.liveSwapNote('replaced'), ...this.recordActivation(target.slotId, id), ...this.runningCaveat('replaced')];
245
+ }, true);
204
246
  }
205
- public removeAccount(id: string): IHarnessOutcome {
206
- return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; });
247
+ public removeAccount(id: string): Promise<IHarnessOutcome> {
248
+ return this.outcome(() => { const account = this.store.read(id); this.store.remove(id); return `Removed saved login for ${plainText(account.label)}; the active login is preserved.`; }, false);
207
249
  }
208
- public diagnose(): IHarnessOutcome {
250
+ public async diagnose(): Promise<IHarnessOutcome> {
209
251
  try {
210
- const state = this.readState();
252
+ const state = await this.readState();
211
253
  return { lines: [`${this.label}: ${state.accounts.filter(account => account.isActive).length} active login(s), ${state.accounts.filter(account => account.isStashed).length} saved account(s).`, this.loginHint], problems: state.saveUnavailableReason ? [state.saveUnavailableReason] : [] };
212
254
  } catch { return { lines: [], problems: ['Credential storage could not be verified. Check file permissions and saved credential records.'] }; }
213
255
  }