@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,208 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IRemoteControlEnrollment } from './interfaces.js';
3
+ import { CodexHome } from './classes.codexhome.js';
4
+
5
+ const TABLE_NAME = 'remote_control_enrollments';
6
+
7
+ /** The columns authswitch knows how to carry across a switch. */
8
+ const COLUMNS = [
9
+ 'websocket_url',
10
+ 'account_id',
11
+ 'app_server_client_name',
12
+ 'server_id',
13
+ 'environment_id',
14
+ 'server_name',
15
+ 'updated_at',
16
+ 'remote_control_enabled',
17
+ ] as const;
18
+
19
+ /** The columns that make up the table's primary key. */
20
+ const KEY_COLUMNS = ['websocket_url', 'account_id', 'app_server_client_name'] as const;
21
+
22
+ /**
23
+ * Reads and restores Codex' remote-control enrollments.
24
+ *
25
+ * An enrollment is the record that binds this machine's Codex app-server to a
26
+ * paired ChatGPT client. Its primary key includes the account id, so enrollments
27
+ * for several accounts can coexist -- but a credential swap can still leave an
28
+ * account without its row, which is exactly what breaks remote control after a
29
+ * naive auth switch. authswitch captures the rows next to the credential and
30
+ * puts them back when the credential is restored.
31
+ */
32
+ export class RemoteControlStore {
33
+ public readonly codexHome: CodexHome;
34
+
35
+ constructor(codexHomeArg: CodexHome) {
36
+ this.codexHome = codexHomeArg;
37
+ }
38
+
39
+ /** True when a Codex state database exists and carries the enrollment table. */
40
+ public isAvailable(): boolean {
41
+ const statePath = this.codexHome.statePath;
42
+ if (!statePath) {
43
+ return false;
44
+ }
45
+ try {
46
+ return this.withDatabase(statePath, true, (databaseArg) => this.hasTable(databaseArg));
47
+ } catch {
48
+ // A read-only connection cannot create the -shm file a WAL database needs,
49
+ // so an unclean shutdown can make even the probe fail. Report "not
50
+ // available" here; the restore path probes over its writable connection.
51
+ return false;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Returns the enrollments for one account, or every enrollment when no
57
+ * account id is given.
58
+ */
59
+ public read(accountIdArg?: string | null): IRemoteControlEnrollment[] {
60
+ const statePath = this.codexHome.statePath;
61
+ if (!statePath) {
62
+ return [];
63
+ }
64
+ try {
65
+ return this.withDatabase(statePath, true, (databaseArg) => {
66
+ if (!this.hasTable(databaseArg)) {
67
+ return [];
68
+ }
69
+ const selection = COLUMNS.join(', ');
70
+ const statement = accountIdArg
71
+ ? databaseArg.prepare(`select ${selection} from ${TABLE_NAME} where account_id = ?`)
72
+ : databaseArg.prepare(`select ${selection} from ${TABLE_NAME}`);
73
+ const rows = accountIdArg ? statement.all(accountIdArg) : statement.all();
74
+ return rows.map((rowArg) => this.toEnrollment(rowArg as Record<string, unknown>));
75
+ });
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Puts enrollments back, updating any row with the same primary key.
83
+ *
84
+ * Throws rather than reporting zero when the database or the table is not
85
+ * there: restoring remote control is the reason this tool exists, so failing
86
+ * to do it must surface instead of looking like "there was nothing to do".
87
+ */
88
+ public restore(enrollmentsArg: IRemoteControlEnrollment[]): number {
89
+ if (enrollmentsArg.length === 0) {
90
+ return 0;
91
+ }
92
+ const statePath = this.codexHome.statePath;
93
+ if (!statePath) {
94
+ throw new Error(
95
+ `no Codex state database found in ${this.codexHome.dir}; cannot restore remote-control enrollments`,
96
+ );
97
+ }
98
+ // Opened read-write: the read-only probe cannot create the -shm file a WAL
99
+ // database needs, and failing there would leave the switch half-applied.
100
+ return this.withDatabase(statePath, false, (databaseArg) => {
101
+ if (!this.hasTable(databaseArg)) {
102
+ throw new Error(
103
+ `${statePath} has no ${TABLE_NAME} table; this Codex version stores remote-control enrollments elsewhere`,
104
+ );
105
+ }
106
+ this.assertKnownColumns(databaseArg, statePath);
107
+
108
+ const placeholders = COLUMNS.map(() => '?').join(', ');
109
+ const updates = COLUMNS.filter(
110
+ (columnArg) => !(KEY_COLUMNS as readonly string[]).includes(columnArg),
111
+ )
112
+ .map((columnArg) => `${columnArg} = excluded.${columnArg}`)
113
+ .join(', ');
114
+ const statement = databaseArg.prepare(
115
+ `insert into ${TABLE_NAME} (${COLUMNS.join(', ')}) values (${placeholders})
116
+ on conflict(${KEY_COLUMNS.join(', ')}) do update set ${updates}`,
117
+ );
118
+
119
+ databaseArg.exec('begin immediate');
120
+ try {
121
+ for (const enrollmentArg of enrollmentsArg) {
122
+ statement.run(
123
+ enrollmentArg.websocketUrl,
124
+ enrollmentArg.accountId,
125
+ enrollmentArg.appServerClientName,
126
+ enrollmentArg.serverId,
127
+ enrollmentArg.environmentId,
128
+ enrollmentArg.serverName,
129
+ enrollmentArg.updatedAt,
130
+ enrollmentArg.remoteControlEnabled,
131
+ );
132
+ }
133
+ databaseArg.exec('commit');
134
+ } catch (errorArg) {
135
+ databaseArg.exec('rollback');
136
+ throw errorArg;
137
+ }
138
+ return enrollmentsArg.length;
139
+ });
140
+ }
141
+
142
+ private hasTable(databaseArg: InstanceType<typeof plugins.DatabaseSync>): boolean {
143
+ const row = databaseArg
144
+ .prepare(`select name from sqlite_master where type = 'table' and name = ?`)
145
+ .get(TABLE_NAME);
146
+ return row !== undefined;
147
+ }
148
+
149
+ /**
150
+ * Refuses to write when Codex has added columns this version does not know
151
+ * about. `CodexHome` deliberately follows the newest state generation, so a
152
+ * Codex upgrade can widen this table -- and writing only the columns we know
153
+ * would quietly reset the rest to their defaults.
154
+ */
155
+ private assertKnownColumns(
156
+ databaseArg: InstanceType<typeof plugins.DatabaseSync>,
157
+ statePathArg: string,
158
+ ): void {
159
+ const rows = databaseArg.prepare(`pragma table_info(${TABLE_NAME})`).all() as Array<
160
+ Record<string, unknown>
161
+ >;
162
+ const actual = rows.map((rowArg) => String(rowArg.name));
163
+ const unexpected = actual.filter((nameArg) => !(COLUMNS as readonly string[]).includes(nameArg));
164
+ const missing = COLUMNS.filter((nameArg) => !actual.includes(nameArg));
165
+ if (unexpected.length > 0 || missing.length > 0) {
166
+ throw new Error(
167
+ `${statePathArg} has an unexpected ${TABLE_NAME} schema ` +
168
+ `(unexpected: ${unexpected.join(', ') || 'none'}; missing: ${missing.join(', ') || 'none'}). ` +
169
+ 'Refusing to restore rather than write a partial row - authswitch needs an update for this Codex version.',
170
+ );
171
+ }
172
+ }
173
+
174
+ private toEnrollment(rowArg: Record<string, unknown>): IRemoteControlEnrollment {
175
+ return {
176
+ websocketUrl: String(rowArg.websocket_url ?? ''),
177
+ accountId: String(rowArg.account_id ?? ''),
178
+ appServerClientName: String(rowArg.app_server_client_name ?? ''),
179
+ serverId: String(rowArg.server_id ?? ''),
180
+ environmentId: String(rowArg.environment_id ?? ''),
181
+ serverName: String(rowArg.server_name ?? ''),
182
+ updatedAt: Number(rowArg.updated_at ?? 0),
183
+ remoteControlEnabled:
184
+ rowArg.remote_control_enabled === null || rowArg.remote_control_enabled === undefined
185
+ ? null
186
+ : Number(rowArg.remote_control_enabled),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Codex keeps the state database in WAL mode and holds it open from the
192
+ * app-server, so every access takes a busy timeout rather than failing on the
193
+ * first lock contention.
194
+ */
195
+ private withDatabase<T>(
196
+ pathArg: string,
197
+ readOnlyArg: boolean,
198
+ handlerArg: (databaseArg: InstanceType<typeof plugins.DatabaseSync>) => T,
199
+ ): T {
200
+ const database = new plugins.DatabaseSync(pathArg, { readOnly: readOnlyArg });
201
+ try {
202
+ database.exec('pragma busy_timeout = 5000');
203
+ return handlerArg(database);
204
+ } finally {
205
+ database.close();
206
+ }
207
+ }
208
+ }
@@ -0,0 +1,152 @@
1
+ import * as plugins from './plugins.js';
2
+ import { CodexHarness } from './classes.codexharness.js';
3
+ import { OpenCodeHarness } from './classes.opencodeharness.js';
4
+ import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
5
+ import { readAccountList } from './classes.accountlist.js';
6
+ import { assertAuthSwitchMutation, authSwitchEnvironmentId, runAuthSwitchMutation, type IAuthSwitchCoordinationRequest, type TAuthSwitchCoordinationResult, type TAuthSwitchCoordinator } from './classes.operations.js';
7
+ import type { IAuthHarness, IHarnessLoginHandle, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessLoginResult, IHarnessOutcome } from './interfaces.harness.js';
8
+ import type { IAccountList } from './interfaces.list.js';
9
+ import type { TAuthLoginPrompt } from './classes.login.js';
10
+
11
+ export type TAuthSwitchServiceRequest = { protocolVersion: 1 } & (
12
+ | { action: 'list'; harnessId?: string }
13
+ | { action: 'mutate'; coordination: IAuthSwitchCoordinationRequest }
14
+ | ({ action: 'login'; harnessId: string } & IHarnessLoginOptions)
15
+ | { action: 'get' | 'cancel'; operationId: string }
16
+ );
17
+ /** Disposable operation status. Hosts authenticate callers; neither prompts nor results contain credentials. */
18
+ export interface IAuthSwitchServiceOperation {
19
+ id: string;
20
+ contextId: string;
21
+ state: 'pending' | 'complete' | 'failed';
22
+ prompt?: TAuthLoginPrompt;
23
+ accounts?: IAccountList;
24
+ loginCapabilities?: { harnessId: string; providers: IHarnessLoginProvider[] }[];
25
+ account?: IHarnessLoginResult['account'];
26
+ result?: TAuthSwitchCoordinationResult;
27
+ error?: string;
28
+ }
29
+ interface IOperation {
30
+ status: IAuthSwitchServiceOperation;
31
+ task: Promise<void>;
32
+ finishedAt?: number;
33
+ login?: IHarnessLoginHandle;
34
+ cancelled: boolean;
35
+ }
36
+
37
+ const publicOutcome = (outcome: IHarnessOutcome): IHarnessOutcome => ({ lines: [...outcome.lines], problems: [...outcome.problems] });
38
+ const publicResult = (result: TAuthSwitchCoordinationResult): TAuthSwitchCoordinationResult => result.status === 'complete'
39
+ ? { status: 'complete', outcome: publicOutcome(result.outcome) }
40
+ : result.status === 'busy' ? { status: 'busy', message: result.message } : { status: 'unavailable' };
41
+ const publicPrompt = (prompt: TAuthLoginPrompt): TAuthLoginPrompt => prompt.flow === 'device'
42
+ ? { flow: 'device', verificationUrl: prompt.verificationUrl, userCode: prompt.userCode }
43
+ : { flow: 'browser', authUrl: prompt.authUrl };
44
+
45
+ export const assertAuthSwitchServiceRequest: (value: unknown) => asserts value is TAuthSwitchServiceRequest = value => {
46
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid authswitch request.');
47
+ const request = value as Record<string, unknown>;
48
+ const keys = request.action === 'login' ? ['harnessId', 'providerId', 'flow'] : request.action === 'list' ? ['harnessId']
49
+ : request.action === 'mutate' ? ['coordination'] : ['operationId'];
50
+ if (request.protocolVersion !== 1 || !['list', 'login', 'mutate', 'get', 'cancel'].includes(String(request.action))
51
+ || Object.keys(request).some(key => !['protocolVersion', 'action', ...keys].includes(key))) throw new Error('Unsupported authswitch request.');
52
+ if (request.action === 'mutate') {
53
+ const coordination = request.coordination as IAuthSwitchCoordinationRequest | undefined;
54
+ if (!coordination || coordination.protocolVersion !== 1 || typeof coordination.waitForIdle !== 'boolean'
55
+ || typeof coordination.contextId !== 'string' || !/^[a-f0-9]{64}$/.test(coordination.contextId)
56
+ || Object.keys(coordination).some(key => !['protocolVersion', 'contextId', 'mutation', 'waitForIdle'].includes(key))) throw new Error('Invalid coordination request.');
57
+ assertAuthSwitchMutation(coordination.mutation);
58
+ } else if (request.action === 'get' || request.action === 'cancel') {
59
+ if (typeof request.operationId !== 'string' || !/^[a-f0-9-]{36}$/.test(request.operationId)) throw new Error('Invalid operation ID.');
60
+ } else if ((request.harnessId !== undefined || request.action === 'login')
61
+ && (typeof request.harnessId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(request.harnessId))) throw new Error('Invalid harness ID.');
62
+ if (request.action === 'login' && (typeof request.providerId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(request.providerId)
63
+ || !['device', 'browser'].includes(String(request.flow)))) throw new Error('Invalid login provider or flow.');
64
+ };
65
+
66
+ /** Hosted account manager. Operations outlive HTTP connections and are retained briefly for polling. */
67
+ export class AuthSwitchService {
68
+ private readonly operations = new Map<string, IOperation>();
69
+ private closed = false;
70
+ constructor(
71
+ private readonly harnesses: IAuthHarness[] = [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()],
72
+ private readonly coordinator?: TAuthSwitchCoordinator,
73
+ ) {}
74
+ private harness(id: string): IAuthHarness {
75
+ const harness = this.harnesses.find(item => item.id === id);
76
+ if (!harness) throw new Error('Unknown harness.');
77
+ return harness;
78
+ }
79
+ private snapshot(operation: IOperation): IAuthSwitchServiceOperation { return structuredClone(operation.status); }
80
+ public async request(request: TAuthSwitchServiceRequest): Promise<IAuthSwitchServiceOperation> {
81
+ assertAuthSwitchServiceRequest(request);
82
+ if (this.closed) throw new Error('Account manager is closed.');
83
+ for (const [id, operation] of this.operations) {
84
+ if (operation.finishedAt !== undefined && Date.now() - operation.finishedAt > 10 * 60_000) this.operations.delete(id);
85
+ }
86
+ if (request.action === 'get' || request.action === 'cancel') {
87
+ const operation = this.operations.get(request.operationId);
88
+ if (!operation) throw new Error('Operation expired or belongs to another controller. Check account status before retrying.');
89
+ if (request.action === 'cancel') {
90
+ if (operation.status.state === 'pending' && !operation.status.prompt) throw new Error('Only a pending login can be cancelled.');
91
+ operation.cancelled = true;
92
+ await operation.login?.cancel();
93
+ }
94
+ return this.snapshot(operation);
95
+ }
96
+ if (this.operations.size >= 64 || [...this.operations.values()].filter(item => item.status.state === 'pending').length >= 8) {
97
+ throw new Error('Account manager operation limit reached. Finish pending operations before starting another.');
98
+ }
99
+ const operation: IOperation = { status: { id: plugins.crypto.randomUUID(), contextId: authSwitchEnvironmentId(), state: 'pending' }, task: Promise.resolve(), cancelled: false };
100
+ this.operations.set(operation.status.id, operation);
101
+ operation.task = Promise.resolve().then(async () => {
102
+ switch (request.action) {
103
+ case 'list': {
104
+ operation.status.accounts = await readAccountList(request.harnessId ? [this.harness(request.harnessId)] : this.harnesses);
105
+ operation.status.loginCapabilities = this.harnesses.filter(item => item.beginLogin).map(item => ({ harnessId: item.id,
106
+ providers: (item.loginProviders ?? []).map(provider => ({ providerId: provider.providerId, label: provider.label, flows: [...provider.flows] })) }));
107
+ break;
108
+ }
109
+ case 'mutate': {
110
+ if (request.coordination.contextId !== authSwitchEnvironmentId()) {
111
+ operation.status.result = { status: 'complete', outcome: { lines: [], problems: ['AGL and authswitch use different credential locations. Use matching credential environment settings before switching.'] } };
112
+ break;
113
+ }
114
+ const harness = this.harness(request.coordination.mutation.harnessId);
115
+ const coordinated = this.coordinator ? await this.coordinator(request.coordination) : { status: 'unavailable' } as const;
116
+ operation.status.result = publicResult(coordinated.status === 'unavailable'
117
+ ? { status: 'complete', outcome: await runAuthSwitchMutation(harness, request.coordination.mutation) } : coordinated);
118
+ break;
119
+ }
120
+ case 'login': {
121
+ const harness = this.harness(request.harnessId);
122
+ if (!harness.beginLogin) throw new Error('This harness does not support hosted login.');
123
+ if (!harness.loginProviders?.some(provider => provider.providerId === request.providerId && provider.flows.includes(request.flow))) throw new Error('This harness does not support that login flow.');
124
+ const login = await harness.beginLogin({ providerId: request.providerId, flow: request.flow });
125
+ operation.login = login;
126
+ operation.status.prompt = publicPrompt(login.prompt);
127
+ if (operation.cancelled || this.closed) await login.cancel();
128
+ try {
129
+ const result = await login.completion;
130
+ operation.status.result = { status: 'complete', outcome: publicOutcome(result.outcome) };
131
+ operation.status.account = result.account ? { id: result.account.id, label: result.account.label, slotId: result.account.slotId } : undefined;
132
+ }
133
+ finally { await login.close(); operation.login = undefined; }
134
+ break;
135
+ }
136
+ }
137
+ operation.status.state = 'complete';
138
+ }).catch(() => {
139
+ operation.status.state = 'failed';
140
+ operation.status.error = 'Account operation failed. Check account and runtime status before retrying.';
141
+ }).finally(() => { operation.finishedAt = Date.now(); });
142
+ return this.snapshot(operation);
143
+ }
144
+ public async close(): Promise<void> {
145
+ this.closed = true;
146
+ await Promise.allSettled([...this.operations.values()].map(async operation => {
147
+ operation.cancelled = true;
148
+ await operation.login?.cancel();
149
+ }));
150
+ await Promise.all([...this.operations.values()].map(operation => operation.task));
151
+ }
152
+ }
@@ -0,0 +1,223 @@
1
+ import * as plugins from './plugins.js';
2
+ import type {
3
+ ICodexIdentity,
4
+ IRemoteControlEnrollment,
5
+ IStashEntry,
6
+ THarnessName,
7
+ } from './interfaces.js';
8
+ import { writeSecretFileAtomically } from './helpers.js';
9
+
10
+ const AUTH_FILE_NAME = 'auth.json';
11
+ const META_FILE_NAME = 'stash.json';
12
+
13
+ /**
14
+ * Filesystem-backed store for Codex credentials and enrollment metadata.
15
+ * Other harness adapters own their storage format; this is not a universal auth schema.
16
+ *
17
+ * A stash is deliberately a directory of files rather than a database: the thing
18
+ * being stored *is* a credential file, the store must survive with no services
19
+ * running, and it must be greppable and removable by hand when something goes
20
+ * wrong. Everything is written owner-only.
21
+ */
22
+ export class StashStore {
23
+ public readonly rootDir: string;
24
+ public readonly harness: THarnessName;
25
+
26
+ constructor(harnessArg: THarnessName, rootDirArg?: string) {
27
+ if (!/^[a-z][a-z0-9-]*$/.test(harnessArg)) {
28
+ throw new Error('invalid harness namespace');
29
+ }
30
+ this.harness = harnessArg;
31
+ const base =
32
+ rootDirArg ??
33
+ process.env.AUTHSWITCH_HOME ??
34
+ plugins.path.join(plugins.os.homedir(), '.authswitch');
35
+ this.rootDir = plugins.path.join(base, harnessArg);
36
+ }
37
+
38
+ /**
39
+ * Turns an account email into a directory name that is safe on every
40
+ * filesystem while staying readable, so `ls ~/.authswitch/codex` is useful.
41
+ */
42
+ public slugFor(emailArg: string): string {
43
+ const slug = emailArg
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9._@+-]+/g, '_')
46
+ .replace(/^[._]+|[._]+$/g, '');
47
+ if (slug.length === 0) {
48
+ throw new Error(`cannot derive a stash name from "${emailArg}"`);
49
+ }
50
+ return slug;
51
+ }
52
+
53
+ public entryDir(emailArg: string): string {
54
+ return plugins.path.join(this.rootDir, this.slugFor(emailArg));
55
+ }
56
+
57
+ public has(emailArg: string): boolean {
58
+ return plugins.fs.existsSync(plugins.path.join(this.entryDir(emailArg), META_FILE_NAME));
59
+ }
60
+
61
+ /**
62
+ * Writes a credential and its remote-control enrollments under the account email,
63
+ * replacing any existing stash for the same account.
64
+ *
65
+ * Both files go through an atomic temp-and-rename, and the metadata is written
66
+ * last: a crash mid-write therefore leaves either the previous complete stash
67
+ * or a credential whose metadata is still the old one, never a directory that
68
+ * `list()` skips because its metadata never arrived. The caller is expected to
69
+ * verify with `readBack()` before destroying the original.
70
+ */
71
+ public write(
72
+ identityArg: ICodexIdentity,
73
+ rawAuthArg: string,
74
+ enrollmentsArg: IRemoteControlEnrollment[],
75
+ codexHomeArg: string,
76
+ ): IStashEntry {
77
+ const previous = this.readMeta(identityArg.email);
78
+ if (previous && (previous.email !== identityArg.email || previous.accountId !== identityArg.accountId || previous.kind !== identityArg.kind)) {
79
+ throw new Error('a different Codex account already occupies this email-based stash; refusing to replace its credential');
80
+ }
81
+ const dir = this.entryDir(identityArg.email);
82
+ plugins.fs.mkdirSync(this.rootDir, { recursive: true, mode: 0o700 });
83
+ plugins.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
84
+ // mkdirSync's mode is subject to the umask and is ignored for a directory
85
+ // that already exists, so tighten both levels explicitly.
86
+ plugins.fs.chmodSync(this.rootDir, 0o700);
87
+ plugins.fs.chmodSync(dir, 0o700);
88
+
89
+ const entry: IStashEntry = {
90
+ email: identityArg.email,
91
+ kind: identityArg.kind,
92
+ accountId: identityArg.accountId,
93
+ planType: identityArg.planType,
94
+ userId: identityArg.userId,
95
+ stashedAt: new Date().toISOString(),
96
+ codexHome: codexHomeArg,
97
+ enrollments: enrollmentsArg,
98
+ };
99
+
100
+ writeSecretFileAtomically(plugins.path.join(dir, AUTH_FILE_NAME), rawAuthArg);
101
+ writeSecretFileAtomically(
102
+ plugins.path.join(dir, META_FILE_NAME),
103
+ `${JSON.stringify(entry, null, 2)}\n`,
104
+ );
105
+ return entry;
106
+ }
107
+
108
+ /**
109
+ * Reads a stash back and confirms it matches what was meant to be written.
110
+ * Callers use this before clearing the active credential, so a stash is only
111
+ * ever trusted once it has been proven readable.
112
+ */
113
+ public readBack(emailArg: string, expectedAuthArg: string): boolean {
114
+ const meta = this.readMeta(emailArg);
115
+ const auth = this.readAuth(emailArg);
116
+ return meta !== null && auth === expectedAuthArg;
117
+ }
118
+
119
+ public readMeta(emailArg: string): IStashEntry | null {
120
+ const metaPath = plugins.path.join(this.entryDir(emailArg), META_FILE_NAME);
121
+ if (!plugins.fs.existsSync(metaPath)) {
122
+ return null;
123
+ }
124
+ try {
125
+ return JSON.parse(plugins.fs.readFileSync(metaPath, 'utf8')) as IStashEntry;
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ public readAuth(emailArg: string): string | null {
132
+ const authPath = plugins.path.join(this.entryDir(emailArg), AUTH_FILE_NAME);
133
+ if (!plugins.fs.existsSync(authPath)) {
134
+ return null;
135
+ }
136
+ return plugins.fs.readFileSync(authPath, 'utf8');
137
+ }
138
+
139
+ /** Every stash, newest first. Unreadable directories are skipped, not fatal. */
140
+ public list(): IStashEntry[] {
141
+ if (!plugins.fs.existsSync(this.rootDir)) {
142
+ return [];
143
+ }
144
+ const entries: IStashEntry[] = [];
145
+ for (const nameArg of plugins.fs.readdirSync(this.rootDir)) {
146
+ const metaPath = plugins.path.join(this.rootDir, nameArg, META_FILE_NAME);
147
+ if (!plugins.fs.existsSync(metaPath)) {
148
+ continue;
149
+ }
150
+ try {
151
+ entries.push(JSON.parse(plugins.fs.readFileSync(metaPath, 'utf8')) as IStashEntry);
152
+ } catch {
153
+ continue;
154
+ }
155
+ }
156
+ return entries.sort((leftArg, rightArg) => rightArg.stashedAt.localeCompare(leftArg.stashedAt));
157
+ }
158
+
159
+ /**
160
+ * Directories under the stash root that hold a credential but no readable
161
+ * metadata -- the shape an interrupted write would leave behind. Reported by
162
+ * `doctor` so they are visible rather than silently skipped by `list()`.
163
+ */
164
+ public incomplete(): string[] {
165
+ if (!plugins.fs.existsSync(this.rootDir)) {
166
+ return [];
167
+ }
168
+ return plugins.fs.readdirSync(this.rootDir).filter((nameArg) => {
169
+ const dir = plugins.path.join(this.rootDir, nameArg);
170
+ if (!plugins.fs.statSync(dir).isDirectory()) {
171
+ return false;
172
+ }
173
+ const hasAuth = plugins.fs.existsSync(plugins.path.join(dir, AUTH_FILE_NAME));
174
+ const hasMeta = plugins.fs.existsSync(plugins.path.join(dir, META_FILE_NAME));
175
+ return hasAuth && !hasMeta;
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Resolves a user-supplied account reference to a stashed email.
181
+ *
182
+ * Accepts the exact email, the directory slug, the local part, or a prefix.
183
+ * Deliberately not a substring match: this reference selects which credential
184
+ * becomes active, so an incidental substring hit would activate an account the
185
+ * user never named.
186
+ */
187
+ public resolve(referenceArg: string): { email: string } | { candidates: string[] } {
188
+ const needle = referenceArg.toLowerCase();
189
+ const all = this.list();
190
+
191
+ const exact = all.find((entryArg) => entryArg.email.toLowerCase() === needle);
192
+ if (exact) {
193
+ return { email: exact.email };
194
+ }
195
+ const bySlug = all.find((entryArg) => this.slugFor(entryArg.email) === needle);
196
+ if (bySlug) {
197
+ return { email: bySlug.email };
198
+ }
199
+ const byLocalPart = all.filter(
200
+ (entryArg) => entryArg.email.toLowerCase().split('@')[0] === needle,
201
+ );
202
+ if (byLocalPart.length === 1) {
203
+ return { email: byLocalPart[0].email };
204
+ }
205
+ if (byLocalPart.length > 1) {
206
+ return { candidates: byLocalPart.map((entryArg) => entryArg.email) };
207
+ }
208
+ const byPrefix = all.filter((entryArg) => entryArg.email.toLowerCase().startsWith(needle));
209
+ if (byPrefix.length === 1) {
210
+ return { email: byPrefix[0].email };
211
+ }
212
+ return { candidates: byPrefix.map((entryArg) => entryArg.email) };
213
+ }
214
+
215
+ public remove(emailArg: string): boolean {
216
+ const dir = this.entryDir(emailArg);
217
+ if (!plugins.fs.existsSync(dir)) {
218
+ return false;
219
+ }
220
+ plugins.fs.rmSync(dir, { recursive: true, force: true });
221
+ return true;
222
+ }
223
+ }