@modelprofile.com/authswitch 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +1 -3
  3. package/dist_ts/authority-contract.js +1 -1
  4. package/dist_ts/authority-import-contract.d.ts +207 -0
  5. package/dist_ts/authority-import-contract.js +18 -0
  6. package/dist_ts/classes.authoritybroker.js +1 -5
  7. package/dist_ts/classes.authoritycli.d.ts +4 -0
  8. package/dist_ts/classes.authoritycli.js +149 -12
  9. package/dist_ts/classes.authorityclient.d.ts +7 -0
  10. package/dist_ts/classes.authorityclient.js +13 -1
  11. package/dist_ts/classes.authoritydaemon.d.ts +4 -0
  12. package/dist_ts/classes.authoritydaemon.js +75 -9
  13. package/dist_ts/classes.authoritydatabase.d.ts +10 -26
  14. package/dist_ts/classes.authoritydatabase.js +68 -133
  15. package/dist_ts/classes.authorityimport.d.ts +161 -0
  16. package/dist_ts/classes.authorityimport.js +638 -0
  17. package/dist_ts/classes.authoritymodels.d.ts +39 -43
  18. package/dist_ts/classes.authoritymodels.js +209 -243
  19. package/dist_ts/classes.authoritypreuse.js +1 -2
  20. package/dist_ts/classes.authorityregistry.d.ts +2 -17
  21. package/dist_ts/classes.authorityregistry.js +18 -74
  22. package/dist_ts/classes.authoritysecrets.d.ts +1 -1
  23. package/dist_ts/classes.authoritysecrets.js +2 -2
  24. package/dist_ts/classes.authorityusage.d.ts +3 -3
  25. package/dist_ts/classes.claudeauthority.js +3 -7
  26. package/dist_ts/classes.claudenative.d.ts +1 -8
  27. package/dist_ts/classes.claudenative.js +2 -25
  28. package/dist_ts/classes.codexmanaged.d.ts +1 -1
  29. package/dist_ts/index.d.ts +1 -0
  30. package/dist_ts/index.js +6 -3
  31. package/dist_ts/plugins.d.ts +7 -7
  32. package/dist_ts/plugins.js +8 -8
  33. package/dist_ts/ts_migration/0001_authority_meta.d.ts +13 -3
  34. package/dist_ts/ts_migration/0001_authority_meta.js +29 -17
  35. package/dist_ts/ts_migration/0002_remove_backup_records.d.ts +17 -0
  36. package/dist_ts/ts_migration/0002_remove_backup_records.js +48 -0
  37. package/dist_ts/ts_migration/index.d.ts +6 -3
  38. package/dist_ts/ts_migration/index.js +10 -4
  39. package/dist_ts/ts_migration/legacysources/authswitchstores.d.ts +2 -0
  40. package/dist_ts/ts_migration/legacysources/authswitchstores.js +244 -0
  41. package/dist_ts/ts_migration/legacysources/index.d.ts +18 -0
  42. package/dist_ts/ts_migration/legacysources/index.js +24 -0
  43. package/dist_ts/ts_migration/legacysources/material.d.ts +37 -0
  44. package/dist_ts/ts_migration/legacysources/material.js +120 -0
  45. package/dist_ts/ts_migration/legacysources/nativestores.d.ts +2 -0
  46. package/dist_ts/ts_migration/legacysources/nativestores.js +207 -0
  47. package/dist_ts/ts_migration/legacysources/shared.d.ts +124 -0
  48. package/dist_ts/ts_migration/legacysources/shared.js +142 -0
  49. package/package.json +6 -5
  50. package/readme.md +68 -26
  51. package/ts/00_commitinfo_data.ts +1 -1
  52. package/ts/authority-contract.ts +1 -3
  53. package/ts/authority-import-contract.ts +216 -0
  54. package/ts/classes.authoritybroker.ts +0 -4
  55. package/ts/classes.authoritycli.ts +165 -11
  56. package/ts/classes.authorityclient.ts +22 -0
  57. package/ts/classes.authoritydaemon.ts +75 -8
  58. package/ts/classes.authoritydatabase.ts +70 -145
  59. package/ts/classes.authorityimport.ts +718 -0
  60. package/ts/classes.authoritymodels.ts +253 -269
  61. package/ts/classes.authoritypreuse.ts +0 -1
  62. package/ts/classes.authorityregistry.ts +20 -91
  63. package/ts/classes.authoritysecrets.ts +1 -1
  64. package/ts/classes.authorityusage.ts +3 -3
  65. package/ts/classes.claudeauthority.ts +2 -6
  66. package/ts/classes.claudenative.ts +1 -32
  67. package/ts/classes.codexmanaged.ts +1 -1
  68. package/ts/index.ts +5 -2
  69. package/ts/plugins.ts +7 -7
  70. package/ts/ts_migration/0001_authority_meta.ts +29 -17
  71. package/ts/ts_migration/0002_remove_backup_records.ts +49 -0
  72. package/ts/ts_migration/index.ts +10 -5
  73. package/ts/ts_migration/legacysources/authswitchstores.ts +211 -0
  74. package/ts/ts_migration/legacysources/index.ts +35 -0
  75. package/ts/ts_migration/legacysources/material.ts +134 -0
  76. package/ts/ts_migration/legacysources/nativestores.ts +204 -0
  77. package/ts/ts_migration/legacysources/shared.ts +220 -0
  78. package/dist_ts/authorityrehearsal.child.d.ts +0 -1
  79. package/dist_ts/authorityrehearsal.child.js +0 -395
  80. package/dist_ts/classes.authoritybackup.d.ts +0 -134
  81. package/dist_ts/classes.authoritybackup.js +0 -769
  82. package/dist_ts/classes.authorityrehearsal.d.ts +0 -127
  83. package/dist_ts/classes.authorityrehearsal.js +0 -632
  84. package/ts/authorityrehearsal.child.ts +0 -330
  85. package/ts/classes.authoritybackup.ts +0 -780
  86. package/ts/classes.authorityrehearsal.ts +0 -625
@@ -0,0 +1,211 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { accessTokenExpiry, claudeSourceIdentity, legacyHash, legacySourceDigest, legacySourceId,
3
+ openAiSourceIdentity, openCodeOpenAiIdentity, parseLegacyJson, readLegacyFile,
4
+ type ILegacySourceFile, type ILegacySourceLocations, type ILegacySourceRecord,
5
+ type ILegacySourceReport } from './shared.js';
6
+
7
+ /**
8
+ * Reads authswitch's own legacy stores: the Codex stash directories of `StashStore` and the saved records of
9
+ * `CredentialStore`.
10
+ *
11
+ * Both are copies authswitch made for itself. No other tool refreshes them, so the authority can become
12
+ * their owner outright -- unlike a native store, where the vendor tool keeps refreshing.
13
+ *
14
+ * `StashStore.list()` skips an incomplete directory and `CredentialStore.list()` throws on a malformed
15
+ * record; neither behaviour is acceptable here, because a source the owner cannot see is a source the
16
+ * cutover would delete unexamined. Every directory and every record is reported, with its defect.
17
+ */
18
+
19
+ const STASH_AUTH_FILE = 'auth.json';
20
+ const STASH_META_FILE = 'stash.json';
21
+ /** The harness namespaces authswitch itself wrote, and the store layout each one uses. */
22
+ const STASH_HARNESSES = ['codex'];
23
+ const RECORD_HARNESSES = ['claude', 'opencode'];
24
+ const RECORD_FILE = /^[a-f0-9]{64}\.json$/;
25
+
26
+ const baseRecord = (path: string, files: readonly ILegacySourceFile[]): Pick<ILegacySourceRecord,
27
+ 'id' | 'sourceKind' | 'path' | 'sourcePathHash' | 'sourceDigest'> => {
28
+ const sourcePathHash = legacyHash(path);
29
+ return {
30
+ id: legacySourceId('authswitch_stash', sourcePathHash),
31
+ sourceKind: 'authswitch_stash',
32
+ path,
33
+ sourcePathHash,
34
+ sourceDigest: legacySourceDigest(files),
35
+ };
36
+ };
37
+
38
+ /** One `~/.authswitch/codex/<slug>` directory: a Codex credential plus the enrollments saved with it. */
39
+ const readStashDirectory = (path: string): ILegacySourceRecord => {
40
+ const problems: string[] = [];
41
+ const files: ILegacySourceFile[] = [];
42
+ for (const name of [STASH_AUTH_FILE, STASH_META_FILE]) {
43
+ const read = readLegacyFile(plugins.path.join(path, name), name);
44
+ if (read.problem) problems.push(read.problem);
45
+ else if (read.file === null) problems.push(`${name} is missing`);
46
+ else files.push(read.file);
47
+ }
48
+ const record: ILegacySourceRecord = {
49
+ ...baseRecord(path, files),
50
+ treatment: 'refuse',
51
+ proof: 'none',
52
+ accountId: null,
53
+ subject: null,
54
+ workspaceId: null,
55
+ providerId: null,
56
+ email: null,
57
+ plan: null,
58
+ label: null,
59
+ accessExpiresAt: null,
60
+ enrollmentCount: null,
61
+ problems,
62
+ };
63
+ const auth = files.find(file => file.name === STASH_AUTH_FILE);
64
+ const meta = files.find(file => file.name === STASH_META_FILE);
65
+ if (meta) {
66
+ const parsed = parseLegacyJson(meta.raw);
67
+ if (!parsed) problems.push(`${STASH_META_FILE} is not a JSON object`);
68
+ else {
69
+ record.label = typeof parsed.email === 'string' ? parsed.email : null;
70
+ record.enrollmentCount = Array.isArray(parsed.enrollments) ? parsed.enrollments.length : null;
71
+ if (record.enrollmentCount === null) problems.push(`${STASH_META_FILE} carries no enrollment list`);
72
+ }
73
+ }
74
+ if (!auth) return record;
75
+ const parsed = parseLegacyJson(auth.raw);
76
+ const tokens = parsed?.tokens;
77
+ const tokenRecord = tokens && typeof tokens === 'object' && !Array.isArray(tokens)
78
+ ? tokens as Record<string, unknown> : null;
79
+ const idToken = typeof tokenRecord?.id_token === 'string' ? tokenRecord.id_token : null;
80
+ const refreshToken = typeof tokenRecord?.refresh_token === 'string' ? tokenRecord.refresh_token : null;
81
+ const identity = idToken === null ? null : openAiSourceIdentity(idToken);
82
+ if (identity === null) {
83
+ // An API-key credential is a stash authswitch can restore but not an OAuth grant the authority can hold.
84
+ problems.push('the saved credential carries no ChatGPT identity the authority can hold');
85
+ return record;
86
+ }
87
+ record.providerId = 'openai';
88
+ record.accountId = identity.accountId;
89
+ record.subject = identity.subject;
90
+ record.workspaceId = identity.workspaceId;
91
+ record.email = identity.email;
92
+ record.plan = identity.plan;
93
+ record.label = record.label ?? identity.email;
94
+ record.accessExpiresAt = accessTokenExpiry(
95
+ typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : null);
96
+ if (refreshToken === null) problems.push('the saved credential carries no refresh token');
97
+ else if (problems.length === 0) { record.treatment = 'import'; record.proof = 'rotating_refresh'; }
98
+ return record;
99
+ };
100
+
101
+ /** One `~/.authswitch/<harness>/<id>.json` record, validated as `CredentialStore.read` validates it. */
102
+ const readCredentialRecord = (harness: string, path: string, id: string): ILegacySourceRecord => {
103
+ const problems: string[] = [];
104
+ const files: ILegacySourceFile[] = [];
105
+ const read = readLegacyFile(path, `${id}.json`);
106
+ if (read.problem) problems.push(read.problem);
107
+ else if (read.file === null) problems.push('the record disappeared while it was being read');
108
+ else files.push(read.file);
109
+ const record: ILegacySourceRecord = {
110
+ ...baseRecord(path, files),
111
+ treatment: 'refuse',
112
+ proof: 'none',
113
+ accountId: null,
114
+ subject: null,
115
+ workspaceId: null,
116
+ providerId: null,
117
+ email: null,
118
+ plan: null,
119
+ label: null,
120
+ accessExpiresAt: null,
121
+ enrollmentCount: null,
122
+ problems,
123
+ };
124
+ const file = files[0];
125
+ if (!file) return record;
126
+ const parsed = parseLegacyJson(file.raw);
127
+ if (!parsed || parsed.schemaVersion !== 1 || parsed.id !== id || typeof parsed.slotId !== 'string'
128
+ || typeof parsed.label !== 'string' || !parsed.credential || typeof parsed.credential !== 'object'
129
+ || Array.isArray(parsed.credential)) {
130
+ problems.push('the saved record does not match the credential-store schema');
131
+ return record;
132
+ }
133
+ const credential = parsed.credential as Record<string, unknown>;
134
+ record.label = parsed.label;
135
+ if (harness === 'claude') {
136
+ const identity = claudeSourceIdentity(credential);
137
+ if (identity === null) problems.push('the saved Claude login or its account metadata is incomplete');
138
+ else {
139
+ record.providerId = 'anthropic';
140
+ record.accountId = identity.accountId;
141
+ record.subject = identity.subject;
142
+ record.workspaceId = identity.workspaceId;
143
+ record.email = identity.email;
144
+ record.accessExpiresAt = identity.accessExpiresAt;
145
+ if (typeof credential.claudeAiOauth === 'object' && credential.claudeAiOauth !== null
146
+ && typeof (credential.claudeAiOauth as Record<string, unknown>).refreshToken !== 'string') {
147
+ problems.push('the saved Claude login carries no refresh token');
148
+ }
149
+ }
150
+ } else if (parsed.slotId === 'openai') {
151
+ const { identity, accessExpiresAt } = openCodeOpenAiIdentity(credential);
152
+ record.accessExpiresAt = accessExpiresAt;
153
+ if (identity === null) problems.push('the saved OpenAI login carries no identity the authority can hold');
154
+ else {
155
+ record.providerId = 'openai';
156
+ record.accountId = identity.accountId;
157
+ record.subject = identity.subject;
158
+ record.workspaceId = identity.workspaceId;
159
+ record.email = identity.email;
160
+ record.plan = identity.plan;
161
+ }
162
+ if (typeof credential.refresh !== 'string') problems.push('the saved login carries no refresh token');
163
+ } else {
164
+ // API keys and other provider entries are credentials, but not grants the authority model can express.
165
+ problems.push(`provider ${parsed.slotId} is not a login the authority can hold`);
166
+ }
167
+ if (problems.length === 0) { record.treatment = 'import'; record.proof = 'rotating_refresh'; }
168
+ return record;
169
+ };
170
+
171
+ export const readAuthSwitchStores = (locations: ILegacySourceLocations): ILegacySourceReport => {
172
+ const sources: ILegacySourceRecord[] = [];
173
+ const problems: string[] = [];
174
+ let entries: plugins.fs.Dirent[];
175
+ try { entries = plugins.fs.readdirSync(locations.authSwitchHome, { withFileTypes: true }); }
176
+ catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { sources, problems };
178
+ return { sources, problems: [`${locations.authSwitchHome} could not be listed`] };
179
+ }
180
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
181
+ const path = plugins.path.join(locations.authSwitchHome, entry.name);
182
+ if (!entry.isDirectory()) { problems.push(`${entry.name} is not a harness directory`); continue; }
183
+ if (STASH_HARNESSES.includes(entry.name)) {
184
+ let stashes: plugins.fs.Dirent[];
185
+ try { stashes = plugins.fs.readdirSync(path, { withFileTypes: true }); }
186
+ catch { problems.push(`${entry.name} could not be listed`); continue; }
187
+ for (const stash of stashes.sort((left, right) => left.name.localeCompare(right.name))) {
188
+ if (!stash.isDirectory()) { problems.push(`${entry.name}/${stash.name} is not a stash`); continue; }
189
+ sources.push(readStashDirectory(plugins.path.join(path, stash.name)));
190
+ }
191
+ } else if (RECORD_HARNESSES.includes(entry.name)) {
192
+ let records: plugins.fs.Dirent[];
193
+ try { records = plugins.fs.readdirSync(path, { withFileTypes: true }); }
194
+ catch { problems.push(`${entry.name} could not be listed`); continue; }
195
+ for (const item of records.sort((left, right) => left.name.localeCompare(right.name))) {
196
+ // switches.json is authswitch's advisory record of the last switch, not a credential source.
197
+ if (item.name === 'switches.json' || item.name === '.lock') continue;
198
+ if (!item.isFile() || !RECORD_FILE.test(item.name)) {
199
+ problems.push(`${entry.name}/${item.name} is not a saved account record`);
200
+ continue;
201
+ }
202
+ sources.push(readCredentialRecord(entry.name, plugins.path.join(path, item.name),
203
+ item.name.slice(0, -5)));
204
+ }
205
+ } else {
206
+ // Guessing a store layout would be the one way this migration could lose an account silently.
207
+ problems.push(`${entry.name} uses no legacy store layout this import knows`);
208
+ }
209
+ }
210
+ return { sources, problems };
211
+ };
@@ -0,0 +1,35 @@
1
+ import { readAuthSwitchStores } from './authswitchstores.js';
2
+ import { readNativeStores } from './nativestores.js';
3
+ import { resolveLegacySourceLocations, type ILegacySourceRecord,
4
+ type ILegacySourceReport } from './shared.js';
5
+
6
+ export { resolveLegacySourceLocations } from './shared.js';
7
+ export type { ILegacySourceLocations, ILegacySourceRecord, ILegacySourceReport } from './shared.js';
8
+ export { LegacySourceChangedError, readLegacySourceEnrollments,
9
+ readLegacySourceMaterial } from './material.js';
10
+ export type { TLegacySourceMaterial } from './material.js';
11
+
12
+ export interface ILegacySourceOptions {
13
+ env: NodeJS.ProcessEnv;
14
+ homeDirectory: string;
15
+ now: number;
16
+ }
17
+
18
+ /**
19
+ * Every legacy credential source this host holds, read and never changed.
20
+ *
21
+ * One function so that the import and the inventory always see the same list: a submit re-runs this read to
22
+ * resolve a source's location from its hash and to re-check its digest, and a discrepancy between what the
23
+ * owner approved and what is imported would defeat the point of approving anything.
24
+ */
25
+ export const readLegacySources = (options: ILegacySourceOptions): ILegacySourceReport => {
26
+ const locations = resolveLegacySourceLocations(options.env, options.homeDirectory);
27
+ const reports = [readAuthSwitchStores(locations), readNativeStores(locations, options.now)];
28
+ const sources: ILegacySourceRecord[] = [];
29
+ const problems: string[] = [];
30
+ for (const report of reports) {
31
+ sources.push(...report.sources);
32
+ problems.push(...report.problems);
33
+ }
34
+ return { sources, problems };
35
+ };
@@ -0,0 +1,134 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { legacySourceDigest, parseLegacyJson, readLegacyFile, type ILegacySourceFile,
3
+ type ILegacySourceRecord } from './shared.js';
4
+
5
+ /**
6
+ * Re-opens the credential of one source, inside the daemon, from the location its hash stands for.
7
+ *
8
+ * Nothing carries this material over a socket: a caller names a source by id and digest, and the daemon reads
9
+ * the bytes itself. The digest is re-checked here, so a source that changed between the owner's approval and
10
+ * the import is refused rather than imported unseen.
11
+ */
12
+
13
+ /** Exactly the envelope the authority already seals for each provider, so no new stored shape appears. */
14
+ export type TLegacySourceMaterial =
15
+ | { providerId: 'openai'; credential: { kind: 'chatgptOAuth'; providerId: 'openai'; accessToken: string;
16
+ refreshToken: string; idToken: string } }
17
+ | { providerId: 'anthropic'; login: { claudeAiOauth: Record<string, unknown>;
18
+ oauthAccount: Record<string, unknown> } };
19
+
20
+ export class LegacySourceChangedError extends Error {
21
+ constructor() { super('The source changed after it was inventoried; no credential was read.'); }
22
+ }
23
+
24
+ const files = (record: ILegacySourceRecord, names: readonly string[],
25
+ base: string): ILegacySourceFile[] => {
26
+ const read: ILegacySourceFile[] = [];
27
+ for (const name of names) {
28
+ const item = readLegacyFile(plugins.path.join(base, name), name);
29
+ if (item.file === null) throw new LegacySourceChangedError();
30
+ read.push(item.file);
31
+ }
32
+ return read;
33
+ };
34
+
35
+ const openAiFromTokens = (tokens: unknown): TLegacySourceMaterial | null => {
36
+ if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) return null;
37
+ const record = tokens as Record<string, unknown>;
38
+ const accessToken = record.access_token;
39
+ const refreshToken = record.refresh_token;
40
+ const idToken = record.id_token;
41
+ if (typeof accessToken !== 'string' || typeof refreshToken !== 'string'
42
+ || typeof idToken !== 'string') return null;
43
+ return { providerId: 'openai',
44
+ credential: { kind: 'chatgptOAuth', providerId: 'openai', accessToken, refreshToken, idToken } };
45
+ };
46
+
47
+ const claudeFromComposite = (credential: Record<string, unknown>): TLegacySourceMaterial | null => {
48
+ const oauth = credential.claudeAiOauth;
49
+ const account = credential.oauthAccount;
50
+ if (!oauth || typeof oauth !== 'object' || Array.isArray(oauth)
51
+ || !account || typeof account !== 'object' || Array.isArray(account)) return null;
52
+ return { providerId: 'anthropic',
53
+ login: { claudeAiOauth: oauth as Record<string, unknown>,
54
+ oauthAccount: account as Record<string, unknown> } };
55
+ };
56
+
57
+ /** Reads the source named by `record`, proves it is still the inventoried source, and returns its envelope. */
58
+ export const readLegacySourceMaterial = (record: ILegacySourceRecord): TLegacySourceMaterial => {
59
+ const assertUnchanged = (read: readonly ILegacySourceFile[]): void => {
60
+ if (legacySourceDigest(read) !== record.sourceDigest) throw new LegacySourceChangedError();
61
+ };
62
+ const fail = (): never => { throw new Error('The source holds no credential the authority can seal.'); };
63
+
64
+ if (record.sourceKind === 'authswitch_stash' && record.path.endsWith('.json')) {
65
+ const read = files(record, [plugins.path.basename(record.path)], plugins.path.dirname(record.path));
66
+ assertUnchanged(read);
67
+ const parsed = parseLegacyJson(read[0].raw);
68
+ const credential = parsed?.credential;
69
+ if (!credential || typeof credential !== 'object' || Array.isArray(credential)) return fail();
70
+ const material = record.providerId === 'anthropic'
71
+ ? claudeFromComposite(credential as Record<string, unknown>)
72
+ : openCodeOpenAi(credential as Record<string, unknown>);
73
+ return material ?? fail();
74
+ }
75
+ if (record.sourceKind === 'authswitch_stash' || record.sourceKind === 'codex_native') {
76
+ const base = record.sourceKind === 'codex_native' ? plugins.path.dirname(record.path) : record.path;
77
+ const names = record.sourceKind === 'codex_native' ? ['auth.json'] : ['auth.json', 'stash.json'];
78
+ const read = files(record, names, base);
79
+ assertUnchanged(read);
80
+ return openAiFromTokens(parseLegacyJson(read[0].raw)?.tokens) ?? fail();
81
+ }
82
+ if (record.sourceKind === 'claude_native') {
83
+ const credentials = readLegacyFile(record.path, '.credentials.json');
84
+ const configFile = plugins.path.join(plugins.path.dirname(record.path), '.claude.json');
85
+ const config = readLegacyFile(configFile, '.claude.json');
86
+ const read = [credentials.file, config.file].filter((file): file is ILegacySourceFile => file !== null);
87
+ assertUnchanged(read);
88
+ const credentialData = credentials.file === null ? null : parseLegacyJson(credentials.file.raw);
89
+ const configData = config.file === null ? null : parseLegacyJson(config.file.raw);
90
+ return claudeFromComposite({ claudeAiOauth: credentialData?.claudeAiOauth,
91
+ oauthAccount: configData?.oauthAccount }) ?? fail();
92
+ }
93
+ if (record.sourceKind === 'opencode_native') {
94
+ // The provider entries share a file; the location names the entry, and the digest covers that entry only.
95
+ const separator = record.path.lastIndexOf('#');
96
+ const file = record.path.slice(0, separator);
97
+ const provider = record.path.slice(separator + 1);
98
+ const read = readLegacyFile(file, 'auth.json');
99
+ if (read.file === null) throw new LegacySourceChangedError();
100
+ const entry = parseLegacyJson(read.file.raw)?.[provider];
101
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new LegacySourceChangedError();
102
+ assertUnchanged([{ name: provider, raw: JSON.stringify(entry) }]);
103
+ return openCodeOpenAi(entry as Record<string, unknown>) ?? fail();
104
+ }
105
+ return fail();
106
+ };
107
+
108
+ /**
109
+ * The remote-control enrollments a Codex stash saved beside its credential.
110
+ *
111
+ * They are separate from the refresh grant and are the pairing between this machine's Codex app-server and a
112
+ * ChatGPT client: a migration that dropped them would silently unpair the account.
113
+ */
114
+ export const readLegacySourceEnrollments = (record: ILegacySourceRecord): Record<string, unknown>[] => {
115
+ if (record.sourceKind !== 'authswitch_stash' || record.path.endsWith('.json')) return [];
116
+ const read = readLegacyFile(plugins.path.join(record.path, 'stash.json'), 'stash.json');
117
+ if (read.file === null) return [];
118
+ const enrollments = parseLegacyJson(read.file.raw)?.enrollments;
119
+ return Array.isArray(enrollments)
120
+ ? enrollments.filter((row): row is Record<string, unknown> =>
121
+ Boolean(row) && typeof row === 'object' && !Array.isArray(row))
122
+ : [];
123
+ };
124
+
125
+ /** An OpenCode OAuth entry: access and refresh, with the identity claims carried by the access token. */
126
+ const openCodeOpenAi = (entry: Record<string, unknown>): TLegacySourceMaterial | null => {
127
+ const accessToken = entry.access;
128
+ const refreshToken = entry.refresh;
129
+ if (typeof accessToken !== 'string' || typeof refreshToken !== 'string') return null;
130
+ // OpenCode stores no id_token; the access token carries the same claims the authority reads.
131
+ return { providerId: 'openai',
132
+ credential: { kind: 'chatgptOAuth', providerId: 'openai', accessToken, refreshToken,
133
+ idToken: accessToken } };
134
+ };
@@ -0,0 +1,204 @@
1
+ import * as plugins from '../../plugins.js';
2
+ import { CodexHome } from '../../classes.codexhome.js';
3
+ import { accessProofAvailable, accessTokenExpiry, claudeSourceIdentity, EXPIRED_ACCESS_PROBLEM, legacyHash,
4
+ legacySourceDigest, legacySourceId, openAiSourceIdentity, openCodeOpenAiIdentity, parseLegacyJson,
5
+ readLegacyFile, type ILegacySourceFile, type ILegacySourceLocations, type ILegacySourceRecord,
6
+ type ILegacySourceReport } from './shared.js';
7
+ import type { TAuthSwitchImportSourceKind } from '../../authority-import-contract.js';
8
+
9
+ /**
10
+ * Reads the native stores the vendor tools own: Codex's home, Claude Code's credential pair and OpenCode's
11
+ * provider file.
12
+ *
13
+ * These are never imported in the sense a stash record is. The native tool keeps refreshing its own store,
14
+ * so the authority registers the account and holds the grant as a projection it must not refresh -- two
15
+ * refreshers on one rotating refresh token strand each other. Their ledger rows are therefore earned with a
16
+ * proof that rotates nothing: a provider read with the access token already in the store.
17
+ */
18
+
19
+ const ENROLLMENT_TABLE = 'remote_control_enrollments';
20
+
21
+ /**
22
+ * Counts Codex's remote-control enrollments without changing them.
23
+ *
24
+ * `RemoteControlStore.read()` turns every failure into an empty list, which is right for a switch and wrong
25
+ * for an inventory: it would report "no enrollments" for a database that is merely locked, and the owner
26
+ * would approve a cutover that loses pairings. This read distinguishes the two.
27
+ */
28
+ const countCodexEnrollments = (codexHome: CodexHome): { count: number | null; problem: string | null } => {
29
+ const statePath = codexHome.statePath;
30
+ if (statePath === null) return { count: 0, problem: null };
31
+ const { DatabaseSync } = plugins.loadSqlite();
32
+ let database: InstanceType<typeof DatabaseSync> | undefined;
33
+ try {
34
+ database = new DatabaseSync(statePath, { readOnly: true });
35
+ database.exec('pragma busy_timeout = 5000');
36
+ const table = database.prepare(`select name from sqlite_master where type = 'table' and name = ?`)
37
+ .get(ENROLLMENT_TABLE);
38
+ if (table === undefined) return { count: 0, problem: null };
39
+ const row = database.prepare(`select count(*) as count from ${ENROLLMENT_TABLE}`).get() as
40
+ { count?: unknown } | undefined;
41
+ const count = Number(row?.count);
42
+ return Number.isSafeInteger(count)
43
+ ? { count, problem: null }
44
+ : { count: null, problem: 'the Codex state database returned no enrollment count' };
45
+ } catch {
46
+ // A WAL database Codex holds open cannot always be opened read-only; that is unknown, never zero.
47
+ return { count: null, problem: 'the Codex state database could not be read, so its enrollments are unknown' };
48
+ } finally { database?.close(); }
49
+ };
50
+
51
+ const nativeRecord = (sourceKind: TAuthSwitchImportSourceKind, path: string,
52
+ files: readonly ILegacySourceFile[], problems: string[]): ILegacySourceRecord => {
53
+ const sourcePathHash = legacyHash(path);
54
+ return {
55
+ id: legacySourceId(sourceKind, sourcePathHash),
56
+ sourceKind,
57
+ path,
58
+ sourcePathHash,
59
+ sourceDigest: legacySourceDigest(files),
60
+ treatment: 'refuse',
61
+ proof: 'none',
62
+ accountId: null,
63
+ subject: null,
64
+ workspaceId: null,
65
+ providerId: null,
66
+ email: null,
67
+ plan: null,
68
+ label: null,
69
+ accessExpiresAt: null,
70
+ enrollmentCount: null,
71
+ problems,
72
+ };
73
+ };
74
+
75
+ /** Marks a projection source ready, or explains why its non-rotating proof cannot be taken right now. */
76
+ const asProjection = (record: ILegacySourceRecord, now: number): ILegacySourceRecord => {
77
+ record.treatment = 'project';
78
+ record.proof = 'access_token_read';
79
+ if (!accessProofAvailable(record.accessExpiresAt, now)) record.problems.push(EXPIRED_ACCESS_PROBLEM);
80
+ return record;
81
+ };
82
+
83
+ const readCodexNative = (locations: ILegacySourceLocations, now: number): ILegacySourceRecord | null => {
84
+ const path = plugins.path.join(locations.codexHome, 'auth.json');
85
+ const read = readLegacyFile(path, 'auth.json');
86
+ if (read.problem === null && read.file === null) return null;
87
+ const files = read.file ? [read.file] : [];
88
+ const problems = read.problem ? [read.problem] : [];
89
+ const record = nativeRecord('codex_native', path, files, problems);
90
+ const enrollments = countCodexEnrollments(new CodexHome(locations.codexHome));
91
+ record.enrollmentCount = enrollments.count;
92
+ if (enrollments.problem) problems.push(enrollments.problem);
93
+ if (!read.file) return record;
94
+ const parsed = parseLegacyJson(read.file.raw);
95
+ const tokens = parsed?.tokens;
96
+ const tokenRecord = tokens && typeof tokens === 'object' && !Array.isArray(tokens)
97
+ ? tokens as Record<string, unknown> : null;
98
+ const idToken = typeof tokenRecord?.id_token === 'string' ? tokenRecord.id_token : null;
99
+ const identity = idToken === null ? null : openAiSourceIdentity(idToken);
100
+ if (identity === null) {
101
+ problems.push('the active Codex credential carries no ChatGPT identity the authority can hold');
102
+ return record;
103
+ }
104
+ record.providerId = 'openai';
105
+ record.accountId = identity.accountId;
106
+ record.subject = identity.subject;
107
+ record.workspaceId = identity.workspaceId;
108
+ record.email = identity.email;
109
+ record.plan = identity.plan;
110
+ record.label = identity.email;
111
+ record.accessExpiresAt = accessTokenExpiry(
112
+ typeof tokenRecord?.access_token === 'string' ? tokenRecord.access_token : null);
113
+ return asProjection(record, now);
114
+ };
115
+
116
+ const readClaudeNative = (locations: ILegacySourceLocations, now: number): ILegacySourceRecord | null => {
117
+ const credentials = readLegacyFile(locations.claudeCredentialFile, '.credentials.json');
118
+ const config = readLegacyFile(locations.claudeConfigFile, '.claude.json');
119
+ if (credentials.problem === null && credentials.file === null) return null;
120
+ const files = [credentials.file, config.file].filter((file): file is ILegacySourceFile => file !== null);
121
+ const problems = [credentials.problem, config.problem].filter((problem): problem is string => problem !== null);
122
+ const record = nativeRecord('claude_native', locations.claudeCredentialFile, files, problems);
123
+ if (!credentials.file) return record;
124
+ const credentialData = parseLegacyJson(credentials.file.raw);
125
+ const configData = config.file === null ? null : parseLegacyJson(config.file.raw);
126
+ if (configData === null) problems.push('the Claude account metadata file is missing or not a JSON object');
127
+ const identity = claudeSourceIdentity({
128
+ claudeAiOauth: credentialData?.claudeAiOauth, oauthAccount: configData?.oauthAccount });
129
+ if (identity === null) {
130
+ problems.push('the native Claude login or its account metadata is incomplete');
131
+ return record;
132
+ }
133
+ record.providerId = 'anthropic';
134
+ record.accountId = identity.accountId;
135
+ record.subject = identity.subject;
136
+ record.workspaceId = identity.workspaceId;
137
+ record.email = identity.email;
138
+ record.label = identity.email;
139
+ record.accessExpiresAt = identity.accessExpiresAt;
140
+ return asProjection(record, now);
141
+ };
142
+
143
+ /**
144
+ * One row per provider entry of OpenCode's `auth.json`.
145
+ *
146
+ * The entries share a file, so the source location names the entry -- `<file>#<provider>` -- and the digest
147
+ * covers that entry's bytes. A change to one provider therefore does not invalidate another's ledger row.
148
+ */
149
+ const readOpenCodeNative = (locations: ILegacySourceLocations, now: number): ILegacySourceReport => {
150
+ const read = readLegacyFile(locations.openCodeAuthFile, 'auth.json');
151
+ if (read.problem !== null) return { sources: [], problems: [`OpenCode ${read.problem}`] };
152
+ if (read.file === null) return { sources: [], problems: [] };
153
+ const parsed = parseLegacyJson(read.file.raw);
154
+ if (parsed === null) return { sources: [], problems: ['OpenCode auth.json is not a JSON object'] };
155
+ const sources: ILegacySourceRecord[] = [];
156
+ for (const [provider, value] of Object.entries(parsed).sort(([left], [right]) => left.localeCompare(right))) {
157
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
158
+ const entry = value as Record<string, unknown>;
159
+ const path = `${locations.openCodeAuthFile}#${provider}`;
160
+ const record = nativeRecord('opencode_native', path,
161
+ [{ name: provider, raw: JSON.stringify(entry) }], []);
162
+ record.label = provider;
163
+ if (entry.type !== 'oauth') {
164
+ record.problems.push(`the ${provider} entry is a ${String(entry.type)} credential, not a login the authority can hold`);
165
+ sources.push(record);
166
+ continue;
167
+ }
168
+ if (provider !== 'openai') {
169
+ // OpenCode identifies a non-OpenAI OAuth entry by a token fingerprint, which is not a provider
170
+ // identity: there is nothing to bind an authority account to. Its account is added from its own store.
171
+ record.problems.push(`the ${provider} entry carries no provider identity the authority can bind an account to`);
172
+ sources.push(record);
173
+ continue;
174
+ }
175
+ const { identity, accessExpiresAt } = openCodeOpenAiIdentity(entry);
176
+ record.accessExpiresAt = accessExpiresAt;
177
+ if (identity === null) {
178
+ record.problems.push('the OpenAI entry carries no identity the authority can hold');
179
+ sources.push(record);
180
+ continue;
181
+ }
182
+ record.providerId = 'openai';
183
+ record.accountId = identity.accountId;
184
+ record.subject = identity.subject;
185
+ record.workspaceId = identity.workspaceId;
186
+ record.email = identity.email;
187
+ record.plan = identity.plan;
188
+ record.label = identity.email ?? provider;
189
+ sources.push(asProjection(record, now));
190
+ }
191
+ return { sources, problems: [] };
192
+ };
193
+
194
+ export const readNativeStores = (locations: ILegacySourceLocations, now: number): ILegacySourceReport => {
195
+ const sources: ILegacySourceRecord[] = [];
196
+ const problems: string[] = [];
197
+ for (const record of [readCodexNative(locations, now), readClaudeNative(locations, now)]) {
198
+ if (record !== null) sources.push(record);
199
+ }
200
+ const openCode = readOpenCodeNative(locations, now);
201
+ sources.push(...openCode.sources);
202
+ problems.push(...openCode.problems);
203
+ return { sources, problems };
204
+ };