@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,101 @@
1
+ import * as plugins from './plugins.js';
2
+ import { writeSecretFileAtomically } from './helpers.js';
3
+
4
+ export const credentialRecord = (value: unknown): Record<string, unknown> => {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Unsupported credential document.');
6
+ return value as Record<string, unknown>;
7
+ };
8
+ export const credentialText = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined;
9
+ export const credentialHash = (value: string): string => plugins.crypto.createHash('sha256').update(value).digest('hex');
10
+
11
+ /** A bounded file read; malformed secrets never become parser diagnostics in the CLI. */
12
+ export const readCredentialDocument = (file: string): { raw: string | null; data: Record<string, unknown> } => {
13
+ let fd: number | undefined;
14
+ try {
15
+ fd = plugins.fs.openSync(file, plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NONBLOCK);
16
+ const stat = plugins.fs.fstatSync(fd);
17
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) throw new Error();
18
+ const buffer = Buffer.alloc(4 * 1024 * 1024 + 1);
19
+ let size = 0;
20
+ while (size < buffer.length) {
21
+ const count = plugins.fs.readSync(fd, buffer, size, buffer.length - size, null);
22
+ if (!count) break;
23
+ size += count;
24
+ }
25
+ if (size === buffer.length) throw new Error();
26
+ const raw = buffer.subarray(0, size).toString('utf8');
27
+ return { raw, data: credentialRecord(JSON.parse(raw)) };
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { raw: null, data: {} };
30
+ throw new Error('Credential file is unreadable, malformed, or too large; no changes were made.');
31
+ } finally { if (fd !== undefined) plugins.fs.closeSync(fd); }
32
+ };
33
+
34
+ export interface IStoredCredential {
35
+ schemaVersion: 1;
36
+ id: string;
37
+ slotId: string;
38
+ identity: string;
39
+ label: string;
40
+ savedAt: string;
41
+ /** Harness-owned payload. This object must never cross the public account interface. */
42
+ credential: Record<string, unknown>;
43
+ }
44
+
45
+ /** A single atomic, owner-only record keeps the saved credential and its identity together. */
46
+ export class CredentialStore {
47
+ public readonly dir: string;
48
+ constructor(harness: string, root = process.env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')) {
49
+ this.dir = plugins.path.join(root, harness);
50
+ }
51
+ public id(slot: string, identity: string): string { return credentialHash(JSON.stringify([slot, identity])); }
52
+ private file(id: string): string {
53
+ if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid saved account ID.');
54
+ return plugins.path.join(this.dir, `${id}.json`);
55
+ }
56
+ public read(id: string): IStoredCredential {
57
+ const value = readCredentialDocument(this.file(id)).data;
58
+ if (value.schemaVersion !== 1 || value.id !== id || !credentialText(value.slotId) || !credentialText(value.identity)
59
+ || !credentialText(value.label) || typeof value.savedAt !== 'string' || !Number.isFinite(Date.parse(value.savedAt))
60
+ || 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.');
61
+ credentialRecord(value.credential);
62
+ return value as unknown as IStoredCredential;
63
+ }
64
+ public list(): IStoredCredential[] {
65
+ if (!plugins.fs.existsSync(this.dir)) return [];
66
+ return plugins.fs.readdirSync(this.dir).filter(name => /^[a-f0-9]{64}\.json$/.test(name))
67
+ .map(name => this.read(name.slice(0, -5))).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
68
+ }
69
+ public save(entry: Omit<IStoredCredential, 'schemaVersion' | 'id' | 'savedAt'>): IStoredCredential {
70
+ const record: IStoredCredential = { ...entry, schemaVersion: 1, id: this.id(entry.slotId, entry.identity), savedAt: new Date().toISOString() };
71
+ plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
72
+ plugins.fs.chmodSync(this.dir, 0o700);
73
+ const raw = JSON.stringify(record, null, 2) + '\n';
74
+ writeSecretFileAtomically(this.file(record.id), raw);
75
+ if (readCredentialDocument(this.file(record.id)).raw !== raw) throw new Error('Saved credential verification failed; the active login was preserved.');
76
+ return this.read(record.id);
77
+ }
78
+ public remove(id: string): void { this.read(id); plugins.fs.unlinkSync(this.file(id)); }
79
+ public locked<T>(action: () => T): T {
80
+ plugins.fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
81
+ const file = plugins.path.join(this.dir, '.lock');
82
+ let fd: number;
83
+ try { fd = plugins.fs.openSync(file, 'wx', 0o600); }
84
+ catch { throw new Error(`Account storage is locked. Close other authswitch operations; if none remain, remove ${file}.`); }
85
+ try { return action(); }
86
+ finally { plugins.fs.closeSync(fd); plugins.fs.unlinkSync(file); }
87
+ }
88
+ }
89
+
90
+ /** Native harnesses retain credentials and may refresh them. Refuse writes while one is running. */
91
+ export const requireHarnessStopped = (binary: 'opencode' | 'claude'): void => {
92
+ let names: string[];
93
+ try {
94
+ if (process.platform === 'win32') {
95
+ names = plugins.childProcess.execFileSync('tasklist', ['/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => line.split('",')[0].replace(/^"/, ''));
96
+ } else {
97
+ names = plugins.childProcess.execFileSync('ps', ['-u', String(process.getuid!()), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).split('\n').map(line => plugins.path.basename(line.trim()));
98
+ }
99
+ } catch { throw new Error(`Could not verify that ${binary} is stopped. No login was changed.`); }
100
+ if (names.some(name => name === binary || name === `${binary}.exe`)) throw new Error(`Exit running ${binary} processes before saving or switching credentials, then start ${binary} again.`);
101
+ };
@@ -0,0 +1,120 @@
1
+ import type { IAuthHarness, IHarnessAccountStatus, IHarnessOutcome, IHarnessState } from './interfaces.harness.js';
2
+ import { CredentialStore, type IStoredCredential } from './classes.credentialstore.js';
3
+ import { plainText } from './formatting.js';
4
+
5
+ export interface IFileAccount {
6
+ slotId: string;
7
+ identity: string;
8
+ label: string;
9
+ credential: Record<string, unknown>;
10
+ }
11
+ export interface IFileHarnessSnapshot {
12
+ accounts: IFileAccount[];
13
+ unavailable: string | null;
14
+ }
15
+
16
+ /** Shared verified preservation for harnesses with independently replaceable credential slots. */
17
+ export abstract class FileHarness implements IAuthHarness {
18
+ public abstract readonly id: string;
19
+ public abstract readonly label: string;
20
+ public abstract readonly loginHint: string;
21
+ public readonly diagnosticsLabel = 'Check credential storage and account availability';
22
+ protected abstract readonly store: CredentialStore;
23
+ protected abstract snapshot(): IFileHarnessSnapshot;
24
+ protected abstract apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void;
25
+ protected abstract beforeMutation(): void;
26
+ protected abstract inspect(credential: Record<string, unknown>, slotId: string): IFileAccount;
27
+ public abstract readAccountStatus(id: string): Promise<IHarnessAccountStatus>;
28
+
29
+ private saved(entry: IStoredCredential): IFileAccount {
30
+ const account = this.inspect(entry.credential, entry.slotId);
31
+ if (account.identity !== entry.identity) throw new Error('Saved credential does not match its account identity.');
32
+ return account;
33
+ }
34
+ public readState(): IHarnessState {
35
+ const snapshot = this.snapshot();
36
+ const saved = this.store.list();
37
+ const accounts: IHarnessState['accounts'] = saved.map(entry => {
38
+ this.saved(entry);
39
+ return { id: entry.id, slotId: entry.slotId, label: entry.label, isActive: false, isStashed: true, savedAt: entry.savedAt, details: [`Provider: ${entry.slotId}`] };
40
+ });
41
+ for (const active of snapshot.accounts) {
42
+ const id = this.store.id(active.slotId, active.identity);
43
+ const existing = accounts.find(account => account.id === id);
44
+ if (existing) { existing.isActive = true; existing.label = active.label; }
45
+ else accounts.unshift({ id, slotId: active.slotId, label: active.label, isActive: true, isStashed: false, savedAt: null, details: [`Provider: ${active.slotId}`] });
46
+ }
47
+ for (const account of accounts) {
48
+ if (accounts.some(other => other.id !== account.id && other.label === account.label)) account.details.push(`Account ID: ${account.id}`);
49
+ }
50
+ return { accounts, saveUnavailableReason: snapshot.unavailable };
51
+ }
52
+ protected readCredential(id: string): IFileAccount {
53
+ const active = this.snapshot().accounts.find(account => this.store.id(account.slotId, account.identity) === id);
54
+ return active ?? this.saved(this.store.read(id));
55
+ }
56
+ /** Save a newly authenticated credential without changing a native harness's login. */
57
+ public importCredential(slotId: string, credential: Record<string, unknown>): { accountId: string } {
58
+ return this.store.locked(() => ({ accountId: this.store.save(this.inspect(credential, slotId)).id }));
59
+ }
60
+ /** Backend-only login handoff. Preserve the outgoing credential before replacing its slot. */
61
+ public activateCredential(slotId: string, credential: Record<string, unknown>): IHarnessOutcome {
62
+ return this.outcome(() => {
63
+ this.beforeMutation();
64
+ const target = this.inspect(credential, slotId);
65
+ const snapshot = this.snapshot();
66
+ if (snapshot.unavailable) throw new Error(snapshot.unavailable);
67
+ const previous = snapshot.accounts.find(account => account.slotId === slotId);
68
+ if (previous) this.store.save(previous);
69
+ this.apply(slotId, previous, target);
70
+ return `Activated ${plainText(target.label)} (${plainText(slotId)}).`;
71
+ });
72
+ }
73
+ public resolveAccount(reference: string): { id: string } | { candidates: string[] } {
74
+ const accounts = this.store.list();
75
+ const exact = accounts.filter(account => account.id === reference || account.label.toLowerCase() === reference.toLowerCase()
76
+ || `${account.slotId}:${account.label}`.toLowerCase() === reference.toLowerCase());
77
+ const matches = exact.length ? exact : accounts.filter(account => account.label.toLowerCase().startsWith(reference.toLowerCase()));
78
+ return matches.length === 1 ? { id: matches[0].id } : { candidates: matches.map(account => `${account.slotId}:${account.label} (${account.id})`) };
79
+ }
80
+ private outcome(action: () => string): IHarnessOutcome {
81
+ try { return this.store.locked(() => ({ lines: [action()], problems: [] })); }
82
+ catch (error) { return { lines: [], problems: [error instanceof Error ? error.message : 'Credential operation failed.'] }; }
83
+ }
84
+ public saveCurrent(options: { keepActive: boolean; accountId?: string }): IHarnessOutcome {
85
+ return this.outcome(() => {
86
+ this.beforeMutation();
87
+ const snapshot = this.snapshot();
88
+ if (snapshot.unavailable) throw new Error(snapshot.unavailable);
89
+ const matches = snapshot.accounts.filter(account => !options.accountId || this.store.id(account.slotId, account.identity) === options.accountId || account.slotId === options.accountId || account.label === options.accountId);
90
+ if (matches.length !== 1) throw new Error(matches.length ? 'Several logins are active. Select the account or provider to save.' : 'No matching active login to save.');
91
+ const active = matches[0];
92
+ this.store.save(active);
93
+ if (!options.keepActive) this.apply(active.slotId, active, undefined);
94
+ return `Saved ${plainText(active.label)} (${plainText(active.slotId)})${options.keepActive ? '; login remains in place.' : `. Start ${this.label} to log in again.`}`;
95
+ });
96
+ }
97
+ public switchAccount(id: string): IHarnessOutcome {
98
+ return this.outcome(() => {
99
+ this.beforeMutation();
100
+ const target = this.saved(this.store.read(id));
101
+ const snapshot = this.snapshot();
102
+ if (snapshot.unavailable) throw new Error(snapshot.unavailable);
103
+ const previous = snapshot.accounts.find(account => account.slotId === target.slotId);
104
+ // Refresh the outgoing stash even when this account was already saved.
105
+ if (previous) this.store.save(previous);
106
+ if (previous?.identity === target.identity) return `${plainText(target.label)} is already active; its latest credential is saved.`;
107
+ this.apply(target.slotId, previous, target);
108
+ return `Activated ${plainText(target.label)} (${plainText(target.slotId)}). Start ${this.label} to use it.`;
109
+ });
110
+ }
111
+ public removeAccount(id: string): IHarnessOutcome {
112
+ 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.`; });
113
+ }
114
+ public diagnose(): IHarnessOutcome {
115
+ try {
116
+ const state = this.readState();
117
+ 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] : [] };
118
+ } catch { return { lines: [], problems: ['Credential storage could not be verified. Check file permissions and saved credential records.'] }; }
119
+ }
120
+ }
@@ -0,0 +1,103 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IHarnessLoginHandle, IHarnessLoginOptions, IHarnessLoginResult } from './interfaces.harness.js';
3
+
4
+ export type TAuthLoginFlow = plugins.flexAccounts.TSmartAiProviderLoginFlow;
5
+ export type TAuthLoginCredential = plugins.flexAccounts.TSmartAiProviderCredential;
6
+ export type TAuthLoginPrompt = plugins.flexAccounts.TSmartAiProviderLoginPrompt;
7
+ export interface IAuthLoginResult extends plugins.flexAccounts.ISmartAiProviderLoginResult {}
8
+ export interface IAuthLoginHandle extends plugins.flexAccounts.ISmartAiProviderLoginHandle {}
9
+ export interface IAuthLoginProvider extends Pick<plugins.flexAccounts.ISmartAiProviderAdapter, 'descriptor' | 'beginLogin' | 'dispose'> {}
10
+ export interface IAuthLoginOptions extends plugins.flexAccounts.ISmartAiProviderLoginOptions {
11
+ providerId: string;
12
+ }
13
+ export interface IAuthSwitchLoginOptions {
14
+ /** False when an enclosing provider registry owns disposal of injected adapters. */
15
+ disposeProviders?: boolean;
16
+ }
17
+
18
+ /** Shared login owner for terminal and hosted UIs. Credential results stay on the backend. */
19
+ export class AuthSwitchLogin {
20
+ private readonly providers = new Map<string, IAuthLoginProvider>();
21
+ private pending = 0;
22
+ private closed = false;
23
+ private closing?: Promise<void>;
24
+ private readonly handles = new Set<IAuthLoginHandle>();
25
+
26
+ constructor(providers: IAuthLoginProvider[] = [new plugins.flexAccounts.OpenAiProviderAdapter()], private readonly options: IAuthSwitchLoginOptions = {}) {
27
+ for (const provider of providers) {
28
+ if (this.providers.has(provider.descriptor.providerId)) throw new Error('Duplicate login provider.');
29
+ this.providers.set(provider.descriptor.providerId, provider);
30
+ }
31
+ }
32
+ public listProviders(): plugins.flexAccounts.ISmartAiProviderDescriptor[] {
33
+ return [...this.providers.values()].map(provider => ({ ...provider.descriptor, loginFlows: [...provider.descriptor.loginFlows] }));
34
+ }
35
+ public async beginLogin(options: IAuthLoginOptions): Promise<IAuthLoginHandle> {
36
+ if (this.closed) throw new Error('Login service is closed.');
37
+ const provider = this.providers.get(options.providerId);
38
+ if (!provider?.descriptor.loginFlows.includes(options.flow)) throw new Error('This provider does not support the requested login flow.');
39
+ if (this.pending + this.handles.size >= 8) throw new Error('Too many pending logins. Complete or cancel an existing login first.');
40
+ this.pending++;
41
+ try {
42
+ const handle = await provider.beginLogin(options);
43
+ if (this.closed) { await handle.close(); throw new Error('Login service is closed.'); }
44
+ this.handles.add(handle);
45
+ // Keep cancellation/close ownership until the consumer has persisted the secret result.
46
+ return {
47
+ prompt: handle.prompt,
48
+ completion: handle.completion,
49
+ cancel: () => handle.cancel(),
50
+ close: async () => { await handle.close(); this.handles.delete(handle); },
51
+ };
52
+ } catch {
53
+ throw new Error('Provider login could not be started.');
54
+ } finally { this.pending--; }
55
+ }
56
+ public close(): Promise<void> { return this.closing ??= this.closeOwnedResources(); }
57
+ private async closeOwnedResources(): Promise<void> {
58
+ this.closed = true;
59
+ const results = await Promise.allSettled([...this.handles].map(handle => handle.close()));
60
+ const disposed = this.options.disposeProviders === false ? [] : await Promise.allSettled([...this.providers.values()].map(provider => provider.dispose()));
61
+ if ([...results, ...disposed].some(result => result.status === 'rejected')) throw new Error('Provider login cleanup failed.');
62
+ this.handles.clear();
63
+ }
64
+ }
65
+
66
+ /** A hosted or terminal UI sees only the prompt and saved-account outcome. */
67
+ export const beginSavedOpenAiLogin = async (
68
+ save: (result: IAuthLoginResult) => IHarnessLoginResult,
69
+ options: IHarnessLoginOptions,
70
+ fetcher?: typeof fetch,
71
+ ): Promise<IHarnessLoginHandle> => {
72
+ if (options.providerId !== 'openai' || options.flow !== 'device') throw new Error('This harness supports OpenAI device login.');
73
+ const service = new AuthSwitchLogin([new plugins.flexAccounts.OpenAiProviderAdapter({ fetch: fetcher })]);
74
+ try {
75
+ const handle = await service.beginLogin({ providerId: 'openai', flow: 'device' });
76
+ const completion = handle.completion.then(save).catch((): IHarnessLoginResult => ({
77
+ outcome: { lines: [], problems: ['Login did not complete or its saved credential could not be verified. The active login was preserved.'] },
78
+ })).finally(() => service.close());
79
+ return { prompt: handle.prompt, completion, cancel: async () => { await handle.cancel(); }, close: () => service.close() };
80
+ } catch { await service.close(); throw new Error('Provider login could not be started.'); }
81
+ };
82
+
83
+ /** Convert a verified OpenAI login for native OpenCode without exposing it in public account DTOs. */
84
+ export const openCodeCredentialFromLogin = (credential: plugins.flexAccounts.IOpenAiChatGptOAuthCredential): {
85
+ type: 'oauth'; access: string; refresh: string; expires: number; accountId?: string;
86
+ } => {
87
+ try {
88
+ const auth = plugins.flexAccounts.toOpenAiChatGptAuthCredentials(credential);
89
+ const payload = credential.accessToken.split('.')[1];
90
+ let expires = 0;
91
+ if (payload && payload.length <= 48 * 1024) {
92
+ try {
93
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown };
94
+ expires = typeof claims?.exp === 'number' ? claims.exp * 1000 : 0;
95
+ } catch { /* Opaque access tokens are valid; native OpenCode refreshes an unknown expiry. */ }
96
+ }
97
+ return {
98
+ type: 'oauth', access: credential.accessToken, refresh: credential.refreshToken,
99
+ expires: Number.isSafeInteger(expires) && expires >= 0 ? expires : 0,
100
+ ...(auth.accountId ? { accountId: auth.accountId } : {}),
101
+ };
102
+ } catch { throw new Error('OpenAI login credential is invalid.'); }
103
+ };
@@ -0,0 +1,114 @@
1
+ import * as plugins from './plugins.js';
2
+ import { FileHarness, type IFileAccount, type IFileHarnessSnapshot } from './classes.fileharness.js';
3
+ import { CredentialStore, credentialHash, credentialRecord, credentialText, readCredentialDocument, requireHarnessStopped } from './classes.credentialstore.js';
4
+ import { CodexAccountStatus } from './classes.codexstatus.js';
5
+ import { writeSecretFileAtomically } from './helpers.js';
6
+ import type { IHarnessAccountStatus, IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
7
+ import { beginSavedOpenAiLogin, openCodeCredentialFromLogin } from './classes.login.js';
8
+ import { plainText } from './formatting.js';
9
+
10
+ export interface IOpenCodeHarnessOptions {
11
+ dataHome?: string;
12
+ stashRoot?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ fetch?: typeof fetch;
15
+ /** Process inspection dependency, injectable for isolated installations and tests. */
16
+ checkStopped?: () => void;
17
+ }
18
+
19
+ /** OpenCode owns one login per provider. Replacing a slot never replaces the whole provider set. */
20
+ export class OpenCodeHarness extends FileHarness {
21
+ public readonly id = 'opencode';
22
+ public readonly label = 'OpenCode';
23
+ public readonly loginHint = 'Use authswitch opencode login for OpenAI; use opencode auth login for other providers.';
24
+ public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
25
+ protected readonly store: CredentialStore;
26
+ private readonly file: string;
27
+ private readonly env: NodeJS.ProcessEnv;
28
+ private readonly status: CodexAccountStatus;
29
+ private readonly checkStopped: () => void;
30
+ private readonly fetcher?: typeof fetch;
31
+
32
+ constructor(options: IOpenCodeHarnessOptions = {}) {
33
+ super();
34
+ this.env = options.env ?? process.env;
35
+ this.file = plugins.path.join(options.dataHome ?? (this.env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local', 'share')), 'opencode', 'auth.json');
36
+ this.store = new CredentialStore(this.id, options.stashRoot);
37
+ this.status = new CodexAccountStatus(options.fetch);
38
+ this.fetcher = options.fetch;
39
+ this.checkStopped = options.checkStopped ?? (() => requireHarnessStopped('opencode'));
40
+ }
41
+ protected beforeMutation(): void { this.checkStopped(); }
42
+ /** Public identity only, for reconciling a host's OpenAI association after native refresh/restart. */
43
+ public activeOpenAiAccountId(): string | undefined {
44
+ const account = this.snapshot().accounts.find(item => item.slotId === 'openai');
45
+ return account ? this.openAiInfo(account.credential).accountId : undefined;
46
+ }
47
+ public beginLogin(options: IHarnessLoginOptions = { providerId: 'openai', flow: 'device' }) {
48
+ return beginSavedOpenAiLogin(result => {
49
+ const saved = this.importCredential('openai', openCodeCredentialFromLogin(result.credential));
50
+ const account = this.readState().accounts.find(account => account.id === saved.accountId)!;
51
+ return { outcome: { lines: [`Saved OpenAI login for ${plainText(account.label)}. Select it to switch OpenCode.`], problems: [] }, account: { id: account.id, label: account.label, slotId: account.slotId } };
52
+ }, options, this.fetcher);
53
+ }
54
+ private openAiInfo(entry: Record<string, unknown>): { accountId?: string; email?: string; plan?: string } {
55
+ if (entry.type !== 'oauth') return {};
56
+ let claims: plugins.flexAuth.IOpenAiChatGptTokenInfo | undefined;
57
+ try { claims = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credentialText(entry.access)!); } catch { /* Opaque tokens can still be preserved. */ }
58
+ const accountId = credentialText(entry.accountId) ?? claims?.chatgptAccountId;
59
+ if (entry.accountId && claims?.chatgptAccountId && entry.accountId !== claims.chatgptAccountId) throw new Error('OpenCode OpenAI account ID does not match the credential.');
60
+ return { accountId, email: claims?.email?.toLowerCase(), plan: claims?.chatgptPlanType };
61
+ }
62
+ protected inspect(credential: Record<string, unknown>, slotId: string): IFileAccount {
63
+ if (!slotId || slotId.length > 256) throw new Error('Unsupported OpenCode provider ID.');
64
+ const type = credential.type;
65
+ let secret: string | undefined;
66
+ if (type === 'oauth') {
67
+ if (!credentialText(credential.access) || !credentialText(credential.refresh) || typeof credential.expires !== 'number' || !Number.isFinite(credential.expires)) throw new Error('Unsupported OpenCode OAuth credential.');
68
+ secret = credentialText(credential.refresh);
69
+ } else if (type === 'api') secret = credentialText(credential.key);
70
+ else if (type === 'wellknown' && credentialText(credential.key)) secret = credentialText(credential.token);
71
+ if (!secret) throw new Error('Unsupported OpenCode credential; the provider entry was preserved.');
72
+ const info = slotId === 'openai' ? this.openAiInfo(credential) : {};
73
+ const fingerprint = credentialHash(secret);
74
+ const identity = info.accountId ? `oauth:${info.accountId}` : `${type}:${fingerprint}`;
75
+ const label = info.email ?? (info.accountId ? `openai:${info.accountId}` : `${slotId} ${type === 'api' ? 'API key' : type} ${fingerprint.slice(0, 12)}`);
76
+ return { slotId, identity, label, credential };
77
+ }
78
+ protected snapshot(): IFileHarnessSnapshot {
79
+ if (this.env.OPENCODE_AUTH_CONTENT !== undefined) return { accounts: [], unavailable: 'OPENCODE_AUTH_CONTENT overrides the credential file. Unset it before managing file logins.' };
80
+ const data = readCredentialDocument(this.file).data;
81
+ const accounts: IFileAccount[] = [];
82
+ const unsupported: string[] = [];
83
+ for (const [provider, value] of Object.entries(data)) {
84
+ try { accounts.push(this.inspect(credentialRecord(value), provider)); }
85
+ catch { unsupported.push(provider); }
86
+ }
87
+ return { accounts, unavailable: unsupported.length ? 'OpenCode contains unsupported credential entries. Resolve them with opencode auth before modifying logins.' : null };
88
+ }
89
+ protected apply(slotId: string, previous: IFileAccount | undefined, target: IFileAccount | undefined): void {
90
+ const snapshot = readCredentialDocument(this.file);
91
+ if (JSON.stringify(snapshot.data[slotId]) !== JSON.stringify(previous?.credential)) throw new Error('OpenCode login changed during the operation; no credential was replaced.');
92
+ const next = { ...snapshot.data };
93
+ if (target) Object.defineProperty(next, slotId, { value: target.credential, enumerable: true, configurable: true, writable: true });
94
+ else delete next[slotId];
95
+ const raw = JSON.stringify(next, null, 2) + '\n';
96
+ if (readCredentialDocument(this.file).raw !== snapshot.raw) throw new Error('OpenCode credential file changed during the operation. Try again after OpenCode exits.');
97
+ plugins.fs.mkdirSync(plugins.path.dirname(this.file), { recursive: true, mode: 0o700 });
98
+ writeSecretFileAtomically(this.file, raw);
99
+ if (readCredentialDocument(this.file).raw !== raw) throw new Error('Active login verification failed. The outgoing login remains saved.');
100
+ }
101
+ public async readAccountStatus(id: string): Promise<IHarnessAccountStatus> {
102
+ const account = this.readCredential(id);
103
+ if (account.slotId === 'openai' && account.credential.type === 'oauth') {
104
+ const info = this.openAiInfo(account.credential);
105
+ if (!info.accountId) return { facts: [], problems: ['The OpenAI login has no account ID for a status lookup.'] };
106
+ return this.status.readCredential(credentialText(account.credential.access)!, info.accountId, info.plan);
107
+ }
108
+ return { facts: [
109
+ { section: 'Authentication', label: 'Provider', value: account.slotId },
110
+ { section: 'Authentication', label: 'Login type', value: String(account.credential.type) },
111
+ { section: 'Availability', label: 'Account usage and billing', value: 'This provider login does not expose a supported subscription or quota API.' },
112
+ ], problems: [] };
113
+ }
114
+ }
@@ -0,0 +1,101 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IAuthHarness, IHarnessOutcome } from './interfaces.harness.js';
3
+
4
+ export type TAuthSwitchMutation = { harnessId: string } & (
5
+ | { action: 'save'; accountId?: string; keepActive: boolean }
6
+ | { action: 'switch' | 'remove'; accountId: string }
7
+ );
8
+ export interface IAuthSwitchCoordinationRequest {
9
+ protocolVersion: 1;
10
+ /** Identifies credential locations; a host must refuse a different environment. */
11
+ contextId: string;
12
+ mutation: TAuthSwitchMutation;
13
+ /** Consent to wait for current work to finish; never permission to kill an active session. */
14
+ waitForIdle: boolean;
15
+ }
16
+ export type TAuthSwitchCoordinationResult =
17
+ | { status: 'unavailable' }
18
+ | { status: 'busy'; message: string }
19
+ | { status: 'complete'; outcome: IHarnessOutcome };
20
+ export type TAuthSwitchCoordinator = (request: IAuthSwitchCoordinationRequest) => Promise<TAuthSwitchCoordinationResult>;
21
+ export type TAuthSwitchConfirmation = (message: string) => Promise<boolean>;
22
+
23
+ export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): string => plugins.crypto.createHash('sha256').update(JSON.stringify({
24
+ home: plugins.os.homedir(),
25
+ stash: plugins.path.resolve(env.AUTHSWITCH_HOME || plugins.path.join(plugins.os.homedir(), '.authswitch')),
26
+ codex: plugins.path.resolve(env.CODEX_HOME || plugins.path.join(plugins.os.homedir(), '.codex')),
27
+ data: plugins.path.resolve(env.XDG_DATA_HOME || plugins.path.join(plugins.os.homedir(), '.local/share')),
28
+ claude: plugins.path.resolve(env.CLAUDE_CONFIG_DIR || plugins.path.join(plugins.os.homedir(), '.claude')),
29
+ openCodeOverride: env.OPENCODE_AUTH_CONTENT !== undefined,
30
+ })).digest('hex');
31
+
32
+ /** Validate the secret-free command boundary, including callers that do not use TypeScript. */
33
+ export const assertAuthSwitchMutation: (value: unknown) => asserts value is TAuthSwitchMutation = (value) => {
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid account operation.');
35
+ const item = value as Record<string, unknown>;
36
+ const keys = item.action === 'save' ? ['harnessId', 'action', 'accountId', 'keepActive'] : ['harnessId', 'action', 'accountId'];
37
+ if (Object.keys(item).some(key => !keys.includes(key))
38
+ || typeof item.harnessId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(item.harnessId)
39
+ || !['save', 'switch', 'remove'].includes(String(item.action))
40
+ || (item.accountId !== undefined && (typeof item.accountId !== 'string' || !item.accountId.length || item.accountId.length > 512))
41
+ || (item.action === 'save' ? typeof item.keepActive !== 'boolean' : typeof item.accountId !== 'string')) {
42
+ throw new Error('Invalid account operation.');
43
+ }
44
+ };
45
+
46
+ export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<IHarnessOutcome> => {
47
+ assertAuthSwitchMutation(mutation);
48
+ if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
49
+ switch (mutation.action) {
50
+ case 'save': return harness.saveCurrent({ keepActive: mutation.keepActive, accountId: mutation.accountId });
51
+ case 'switch': return harness.switchAccount(mutation.accountId);
52
+ case 'remove': return harness.removeAccount(mutation.accountId);
53
+ }
54
+ };
55
+
56
+ /** One mutation path for command, guide and TUI. Supervisors own their complete restart transaction. */
57
+ export class AuthSwitchOperations {
58
+ constructor(private readonly coordinator?: TAuthSwitchCoordinator) {}
59
+ public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation): Promise<IHarnessOutcome> {
60
+ assertAuthSwitchMutation(mutation);
61
+ if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
62
+ if (this.coordinator && mutation.action !== 'remove') {
63
+ const request: IAuthSwitchCoordinationRequest = { protocolVersion: 1, contextId: authSwitchEnvironmentId(), mutation, waitForIdle: false };
64
+ let result = await this.coordinator(request);
65
+ if (result.status === 'busy') {
66
+ if (!confirm || !await confirm(result.message)) return { lines: [], problems: [result.message] };
67
+ result = await this.coordinator({ ...request, waitForIdle: true });
68
+ }
69
+ if (result.status === 'complete') return result.outcome;
70
+ if (result.status === 'busy') return { lines: [], problems: [result.message] };
71
+ }
72
+ return runAuthSwitchMutation(harness, mutation);
73
+ }
74
+ }
75
+
76
+ /** AGL owns authenticated controller discovery. No AGL dependency, tokens or credential payloads cross this bridge. */
77
+ export class AglAuthSwitchCoordinator {
78
+ public async coordinate(request: IAuthSwitchCoordinationRequest): Promise<TAuthSwitchCoordinationResult> {
79
+ assertAuthSwitchMutation(request.mutation);
80
+ const output = await new Promise<string | undefined>((resolve, reject) => {
81
+ plugins.childProcess.execFile('agl', ['authswitch', '--request', JSON.stringify(request)], {
82
+ encoding: 'utf8', timeout: 360_000, maxBuffer: 1024 * 1024, windowsHide: true,
83
+ }, (error, stdout) => {
84
+ if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') { resolve(undefined); return; }
85
+ if (error) { reject(new Error('AGL coordination did not complete. Check AGL and account status before retrying; no local credential operation was attempted. Install an AGL version with authswitch support.')); return; }
86
+ resolve(stdout);
87
+ });
88
+ });
89
+ if (output === undefined) return { status: 'unavailable' };
90
+ try {
91
+ const result = JSON.parse(output) as TAuthSwitchCoordinationResult;
92
+ if (result.status === 'unavailable') return { status: 'unavailable' };
93
+ if (result.status === 'busy' && typeof result.message === 'string' && result.message.length <= 4096) return { status: 'busy', message: result.message };
94
+ if (result.status === 'complete' && result.outcome && ['lines', 'problems'].every(key => {
95
+ const items = result.outcome[key as keyof IHarnessOutcome];
96
+ return Array.isArray(items) && items.length <= 100 && items.every(item => typeof item === 'string' && item.length <= 4096);
97
+ })) return { status: 'complete', outcome: { lines: result.outcome.lines, problems: result.outcome.problems } };
98
+ } catch { /* Never print raw transport output, which may contain native diagnostics. */ }
99
+ throw new Error('AGL returned an unsupported coordination response. Check account status before retrying.');
100
+ }
101
+ }