@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,199 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { IActiveAuth, ICodexIdentity, TAuthSource } from './interfaces.js';
3
+ import { CodexHome } from './classes.codexhome.js';
4
+ import { writeSecretFileAtomically } from './helpers.js';
5
+
6
+ /**
7
+ * Decodes the payload of a JWT without verifying it.
8
+ *
9
+ * authswitch never uses these claims for authorization -- only to label a stash
10
+ * with the account it belongs to -- so signature verification would buy nothing.
11
+ */
12
+ const decodeJwtPayload = (tokenArg: string): Record<string, unknown> | null => {
13
+ const segments = tokenArg.split('.');
14
+ if (segments.length < 2) {
15
+ return null;
16
+ }
17
+ try {
18
+ const decoded = Buffer.from(segments[1], 'base64url').toString('utf8');
19
+ const parsed: unknown = JSON.parse(decoded);
20
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
21
+ return parsed as Record<string, unknown>;
22
+ }
23
+ return null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ };
28
+
29
+ const asString = (valueArg: unknown): string | null =>
30
+ typeof valueArg === 'string' && valueArg.length > 0 ? valueArg : null;
31
+
32
+ /**
33
+ * Walks an arbitrary JSON value and returns the first string that looks like a JWT.
34
+ * Used as a fallback so a Codex schema change cannot silently break stash labelling.
35
+ */
36
+ const findFirstJwt = (valueArg: unknown, depthArg = 0): string | null => {
37
+ if (depthArg > 6) {
38
+ return null;
39
+ }
40
+ if (typeof valueArg === 'string') {
41
+ return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+$/.test(valueArg) ? valueArg : null;
42
+ }
43
+ if (Array.isArray(valueArg)) {
44
+ for (const itemArg of valueArg) {
45
+ const found = findFirstJwt(itemArg, depthArg + 1);
46
+ if (found) return found;
47
+ }
48
+ return null;
49
+ }
50
+ if (valueArg && typeof valueArg === 'object') {
51
+ for (const itemArg of Object.values(valueArg as Record<string, unknown>)) {
52
+ const found = findFirstJwt(itemArg, depthArg + 1);
53
+ if (found) return found;
54
+ }
55
+ }
56
+ return null;
57
+ };
58
+
59
+ /**
60
+ * Reads, writes and clears the Codex file credential store, and derives the
61
+ * account identity used to key a stash.
62
+ *
63
+ * Nothing in this class logs or returns a token value; only the raw string that
64
+ * callers copy verbatim between the active slot and the stash.
65
+ */
66
+ export class CodexAuth {
67
+ public readonly codexHome: CodexHome;
68
+
69
+ constructor(codexHomeArg: CodexHome) {
70
+ this.codexHome = codexHomeArg;
71
+ }
72
+
73
+ /**
74
+ * Extracts the account identity from the content of an auth.json.
75
+ * Returns null when the file holds no recognisable credential.
76
+ */
77
+ 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');
153
+ }
154
+
155
+ /** Reads the active credential, if any. */
156
+ public readActive(): IActiveAuth {
157
+ const authFilePath = this.codexHome.authFilePath;
158
+ if (!plugins.fs.existsSync(authFilePath)) {
159
+ return {
160
+ source: this.detectNonFileSource(),
161
+ authFilePath,
162
+ identity: null,
163
+ raw: null,
164
+ };
165
+ }
166
+ const raw = plugins.fs.readFileSync(authFilePath, 'utf8');
167
+ return {
168
+ source: 'file',
169
+ authFilePath,
170
+ identity: this.identityFromRaw(raw),
171
+ raw,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * When no auth.json exists the credential is either absent or held by an OS
177
+ * keyring. The age-encrypted secrets directory is the observable marker of the
178
+ * keyring-backed store, so use it to tell the two apart.
179
+ */
180
+ private detectNonFileSource(): TAuthSource {
181
+ if (plugins.fs.existsSync(this.codexHome.secretsDir)) {
182
+ return 'keyring';
183
+ }
184
+ return 'none';
185
+ }
186
+
187
+ /** Writes a credential into the active slot atomically and owner-only. */
188
+ public writeActive(rawArg: string): void {
189
+ plugins.fs.mkdirSync(this.codexHome.dir, { recursive: true });
190
+ writeSecretFileAtomically(this.codexHome.authFilePath, rawArg);
191
+ }
192
+
193
+ /** Removes the active credential. Safe to call when none exists. */
194
+ public clearActive(): void {
195
+ if (plugins.fs.existsSync(this.codexHome.authFilePath)) {
196
+ plugins.fs.rmSync(this.codexHome.authFilePath);
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,137 @@
1
+ import * as plugins from './plugins.js';
2
+ import type { ICommandResult, IDaemonStatus } from './interfaces.js';
3
+ import { CodexHome } from './classes.codexhome.js';
4
+ import { describeCodexFailure } from './helpers.js';
5
+
6
+ /**
7
+ * Drives the Codex CLI itself for everything Codex already exposes.
8
+ *
9
+ * The local app-server holds the remote-control websocket, keeps the state
10
+ * database open, and refreshes tokens on its own schedule, so a credential swap
11
+ * has to happen while it is stopped. Codex ships
12
+ * `codex app-server daemon {start,stop,version}` for exactly this, so authswitch
13
+ * goes through those rather than signalling pids behind Codex' back.
14
+ */
15
+ export class CodexDaemon {
16
+ public readonly codexHome: CodexHome;
17
+ private readonly binary: string;
18
+
19
+ constructor(codexHomeArg: CodexHome, binaryArg?: string) {
20
+ this.codexHome = codexHomeArg;
21
+ this.binary = binaryArg ?? process.env.AUTHSWITCH_CODEX_BIN ?? 'codex';
22
+ }
23
+
24
+ /** Whether the Codex CLI can be executed at all. */
25
+ public isAvailable(): boolean {
26
+ return this.run(['--version']).ok;
27
+ }
28
+
29
+ /**
30
+ * Authoritative daemon status, straight from Codex.
31
+ * Returns null when the Codex CLI cannot be reached or answers unexpectedly,
32
+ * which callers treat as "unknown" rather than "stopped".
33
+ */
34
+ public status(): IDaemonStatus | null {
35
+ const result = this.run(['app-server', 'daemon', 'version']);
36
+ if (!result.ok) {
37
+ return null;
38
+ }
39
+ try {
40
+ const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
41
+ return {
42
+ running: parsed.status === 'running',
43
+ backend: typeof parsed.backend === 'string' ? parsed.backend : null,
44
+ appServerVersion:
45
+ typeof parsed.appServerVersion === 'string' ? parsed.appServerVersion : null,
46
+ cliVersion: typeof parsed.cliVersion === 'string' ? parsed.cliVersion : null,
47
+ socketPath: typeof parsed.socketPath === 'string' ? parsed.socketPath : null,
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /** True when remote control is enabled for future daemon starts. */
55
+ public isRemoteControlEnabled(): boolean {
56
+ const settingsPath = this.codexHome.appServerSettingsPath;
57
+ if (!plugins.fs.existsSync(settingsPath)) {
58
+ return false;
59
+ }
60
+ try {
61
+ const parsed = JSON.parse(plugins.fs.readFileSync(settingsPath, 'utf8')) as {
62
+ remoteControlEnabled?: boolean;
63
+ };
64
+ return parsed.remoteControlEnabled === true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ /** The pid of the managed daemon, when its pid file is readable. */
71
+ public daemonPid(): number | null {
72
+ const pidPath = this.codexHome.appServerPidPath;
73
+ if (!plugins.fs.existsSync(pidPath)) {
74
+ return null;
75
+ }
76
+ try {
77
+ const parsed = JSON.parse(plugins.fs.readFileSync(pidPath, 'utf8')) as { pid?: number };
78
+ return typeof parsed.pid === 'number' ? parsed.pid : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Whether a daemon is up. Prefers Codex' own answer and falls back to the pid
86
+ * file so the check still works when the Codex CLI is not on PATH.
87
+ */
88
+ public isDaemonRunning(): boolean {
89
+ const status = this.status();
90
+ if (status !== null) {
91
+ return status.running;
92
+ }
93
+ const pid = this.daemonPid();
94
+ if (pid === null) {
95
+ return false;
96
+ }
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ public stop(): ICommandResult {
106
+ return this.run(['app-server', 'daemon', 'stop']);
107
+ }
108
+
109
+ public start(): ICommandResult {
110
+ return this.run(['app-server', 'daemon', 'start']);
111
+ }
112
+
113
+ /** A one-line diagnosis of a failed invocation, suitable for an error message. */
114
+ public describeFailure(resultArg: ICommandResult): string {
115
+ return describeCodexFailure(
116
+ this.binary,
117
+ resultArg.stdout,
118
+ resultArg.stderr,
119
+ resultArg.spawnError,
120
+ );
121
+ }
122
+
123
+ private run(argsArg: string[]): ICommandResult {
124
+ const result = plugins.childProcess.spawnSync(this.binary, argsArg, {
125
+ encoding: 'utf8',
126
+ timeout: 60_000,
127
+ env: { ...process.env, CODEX_HOME: this.codexHome.dir },
128
+ });
129
+ return {
130
+ ok: result.status === 0,
131
+ stdout: result.stdout ?? '',
132
+ stderr: result.stderr ?? '',
133
+ code: result.status,
134
+ spawnError: result.error ? result.error.message : null,
135
+ };
136
+ }
137
+ }
@@ -0,0 +1,233 @@
1
+ import { CodexSwitcher } from './classes.codexswitcher.js';
2
+ import { CodexAccountStatus } from './classes.codexstatus.js';
3
+ import { CodexPreuse } from './classes.codexpreuse.js';
4
+ import { PreuseError } from './preuse.js';
5
+ import type { IAuthHarness, IHarnessAccount, IHarnessAccountStatus, IHarnessOutcome, IHarnessState, IHarnessPreuseOptions, IHarnessPreuseResult } from './interfaces.harness.js';
6
+ import type { IStashListing } from './interfaces.js';
7
+ import { countPaired, enrollmentState } from './helpers.js';
8
+ import { beginSavedOpenAiLogin } from './classes.login.js';
9
+ import { plainText } from './formatting.js';
10
+ import type { IHarnessLoginOptions, IHarnessLoginProvider } from './interfaces.harness.js';
11
+
12
+ /** Codex-specific credentials, remote-control handling and service status. */
13
+ export class CodexHarness implements IAuthHarness {
14
+ public readonly id = 'codex';
15
+ public readonly label = 'Codex';
16
+ public readonly loginHint = 'Use authswitch codex login to sign in and save another account.';
17
+ public readonly loginProviders: IHarnessLoginProvider[] = [{ providerId: 'openai', label: 'OpenAI', flows: ['device'] }];
18
+ public readonly diagnosticsLabel = 'Check credential storage and remote control';
19
+
20
+ constructor(
21
+ private readonly switcher = new CodexSwitcher(),
22
+ private readonly accountStatus: Pick<CodexAccountStatus, 'read'> = new CodexAccountStatus(),
23
+ private readonly preuse: Pick<CodexPreuse, 'run'> = new CodexPreuse(),
24
+ ) {}
25
+
26
+ public beginLogin(options: IHarnessLoginOptions = { providerId: 'openai', flow: 'device' }) {
27
+ return beginSavedOpenAiLogin(result => {
28
+ const credential = result.credential;
29
+ const raw = JSON.stringify({ auth_mode: 'chatgpt', OPENAI_API_KEY: null, tokens: {
30
+ access_token: credential.accessToken, refresh_token: credential.refreshToken,
31
+ id_token: credential.idToken, account_id: result.account.accountId,
32
+ }, last_refresh: new Date().toISOString() }, null, 2) + '\n';
33
+ const identity = this.switcher.auth.identityFromRaw(raw);
34
+ if (!identity) throw new Error('The new login has no Codex account identity.');
35
+ const previous = this.switcher.stashes.readMeta(identity.email);
36
+ this.switcher.stashes.write(identity, raw, previous?.enrollments ?? [], this.switcher.home.dir);
37
+ if (!this.switcher.stashes.readBack(identity.email, raw)) throw new Error('The new Codex login could not be verified.');
38
+ return { outcome: { lines: [`Saved ${plainText(identity.email)}. Select it to switch Codex.`], problems: [] }, account: { id: identity.email, label: identity.email } };
39
+ }, options);
40
+ }
41
+
42
+ public readState(): IHarnessState {
43
+ const active = this.switcher.readActive();
44
+ const accounts: IHarnessAccount[] = this.switcher.list().map((entryArg) => {
45
+ const raw = this.switcher.stashes.readAuth(entryArg.email);
46
+ const identity = raw === null ? null : this.switcher.auth.identityFromRaw(raw);
47
+ const valid = identity !== null && identity.email === entryArg.email && identity.accountId === entryArg.accountId && identity.kind === entryArg.kind;
48
+ const isActive = active.identity !== null && active.identity.email === entryArg.email && active.identity.accountId === entryArg.accountId && active.identity.kind === entryArg.kind;
49
+ const isStashed = valid && (!isActive || raw === active.raw);
50
+ return {
51
+ id: entryArg.email,
52
+ label: entryArg.email,
53
+ isActive,
54
+ isStashed,
55
+ savedAt: entryArg.stashedAt,
56
+ 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'] : [])],
57
+ };
58
+ });
59
+ if (active.identity && !accounts.some((accountArg) => accountArg.isActive)) {
60
+ accounts.unshift({ id: this.activeAccountId(active.identity.email, active.identity.accountId), label: active.identity.email, isActive: true,
61
+ isStashed: false, savedAt: null, details: [] });
62
+ }
63
+ return {
64
+ accounts,
65
+ saveUnavailableReason: active.source === 'keyring'
66
+ ? 'This login is stored in an OS keyring and cannot be saved by authswitch.'
67
+ : !active.identity ? 'No identifiable login to save. ' + this.loginHint
68
+ : accounts.some((accountArg) => accountArg.label === active.identity!.email && !accountArg.isActive)
69
+ ? 'A different Codex account with this email is already saved. The email-based Codex store cannot keep both; refusing to replace it.' : null,
70
+ };
71
+ }
72
+
73
+ public async readAccountStatus(accountIdArg: string): Promise<IHarnessAccountStatus> {
74
+ const credential = this.readAccountCredential(accountIdArg);
75
+ if (!credential) return { facts: [], problems: ['No matching credential is available for status lookup.'] };
76
+ return this.accountStatus.read(credential.raw, credential.identity);
77
+ }
78
+
79
+ public async preuseAccount(accountIdArg: string, optionsArg: IHarnessPreuseOptions): Promise<IHarnessPreuseResult> {
80
+ const credential = this.readAccountCredential(accountIdArg);
81
+ if (!credential) throw new PreuseError('No matching credential is available for preuse.');
82
+ return this.preuse.run(credential.raw, credential.identity, optionsArg);
83
+ }
84
+
85
+ private readAccountCredential(accountIdArg: string) {
86
+ const active = this.switcher.readActive();
87
+ const meta = this.switcher.stashes.list().find((entryArg) => entryArg.email === accountIdArg);
88
+ // The active credential may have been refreshed since its last stash.
89
+ const useActive = active.identity && (this.activeAccountId(active.identity.email, active.identity.accountId) === accountIdArg ||
90
+ (meta && active.identity.email === meta.email && active.identity.accountId === meta.accountId && active.identity.kind === meta.kind));
91
+ const raw = useActive ? active.raw : meta ? this.switcher.stashes.readAuth(meta.email) : null;
92
+ const identity = raw === null ? null : this.switcher.auth.identityFromRaw(raw);
93
+ if (!identity || (!useActive && (!meta || identity.email !== meta.email || identity.accountId !== meta.accountId || identity.kind !== meta.kind))) {
94
+ return null;
95
+ }
96
+ return { raw: raw!, identity };
97
+ }
98
+
99
+ private activeAccountId(emailArg: string, accountIdArg: string | null): string {
100
+ return JSON.stringify(['active', emailArg, accountIdArg]);
101
+ }
102
+
103
+ public resolveAccount(referenceArg: string): { id: string } | { candidates: string[] } {
104
+ const result = this.switcher.stashes.resolve(referenceArg);
105
+ return 'email' in result ? { id: result.email } : result;
106
+ }
107
+
108
+ public switchAccount(accountIdArg: string): IHarnessOutcome {
109
+ const result = this.switcher.use(accountIdArg);
110
+ return {
111
+ lines: [`switched ${result.from ? result.from + ' -> ' : ''}${result.to}`,
112
+ ...result.notes.map((noteArg) => ' ' + noteArg)],
113
+ problems: result.problems,
114
+ };
115
+ }
116
+
117
+ public removeAccount(accountIdArg: string): IHarnessOutcome {
118
+ const removed = this.switcher.drop(accountIdArg);
119
+ return { lines: [removed ? 'dropped ' + accountIdArg : 'nothing to drop'],
120
+ problems: removed ? [] : ['The saved account no longer exists.'] };
121
+ }
122
+
123
+ public saveCurrent(optionsArg: { keepActive: boolean }): IHarnessOutcome {
124
+ const { keepActive } = optionsArg;
125
+ const lines: string[] = [];
126
+ const result = this.switcher.stash({ keepActive });
127
+ const entry = result.entry;
128
+ const paired = countPaired(entry.enrollments);
129
+ const enrollmentNote =
130
+ paired > 0
131
+ ? `with ${paired} remote-control pairing(s)`
132
+ : entry.enrollments.length > 0
133
+ ? `with ${entry.enrollments.length} registered enrollment(s) but no pairing - run \`codex remote-control pair\` to make this account reachable`
134
+ : 'no remote-control enrollment found for this account';
135
+ lines.push(`stashed ${entry.email} ${enrollmentNote}\n`);
136
+ lines.push(
137
+ keepActive
138
+ ? `the credential is still active\n`
139
+ : `the active credential slot is now empty - run \`codex login\` for another account\n`,
140
+ );
141
+ return { lines, problems: result.problems };
142
+ }
143
+
144
+ private formatRemoteControl(listingArg: IStashListing): string {
145
+ if (listingArg.enrollmentsPaired > 0) {
146
+ const unpaired = listingArg.enrollmentsTotal - listingArg.enrollmentsPaired;
147
+ const extra = unpaired > 0 ? ` +${unpaired} unpaired` : '';
148
+ return ` remote-control:${listingArg.enrollmentsPaired}${extra}`;
149
+ }
150
+ if (listingArg.enrollmentsTotal > 0) {
151
+ return ` remote-control:not paired (${listingArg.enrollmentsTotal} registered)`;
152
+ }
153
+ return ' remote-control:none';
154
+ }
155
+
156
+ public diagnose(): IHarnessOutcome {
157
+ const active = this.switcher.readActive();
158
+ const lines: string[] = [];
159
+
160
+ lines.push(`codex home ${this.switcher.home.dir}`);
161
+ lines.push(`state database ${this.switcher.home.statePath ?? 'not found'}`);
162
+ lines.push(`stash directory ${this.switcher.stashes.rootDir}`);
163
+
164
+ const incomplete = this.switcher.stashes.incomplete();
165
+ if (incomplete.length > 0) {
166
+ lines.push(
167
+ `incomplete stashes ${incomplete.join(', ')} - a credential without metadata; remove the directory or re-stash the account`,
168
+ );
169
+ }
170
+
171
+ const sourceLabel =
172
+ active.source === 'file'
173
+ ? 'file'
174
+ : active.source === 'keyring'
175
+ ? 'keyring (not switchable)'
176
+ : 'none';
177
+ lines.push(`credential store ${sourceLabel}`);
178
+ lines.push(`active account ${active.identity ? active.identity.email : 'none'}`);
179
+
180
+ const remoteAvailable = this.switcher.remoteControl.isAvailable();
181
+ lines.push(`enrollment table ${remoteAvailable ? 'present' : 'missing'}`);
182
+ if (remoteAvailable) {
183
+ const all = this.switcher.remoteControl.read();
184
+ const paired = countPaired(all);
185
+ lines.push(
186
+ `enrollments ${all.length} (${paired} paired)`,
187
+ );
188
+ const activeAccountId = active.identity?.accountId ?? null;
189
+ for (const enrollmentArg of all) {
190
+ const state = enrollmentState(enrollmentArg);
191
+ const label =
192
+ state === 'paired'
193
+ ? 'paired'
194
+ : state === 'disabled'
195
+ ? 'disabled'
196
+ : 'not paired';
197
+ const whose =
198
+ activeAccountId !== null && enrollmentArg.accountId === activeAccountId
199
+ ? ' this account'
200
+ : ' another account';
201
+ lines.push(
202
+ ` - ${enrollmentArg.serverName || '(unnamed)'} ${label}${whose} ${enrollmentArg.websocketUrl}`,
203
+ );
204
+ }
205
+ if (paired === 0 && all.length > 0) {
206
+ lines.push(
207
+ ` note a registered enrollment is not a pairing - run \`codex remote-control pair\` to pair this machine`,
208
+ );
209
+ }
210
+ }
211
+
212
+ lines.push(
213
+ `remote control ${this.switcher.daemon.isRemoteControlEnabled() ? 'enabled for future starts' : 'disabled'}`,
214
+ );
215
+
216
+ const daemonStatus = this.switcher.daemon.status();
217
+ if (daemonStatus === null) {
218
+ const pid = this.switcher.daemon.daemonPid();
219
+ lines.push(
220
+ `app-server codex CLI unreachable${pid === null ? '' : ` (pid file says ${pid})`}`,
221
+ );
222
+ } else if (daemonStatus.running) {
223
+ const pid = this.switcher.daemon.daemonPid();
224
+ lines.push(
225
+ `app-server running v${daemonStatus.appServerVersion ?? '?'}${pid === null ? '' : ` pid ${pid}`}`,
226
+ );
227
+ } else {
228
+ lines.push(`app-server not running`);
229
+ }
230
+
231
+ return { lines, problems: [] };
232
+ }
233
+ }
@@ -0,0 +1,68 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ /**
4
+ * Resolves the Codex home directory and every path inside it that authswitch touches.
5
+ *
6
+ * Codex honours the CODEX_HOME environment variable and falls back to ~/.codex.
7
+ * Every path is derived here so a test can point the whole tool at a fixture directory.
8
+ */
9
+ export class CodexHome {
10
+ public readonly dir: string;
11
+
12
+ constructor(dirArg?: string) {
13
+ this.dir = dirArg ?? process.env.CODEX_HOME ?? plugins.path.join(plugins.os.homedir(), '.codex');
14
+ }
15
+
16
+ /** The file credential store. Present when Codex uses file storage rather than an OS keyring. */
17
+ public get authFilePath(): string {
18
+ return plugins.path.join(this.dir, 'auth.json');
19
+ }
20
+
21
+ /** Directory holding the age-encrypted credential store used when a keyring backs the key. */
22
+ public get secretsDir(): string {
23
+ return plugins.path.join(this.dir, 'secrets');
24
+ }
25
+
26
+ public get configPath(): string {
27
+ return plugins.path.join(this.dir, 'config.toml');
28
+ }
29
+
30
+ public get appServerDaemonDir(): string {
31
+ return plugins.path.join(this.dir, 'app-server-daemon');
32
+ }
33
+
34
+ public get appServerSettingsPath(): string {
35
+ return plugins.path.join(this.appServerDaemonDir, 'settings.json');
36
+ }
37
+
38
+ public get appServerPidPath(): string {
39
+ return plugins.path.join(this.appServerDaemonDir, 'app-server.pid');
40
+ }
41
+
42
+ /**
43
+ * Codex versions its state database by schema generation (state_5.sqlite today).
44
+ * Resolve the highest-numbered one that exists rather than hardcoding a number
45
+ * that a Codex upgrade would silently invalidate.
46
+ */
47
+ public get statePath(): string | null {
48
+ let entries: string[];
49
+ try {
50
+ entries = plugins.fs.readdirSync(this.dir);
51
+ } catch {
52
+ return null;
53
+ }
54
+ const candidates = entries
55
+ .map((entryArg) => /^state_(\d+)\.sqlite$/.exec(entryArg))
56
+ .filter((matchArg): matchArg is RegExpExecArray => matchArg !== null)
57
+ .map((matchArg) => ({ name: matchArg[0], generation: Number(matchArg[1]) }))
58
+ .sort((leftArg, rightArg) => rightArg.generation - leftArg.generation);
59
+ if (candidates.length === 0) {
60
+ return null;
61
+ }
62
+ return plugins.path.join(this.dir, candidates[0].name);
63
+ }
64
+
65
+ public exists(): boolean {
66
+ return plugins.fs.existsSync(this.dir);
67
+ }
68
+ }