@modelprofile.com/authswitch 6.0.0 → 6.2.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.
@@ -56,6 +56,90 @@ const findFirstJwt = (valueArg: unknown, depthArg = 0): string | null => {
56
56
  return null;
57
57
  };
58
58
 
59
+ /**
60
+ * A short, non-reversible label for an API key so two different keys get two
61
+ * different stashes without the key itself ever being written down.
62
+ */
63
+ const fingerprint = (secretArg: string): string => {
64
+ let hash = 0x811c9dc5;
65
+ for (let index = 0; index < secretArg.length; index++) {
66
+ hash ^= secretArg.charCodeAt(index);
67
+ hash = Math.imul(hash, 0x01000193) >>> 0;
68
+ }
69
+ return hash.toString(16).padStart(8, '0');
70
+ };
71
+
72
+ /**
73
+ * Extracts the account identity from the content of an auth.json.
74
+ * Returns null when the content holds no recognisable credential.
75
+ *
76
+ * It stands outside the class because the stash store keys and guards its entries by the same identity and has
77
+ * no Codex installation to read: the credential itself is all this derivation needs.
78
+ */
79
+ export const codexIdentityFromRaw = (rawArg: string): ICodexIdentity | null => {
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(rawArg);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (!parsed || typeof parsed !== 'object') {
87
+ return null;
88
+ }
89
+ const record = parsed as Record<string, unknown>;
90
+
91
+ const tokens = (record.tokens ?? null) as Record<string, unknown> | null;
92
+ const idToken =
93
+ (tokens ? asString(tokens.id_token) : null) ?? findFirstJwt(record);
94
+
95
+ if (idToken) {
96
+ const claims = decodeJwtPayload(idToken) ?? {};
97
+ const authClaim = (claims['https://api.openai.com/auth'] ?? {}) as Record<string, unknown>;
98
+ const profileClaim = (claims['https://api.openai.com/profile'] ?? {}) as Record<string, unknown>;
99
+ const email =
100
+ asString(claims.email) ??
101
+ asString(profileClaim.email) ??
102
+ asString(authClaim.user_email);
103
+ const accountId =
104
+ asString(authClaim.chatgpt_account_id) ??
105
+ (tokens ? asString(tokens.account_id) : null) ??
106
+ asString(record.account_id);
107
+ if (email) {
108
+ return {
109
+ kind: 'chatgpt',
110
+ email: email.toLowerCase(),
111
+ accountId,
112
+ planType: asString(authClaim.chatgpt_plan_type),
113
+ userId: asString(authClaim.user_id) ?? asString(claims.sub),
114
+ };
115
+ }
116
+ if (accountId) {
117
+ // An OAuth credential we cannot label by email is still worth stashing;
118
+ // key it by account id so it round-trips instead of being silently dropped.
119
+ return {
120
+ kind: 'chatgpt',
121
+ email: `account:${accountId}`,
122
+ accountId,
123
+ planType: asString(authClaim.chatgpt_plan_type),
124
+ userId: asString(authClaim.user_id) ?? asString(claims.sub),
125
+ };
126
+ }
127
+ }
128
+
129
+ const apiKey = asString(record.OPENAI_API_KEY) ?? asString(record.openai_api_key);
130
+ if (apiKey) {
131
+ return {
132
+ kind: 'apikey',
133
+ email: `apikey:${fingerprint(apiKey)}`,
134
+ accountId: null,
135
+ planType: null,
136
+ userId: null,
137
+ };
138
+ }
139
+
140
+ return null;
141
+ };
142
+
59
143
  /**
60
144
  * Reads, writes and clears the Codex file credential store, and derives the
61
145
  * account identity used to key a stash.
@@ -75,81 +159,7 @@ export class CodexAuth {
75
159
  * Returns null when the file holds no recognisable credential.
76
160
  */
77
161
  public identityFromRaw(rawArg: string): ICodexIdentity | null {
78
- let parsed: unknown;
79
- try {
80
- parsed = JSON.parse(rawArg);
81
- } catch {
82
- return null;
83
- }
84
- if (!parsed || typeof parsed !== 'object') {
85
- return null;
86
- }
87
- const record = parsed as Record<string, unknown>;
88
-
89
- const tokens = (record.tokens ?? null) as Record<string, unknown> | null;
90
- const idToken =
91
- (tokens ? asString(tokens.id_token) : null) ?? findFirstJwt(record);
92
-
93
- if (idToken) {
94
- const claims = decodeJwtPayload(idToken) ?? {};
95
- const authClaim = (claims['https://api.openai.com/auth'] ?? {}) as Record<string, unknown>;
96
- const profileClaim = (claims['https://api.openai.com/profile'] ?? {}) as Record<string, unknown>;
97
- const email =
98
- asString(claims.email) ??
99
- asString(profileClaim.email) ??
100
- asString(authClaim.user_email);
101
- const accountId =
102
- asString(authClaim.chatgpt_account_id) ??
103
- (tokens ? asString(tokens.account_id) : null) ??
104
- asString(record.account_id);
105
- if (email) {
106
- return {
107
- kind: 'chatgpt',
108
- email: email.toLowerCase(),
109
- accountId,
110
- planType: asString(authClaim.chatgpt_plan_type),
111
- userId: asString(authClaim.user_id) ?? asString(claims.sub),
112
- };
113
- }
114
- if (accountId) {
115
- // An OAuth credential we cannot label by email is still worth stashing;
116
- // key it by account id so it round-trips instead of being silently dropped.
117
- return {
118
- kind: 'chatgpt',
119
- email: `account:${accountId}`,
120
- accountId,
121
- planType: asString(authClaim.chatgpt_plan_type),
122
- userId: asString(authClaim.user_id) ?? asString(claims.sub),
123
- };
124
- }
125
- }
126
-
127
- const apiKey = asString(record.OPENAI_API_KEY) ?? asString(record.openai_api_key);
128
- if (apiKey) {
129
- const fingerprint = this.fingerprint(apiKey);
130
- return {
131
- kind: 'apikey',
132
- email: `apikey:${fingerprint}`,
133
- accountId: null,
134
- planType: null,
135
- userId: null,
136
- };
137
- }
138
-
139
- return null;
140
- }
141
-
142
- /**
143
- * A short, non-reversible label for an API key so two different keys get two
144
- * different stashes without the key itself ever being written down.
145
- */
146
- private fingerprint(secretArg: string): string {
147
- let hash = 0x811c9dc5;
148
- for (let index = 0; index < secretArg.length; index++) {
149
- hash ^= secretArg.charCodeAt(index);
150
- hash = Math.imul(hash, 0x01000193) >>> 0;
151
- }
152
- return hash.toString(16).padStart(8, '0');
162
+ return codexIdentityFromRaw(rawArg);
153
163
  }
154
164
 
155
165
  /** Reads the active credential, if any. */
@@ -3,12 +3,21 @@ import { CodexAccountStatus } from './classes.codexstatus.js';
3
3
  import { CodexPreuse } from './classes.codexpreuse.js';
4
4
  import { PreuseError } from './preuse.js';
5
5
  import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult, IHarnessStatusOptions } from './interfaces.harness.js';
6
- import type { IStashListing } from './interfaces.js';
6
+ import type { IActiveAuth, ICodexIdentity, IStashEntry, IStashListing } from './interfaces.js';
7
7
  import { countPaired, enrollmentState } from './helpers.js';
8
8
  import { beginSavedOpenAiLogin } from './classes.login.js';
9
9
  import { plainText } from './formatting.js';
10
10
  import type { IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
11
11
 
12
+ /**
13
+ * Why a write into the stash failed, as its system error code. The message of a filesystem error names the path
14
+ * it was writing, and that path leads to a credential.
15
+ */
16
+ const writeFailureCause = (errorArg: unknown): string => {
17
+ const code = (errorArg as { code?: unknown } | null)?.code;
18
+ return typeof code === 'string' ? code : 'unknown cause';
19
+ };
20
+
12
21
  /** Codex-specific credentials, remote-control handling and service status. */
13
22
  export class CodexHarness implements IAuthHarness {
14
23
  public readonly id = 'codex';
@@ -43,21 +52,28 @@ export class CodexHarness implements IAuthHarness {
43
52
  }, options);
44
53
  }
45
54
 
55
+ /**
56
+ * The harness's accounts, with the saved copy of the active login brought up to date first.
57
+ *
58
+ * A saved login is one whose stored credential is readable and belongs to the account its record names. The
59
+ * exact tokens are not part of that: Codex' app-server refreshes the login it runs on its own schedule, so
60
+ * comparing bytes reported every saved active login as unsaved from its first refresh on, and the guide then
61
+ * offered to save what was already saved.
62
+ */
46
63
  public readState(): IHarnessState {
47
64
  const active = this.switcher.readActive();
65
+ const mirrorFailure = this.mirrorActiveLogin(active);
48
66
  const accounts: IHarnessAccount[] = this.switcher.list().map((entryArg) => {
49
67
  const raw = this.switcher.stashes.readAuth(entryArg.email);
50
- const identity = raw === null ? null : this.switcher.auth.identityFromRaw(raw);
51
- const valid = identity !== null && identity.email === entryArg.email && identity.accountId === entryArg.accountId && identity.kind === entryArg.kind;
52
- const isActive = active.identity !== null && active.identity.email === entryArg.email && active.identity.accountId === entryArg.accountId && active.identity.kind === entryArg.kind;
53
- const isStashed = valid && (!isActive || raw === active.raw);
68
+ const isStashed = this.recordOf(entryArg, raw === null ? null : this.switcher.auth.identityFromRaw(raw));
69
+ const isActive = this.recordOf(entryArg, active.identity);
54
70
  return {
55
71
  id: entryArg.email,
56
72
  label: entryArg.email,
57
73
  isActive,
58
74
  isStashed,
59
75
  savedAt: entryArg.stashedAt,
60
- details: [this.formatRemoteControl(entryArg).trim(), ...(!valid ? ['saved credential missing or does not match its metadata'] : !isStashed ? ['the current login has changed since it was saved'] : [])],
76
+ details: [this.formatRemoteControl(entryArg).trim(), ...(!isStashed ? ['saved credential missing or does not match its metadata'] : isActive && mirrorFailure !== null ? [mirrorFailure] : [])],
61
77
  };
62
78
  });
63
79
  if (active.identity && !accounts.some((accountArg) => accountArg.isActive)) {
@@ -74,6 +90,42 @@ export class CodexHarness implements IAuthHarness {
74
90
  };
75
91
  }
76
92
 
93
+ /** Whether a saved record is the record of this identity: same account, same email, same kind of login. */
94
+ private recordOf(entryArg: IStashEntry, identityArg: ICodexIdentity | null): boolean {
95
+ return identityArg !== null && entryArg.email === identityArg.email && entryArg.accountId === identityArg.accountId && entryArg.kind === identityArg.kind;
96
+ }
97
+
98
+ /**
99
+ * Writes the credential Codex is running on into that account's own saved record, and reports why it could
100
+ * not when the write failed.
101
+ *
102
+ * Codex rotates the tokens of the active login, and until now only a switch or a save wrote that rotation
103
+ * back -- `use()` re-saves the outgoing login, and re-selecting the active account updates its record before
104
+ * restoring the pairings. Every read now keeps the saved copy on the credential Codex last wrote, so the copy
105
+ * is one a switch can restore rather than one the service has moved past.
106
+ *
107
+ * The write goes only into the stash: Codex' own files are read, nothing is stopped or signalled, no daemon
108
+ * has to be down for it, and the record keeps its save time and its enrollments. A login with no saved record
109
+ * of its own is never saved implicitly -- `stash` is what saves a login -- and a record whose credential is
110
+ * missing or belongs to another account is left for `stash` to repair rather than quietly overwritten.
111
+ */
112
+ private mirrorActiveLogin(activeArg: IActiveAuth): string | null {
113
+ if (activeArg.source !== 'file' || activeArg.identity === null || activeArg.raw === null) return null;
114
+ const email = activeArg.identity.email;
115
+ const saved = this.switcher.stashes.readAuth(email);
116
+ if (saved === null || saved === activeArg.raw) return null;
117
+ const meta = this.switcher.stashes.readMeta(email);
118
+ if (meta === null || !this.recordOf(meta, activeArg.identity) || !this.recordOf(meta, this.switcher.auth.identityFromRaw(saved))) return null;
119
+ try {
120
+ this.switcher.stashes.replaceAuth(email, activeArg.raw);
121
+ return null;
122
+ } catch (errorArg) {
123
+ // A saved copy that could not be brought up to date is no reason to fail a read: the record still holds a
124
+ // credential of this account, and the next read tries again.
125
+ return `the current login could not be copied into its saved record (${writeFailureCause(errorArg)}); the record still holds the credential from the last save`;
126
+ }
127
+ }
128
+
77
129
  public async readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
78
130
  const credential = this.readAccountCredential(accountIdArg);
79
131
  if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
@@ -164,8 +164,8 @@ export class CredentialStore {
164
164
  && typeof record.switchedAt === 'string' && Number.isFinite(Date.parse(record.switchedAt));
165
165
  });
166
166
  }
167
- public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'>): void {
168
- this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: new Date().toISOString() }]);
167
+ public recordSwitch(entry: Omit<ISwitchRecord, 'switchedAt'> & { switchedAt?: string }): void {
168
+ this.writeSwitchRecords([...this.switchRecords().filter(item => item.slotId !== entry.slotId), { ...entry, switchedAt: entry.switchedAt ?? new Date().toISOString() }]);
169
169
  }
170
170
  public clearSwitch(slotId: string): void {
171
171
  const remaining = this.switchRecords().filter(item => item.slotId !== slotId);
@@ -99,9 +99,10 @@ export abstract class FileHarness implements IAuthHarness {
99
99
  * Credential slots that no longer hold the account the last switch wrote.
100
100
  *
101
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
102
+ * up swaps live, a request already in flight writing the previous login's own rotation back. The recorded
103
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
104
+ * switched-in account rotating its own token -- from a different account. A rotation authswitch performed
105
+ * itself is not drift at all: `recordRenewal` moves the record onto it. Detection is advisory and
105
106
  * never turns a read into a failure.
106
107
  */
107
108
  private credentialDrift(snapshotArg: IFileHarnessSnapshot): IHarnessCredentialDrift[] {
@@ -127,14 +128,18 @@ export abstract class FileHarness implements IAuthHarness {
127
128
  private savedLabel(accountIdArg: string): string | null {
128
129
  try { return this.store.read(accountIdArg).label; } catch { return null; }
129
130
  }
130
- /** Instances that keep a replaced or cleared login in memory until they are restarted; none for a live-swap harness. */
131
+ /** How a running instance still holds the previous login and can write it back; stated per harness. */
132
+ protected runningRetention(): string {
133
+ return 'They keep the previous login in memory until they are restarted, and a token refresh by one of them can ';
134
+ }
135
+ /** Instances that can still write a replaced or cleared login back; none for a live-swap harness. */
131
136
  private runningCaveat(kindArg: 'replaced' | 'cleared'): string[] {
132
137
  if (this.liveSwap) return [];
133
138
  let count: number;
134
139
  try { count = this.processes.list().length; }
135
140
  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.`]; }
136
141
  if (!count) return [];
137
- return [`${count} ${this.label} process(es) are still running. They keep the previous login in memory until they are restarted, and a token refresh by one of them can `
142
+ return [`${count} ${this.label} process(es) are still running. ${this.runningRetention()}`
138
143
  + `${kindArg === 'replaced' ? 'overwrite the credential file with the previous account' : 'write the cleared login back into the credential file'}. `
139
144
  + `Restart them, then check authswitch ${this.id} active.`];
140
145
  }
@@ -154,6 +159,25 @@ export abstract class FileHarness implements IAuthHarness {
154
159
  return [];
155
160
  } catch { return [`The login was written, but this switch could not be recorded for later credential checks.`]; }
156
161
  }
162
+ /**
163
+ * Let a slot's switch record follow a credential authswitch itself renewed in place.
164
+ *
165
+ * The record must name what authswitch last wrote, or the next read reports authswitch's own rotation as a
166
+ * login someone else put there -- an account whose identity is its refresh token changes id on every rotation.
167
+ * A slot no switch armed keeps no record: a renewal never starts watching one. The record is advisory, so an
168
+ * update that fails costs at most the drift note the next read would have shown anyway; a status read has
169
+ * nowhere to report it.
170
+ */
171
+ protected recordRenewal(slotIdArg: string): void {
172
+ try {
173
+ const record = this.store.switchRecords().find(item => item.slotId === slotIdArg);
174
+ const active = this.snapshot().accounts.find(account => account.slotId === slotIdArg);
175
+ if (!record || !active) return;
176
+ // The switch time stays the switch time: a renewal changes what the record names, not when the login was put there.
177
+ this.store.recordSwitch({ slotId: slotIdArg, accountId: this.store.id(active.slotId, active.identity),
178
+ credentialHash: credentialHash(JSON.stringify(active.credential)), switchedAt: record.switchedAt });
179
+ } catch { /* Advisory state only. */ }
180
+ }
157
181
  /** The active login with this account id, read from the native files; undefined when the account is not active. */
158
182
  protected activeCredential(id: string): IFileAccount | undefined {
159
183
  return this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
@@ -162,10 +186,6 @@ export abstract class FileHarness implements IAuthHarness {
162
186
  protected savedCredential(id: string): IFileAccount {
163
187
  return this.saved(this.store.read(id));
164
188
  }
165
- /** The account's current credential: the active login when it is active, which may be newer than its saved copy. */
166
- protected readCredential(id: string): IFileAccount {
167
- return this.activeCredential(id) ?? this.savedCredential(id);
168
- }
169
189
  /** Save a newly authenticated credential without changing a native harness's login. */
170
190
  public importCredential(slotId: string, credential: Record<string, unknown>): Promise<{ accountId: string }> {
171
191
  return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
@@ -0,0 +1,64 @@
1
+ import { credentialText } from './classes.credentialstore.js';
2
+ import { claudeRequest, ClaudeLoginRejectedError, ClaudeRequestError, oauthErrorCode } from './claudehttp.js';
3
+
4
+ /** The token endpoint `opencode-anthropic-auth` 0.0.13 renews an OpenCode `anthropic` login at (`index.mjs`). */
5
+ const TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
6
+ /** The OAuth client that plugin sends, Claude Code's public one (`CLIENT_ID`, `index.mjs`). */
7
+ const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
8
+ /**
9
+ * How long one renewal may take. A renewal is never cut shorter than that, nor cancelled once sent: the service may
10
+ * already have replaced the refresh token the request carries.
11
+ */
12
+ const REFRESH_TIMEOUT_MS = 30_000;
13
+
14
+ /** The token fields OpenCode keeps for an OAuth login, as one renewal replaces them. */
15
+ export interface IOpenCodeAnthropicTokens {
16
+ access: string;
17
+ refresh: string;
18
+ /** Milliseconds since the epoch, as OpenCode stores them. */
19
+ expires: number;
20
+ }
21
+
22
+ const seconds = (valueArg: unknown): number | undefined =>
23
+ typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0 ? valueArg : undefined;
24
+
25
+ /**
26
+ * Renews an OpenCode `anthropic` OAuth login with the request OpenCode's own Anthropic provider sends for it.
27
+ *
28
+ * `opencode-anthropic-auth` 0.0.13 -- the plugin that gives OpenCode 1.18.x its `anthropic` OAuth provider; the
29
+ * binary carries no Anthropic OAuth code of its own -- posts a JSON body of `grant_type`, `refresh_token` and
30
+ * Claude Code's public client to `console.anthropic.com`, with no scope, and stores `{access, refresh, expires}`
31
+ * from the answer. This sends exactly that request, so a login renewed here is renewed the way OpenCode renews it.
32
+ *
33
+ * It only talks to the token endpoint; which login is renewed, and where the rotated pair is written, belongs to
34
+ * the harness.
35
+ */
36
+ export class OpenCodeAnthropicRefresh {
37
+ constructor(private readonly fetcher: typeof fetch = globalThis.fetch, private readonly now: () => number = Date.now) {}
38
+
39
+ /**
40
+ * New tokens for the login. A response without a refresh token keeps the one that was sent, as the service's own
41
+ * answer to a grant it did not rotate. Throws `ClaudeRequestError` with a fixed diagnostic; a grant the service
42
+ * rejects is `ClaudeLoginRejectedError`, and a rate limit is `ClaudeRateLimitError`.
43
+ */
44
+ public async refresh(refreshTokenArg: string): Promise<IOpenCodeAnthropicTokens> {
45
+ const { status, body } = await claudeRequest(this.fetcher, {
46
+ url: TOKEN_URL, method: 'POST', headers: { 'Content-Type': 'application/json' }, subject: 'Claude sign-in',
47
+ body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshTokenArg, client_id: CLIENT_ID }),
48
+ timeoutMs: REFRESH_TIMEOUT_MS, readErrorBody: true, now: this.now,
49
+ });
50
+ if ((status === 400 || status === 401) && oauthErrorCode(body) === 'invalid_grant') throw new ClaudeLoginRejectedError();
51
+ if (status < 200 || status > 299) throw new ClaudeRequestError(`The Claude sign-in service returned HTTP ${status}.`);
52
+ const receivedAt = this.now();
53
+ const access = credentialText(body?.access_token);
54
+ const tokenType = body?.token_type;
55
+ const expiresIn = seconds(body?.expires_in);
56
+ const rotated = body?.refresh_token == null ? null : credentialText(body.refresh_token);
57
+ // Only a response every field of the stored entry can be taken from is written back into a file OpenCode owns.
58
+ if (!access || (tokenType != null && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
59
+ || expiresIn === undefined || rotated === undefined) {
60
+ throw new ClaudeRequestError('The Claude sign-in service returned an unsupported token response; the stored login was not changed.');
61
+ }
62
+ return { access, refresh: rotated ?? refreshTokenArg, expires: receivedAt + expiresIn * 1000 };
63
+ }
64
+ }
@@ -4,7 +4,9 @@ import { CredentialStore, credentialHash, credentialRecord, credentialText, read
4
4
  import { HarnessProcesses } from './classes.harnessprocesses.js';
5
5
  import { ClaudeAccountStatus } from './classes.claudestatus.js';
6
6
  import { CLAUDE_REFRESH_MARGIN_MS } from './classes.claudetokenrefresh.js';
7
+ import { ClaudeLoginRejectedError, ClaudeRateLimitError } from './claudehttp.js';
7
8
  import { CodexAccountStatus } from './classes.codexstatus.js';
9
+ import { OpenCodeAnthropicRefresh } from './classes.opencodeanthropicrefresh.js';
8
10
  import { writeSecretFileAtomically } from './helpers.js';
9
11
  import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessProcessControl, IHarnessStatusOptions } from './interfaces.harness.js';
10
12
  import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
@@ -21,6 +23,11 @@ export interface IOpenCodeHarnessOptions {
21
23
  now?: () => number;
22
24
  }
23
25
 
26
+ /** Anthropic refused the grant OpenCode's entry holds; no retry and no other account changes that answer. */
27
+ const ANTHROPIC_LOGIN_REJECTED = 'Login expired or was rejected. Log in again with opencode auth login.';
28
+ /** A saved copy is not the login OpenCode uses, so renewing it would rotate a token OpenCode's own entry still holds. */
29
+ const SAVED_ANTHROPIC_EXPIRED = 'This saved Anthropic login\'s access token has expired. authswitch renews the Anthropic login OpenCode has active; activate this one to have it renewed, or log in again with opencode auth login.';
30
+
24
31
  /** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
25
32
  export class OpenCodeHarness extends FileHarness {
26
33
  public readonly id = 'opencode';
@@ -30,12 +37,17 @@ export class OpenCodeHarness extends FileHarness {
30
37
  public readonly liveSwap = false;
31
38
  /** Only its OpenAI login reports usage, and a supervisor (AGL) restarts it around a switch, so a watch leaves it alone. */
32
39
  public readonly autoSwitch = false;
40
+ /** OpenCode re-reads its credential file on every request; only a request already in flight still holds the previous login. */
41
+ protected override runningRetention(): string {
42
+ return 'They re-read the credential file on every request, but a request already in flight still holds the previous login, and its token refresh can ';
43
+ }
33
44
  public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
34
45
  protected readonly store: CredentialStore;
35
46
  private readonly file: string;
36
47
  private readonly env: NodeJS.ProcessEnv;
37
48
  private readonly status: CodexAccountStatus;
38
49
  private readonly claudeStatus: ClaudeAccountStatus;
50
+ private readonly anthropicRefresh: OpenCodeAnthropicRefresh;
39
51
  public readonly processes: IHarnessProcessControl;
40
52
  private readonly fetcher?: typeof fetch;
41
53
  private readonly now: () => number;
@@ -47,6 +59,7 @@ export class OpenCodeHarness extends FileHarness {
47
59
  this.store = new CredentialStore(this.id, options.stashRoot);
48
60
  this.status = new CodexAccountStatus(options.fetch);
49
61
  this.claudeStatus = new ClaudeAccountStatus(options.fetch, options.now);
62
+ this.anthropicRefresh = new OpenCodeAnthropicRefresh(options.fetch, options.now);
50
63
  this.fetcher = options.fetch;
51
64
  this.now = options.now ?? Date.now;
52
65
  this.processes = options.processes ?? new HarnessProcesses('opencode');
@@ -112,35 +125,87 @@ export class OpenCodeHarness extends FileHarness {
112
125
  writeSecretFileAtomically(this.file, raw);
113
126
  if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
114
127
  }
128
+ /** The login OpenCode has in a provider slot, read from its file; undefined where the file holds none it can read. */
129
+ private activeSlot(slotIdArg: string): IFileAccount | undefined {
130
+ return this.snapshot().accounts.find(account => account.slotId === slotIdArg);
131
+ }
132
+ /** Whether this login's access token is inside Claude Code's five-minute margin, the margin Claude's own logins use. */
133
+ private renewalDue(credentialArg: Record<string, unknown>): boolean {
134
+ const expires = credentialArg.expires;
135
+ return typeof expires === 'number' && Number.isFinite(expires) && this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires;
136
+ }
137
+
138
+ /**
139
+ * Renews OpenCode's active Anthropic login and writes the rotated pair back into OpenCode's own credential file.
140
+ *
141
+ * Nothing is stranded by that write. OpenCode's auth service reads `auth.json` on every `Auth.all()`, `Auth.get()`
142
+ * is that read (1.18.15 @99100961, 1.18.31 @103733929), and its Anthropic provider -- `opencode-anthropic-auth`
143
+ * 0.0.13, since the binary carries no Anthropic OAuth code -- calls the `getAuth()` it was initialised with
144
+ * (@98017331) inside the `fetch` wrapper of every single request. A running OpenCode therefore holds no refresh
145
+ * token in memory and picks up this rotation on its next request, exactly as it picks up its own.
146
+ *
147
+ * The renewal is the only authswitch operation on the store while it runs, and the rotated pair replaces the
148
+ * entry only while OpenCode still holds the refresh token that was sent: a login OpenCode renewed meanwhile is
149
+ * returned as it now stands. The write itself is `apply`'s verified compare-and-swap, which preserves every other
150
+ * provider and replaces the file atomically -- OpenCode writes the same file in place. The slot's switch record
151
+ * follows that write, because an `anthropic` login's identity is its refresh token and this rotation is
152
+ * authswitch's own: without that, the next read would report this write as a different account.
153
+ */
154
+ private renewAnthropic(accountArg: IFileAccount, signalArg?: AbortSignal): Promise<IFileAccount> {
155
+ return this.store.locked(async () => {
156
+ const current = this.activeSlot(accountArg.slotId) ?? accountArg;
157
+ if (!this.renewalDue(current.credential)) return current;
158
+ const refreshToken = credentialText(current.credential.refresh);
159
+ if (refreshToken === undefined) throw new Error('This Anthropic login holds no refresh token; log in again with opencode auth login.');
160
+ signalArg?.throwIfAborted();
161
+ const tokens = await this.anthropicRefresh.refresh(refreshToken);
162
+ const latest = this.activeSlot(accountArg.slotId);
163
+ if (!latest || credentialText(latest.credential.refresh) !== refreshToken) return latest ?? current;
164
+ const renewed = this.inspect({ ...latest.credential, access: tokens.access, refresh: tokens.refresh, expires: tokens.expires }, latest.slotId);
165
+ this.apply(latest.slotId, latest, renewed);
166
+ this.recordRenewal(latest.slotId);
167
+ return renewed;
168
+ }, { signal: signalArg });
169
+ }
170
+
115
171
  /**
116
172
  * The subscription and usage of an OpenCode `anthropic` OAuth login.
117
173
  *
118
174
  * That entry holds the same Claude subscriber grant Claude Code stores, so Claude's own account endpoints answer
119
175
  * for it and the account reports real windows instead of no quota API at all. OpenCode keeps no scopes and no
120
- * account identity with it, so the service decides both; the token is used exactly as stored. Renewing it is
121
- * OpenCode's, because the renewal rotates the refresh token a running OpenCode holds in memory, and authswitch
122
- * cannot write that rotation into an instance it does not manage.
176
+ * account identity with it, so the service decides both. A due access token is renewed first, with OpenCode's own
177
+ * request; a refusal is reported and nothing is written, so the entry OpenCode owns is left exactly as it was.
178
+ *
179
+ * A saved copy is never renewed: OpenCode's entry holds the same refresh token, and rotating it here would leave
180
+ * the login OpenCode actually uses holding a token the service has replaced.
123
181
  */
124
- private anthropicStatus(credentialArg: Record<string, unknown>, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
125
- const accessToken = credentialText(credentialArg.access);
126
- const expires = credentialArg.expires;
127
- if (accessToken === undefined || typeof expires !== 'number') {
128
- return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.'));
129
- }
130
- if (this.now() + CLAUDE_REFRESH_MARGIN_MS >= expires) {
131
- return Promise.resolve(this.claudeStatus.unavailable({}, 'This Anthropic login\'s access token has expired. OpenCode renews it while it runs; authswitch does not, because the renewal rotates the refresh token a running OpenCode keeps in memory. Start OpenCode, or log in again with opencode auth login.'));
182
+ private async anthropicStatus(accountArg: IFileAccount, isActiveArg: boolean, optionsArg: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
183
+ let account = accountArg;
184
+ if (this.renewalDue(account.credential)) {
185
+ if (!isActiveArg) return this.claudeStatus.unavailable({}, SAVED_ANTHROPIC_EXPIRED);
186
+ try { account = await this.renewAnthropic(account, optionsArg.signal); }
187
+ catch (error) {
188
+ if (optionsArg.signal?.aborted) throw error;
189
+ const problem = error instanceof ClaudeLoginRejectedError ? ANTHROPIC_LOGIN_REJECTED
190
+ : error instanceof Error ? error.message : 'the login could not be renewed.';
191
+ return this.claudeStatus.unavailable({}, `Login refresh: ${problem}`,
192
+ error instanceof ClaudeRateLimitError ? { retryAt: error.retryAt } : undefined);
193
+ }
132
194
  }
195
+ const accessToken = credentialText(account.credential.access);
196
+ if (accessToken === undefined) return this.claudeStatus.unavailable({}, 'This Anthropic login holds no usable access token; log in again with opencode auth login.');
133
197
  return this.claudeStatus.read({ accessToken, scopes: [] }, optionsArg);
134
198
  }
135
199
 
136
- public async readAccountStatus(id: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus> {
137
- const account = this.readCredential(id);
200
+ public async readAccountStatus(id: string, optionsArg: IHarnessStatusOptions = {}): Promise<IHarnessAccountStatus> {
201
+ const active = this.activeCredential(id);
202
+ const account = active ?? this.savedCredential(id);
138
203
  if (account.slotId === 'openai' && account.credential.type === 'oauth') {
139
204
  const info = this.openAiInfo(account.credential);
140
205
  if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
141
206
  return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan, optionsArg);
142
207
  }
143
- if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account.credential, optionsArg);
208
+ if (account.slotId === 'anthropic' && account.credential.type === 'oauth') return this.anthropicStatus(account, active !== undefined, optionsArg);
144
209
  return { facts: [
145
210
  { section: 'Authentication', label: 'Provider', value: account.slotId },
146
211
  { section: 'Authentication', label: 'Login type', value: String(account.credential.type) },
@@ -6,6 +6,7 @@ import type {
6
6
  THarnessName,
7
7
  } from './interfaces.js';
8
8
  import { writeSecretFileAtomically } from './helpers.js';
9
+ import { codexIdentityFromRaw } from './classes.codexauth.js';
9
10
 
10
11
  const AUTH_FILE_NAME = 'auth.json';
11
12
  const META_FILE_NAME = 'stash.json';
@@ -105,6 +106,27 @@ export class StashStore {
105
106
  return entry;
106
107
  }
107
108
 
109
+ /**
110
+ * Replaces the credential of an existing stash and leaves its metadata exactly as it is.
111
+ *
112
+ * Codex refreshes the login it is running on its own schedule, so a saved copy of that login holds older
113
+ * tokens until something writes the rotation back. Doing so is an update of one account's credential, not a
114
+ * new save: the record keeps its account, its enrollments and the moment it was saved, which `write()` would
115
+ * stamp anew. The credential must belong to the account the metadata names -- the same guard `write()`
116
+ * applies -- so a mirror can never move one account's login into another account's record.
117
+ */
118
+ public replaceAuth(emailArg: string, rawAuthArg: string): void {
119
+ const meta = this.readMeta(emailArg);
120
+ if (!meta) {
121
+ throw new Error(`no stash for "${emailArg}" whose credential could be replaced`);
122
+ }
123
+ const identity = codexIdentityFromRaw(rawAuthArg);
124
+ if (!identity || identity.email !== meta.email || identity.accountId !== meta.accountId || identity.kind !== meta.kind) {
125
+ throw new Error('the credential does not belong to the account this stash holds; refusing to replace it');
126
+ }
127
+ writeSecretFileAtomically(plugins.path.join(this.entryDir(emailArg), AUTH_FILE_NAME), rawAuthArg);
128
+ }
129
+
108
130
  /**
109
131
  * Reads a stash back and confirms it matches what was meant to be written.
110
132
  * Callers use this before clearing the active credential, so a stash is only
@@ -239,9 +239,13 @@ export interface IAuthHarness {
239
239
  */
240
240
  readonly autoSwitch?: boolean;
241
241
  /**
242
- * The account's live status. It must never activate, switch or clear a login. It may renew the tokens of the
243
- * saved copy it reads when the provider leaves it no other way to answer -- the Claude Code adapter does, for
244
- * saved and inactive logins only, under its store's lock -- and must leave the active login's credential alone.
242
+ * The account's live status. It must never activate, switch or clear a login.
243
+ *
244
+ * It may renew the tokens of the credential it reads when the provider leaves it no other way to answer, under
245
+ * its store's lock and with a compare-and-swap on the refresh token it sent. A saved copy is always its own to
246
+ * renew -- the Claude Code adapter renews one, because Claude Code's active login is Claude Code's. The active
247
+ * login may only be renewed where the owning harness re-reads its credential file on every request, so that the
248
+ * rotation strands nothing: OpenCode's Anthropic provider does, and authswitch renews that entry.
245
249
  */
246
250
  readAccountStatus(accountIdArg: string, optionsArg?: IHarnessStatusOptions): Promise<IHarnessAccountStatus>;
247
251
  readonly loginProviders?: IHarnessLoginProvider[];