@modelprofile.com/authswitch 8.2.0 → 9.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 (64) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/authority-contract.d.ts +84 -1
  3. package/dist_ts/authority-contract.js +14 -2
  4. package/dist_ts/authority-import-contract.d.ts +6 -0
  5. package/dist_ts/authority-paths.d.ts +37 -0
  6. package/dist_ts/authority-paths.js +46 -0
  7. package/dist_ts/authority-runtime-contract.d.ts +11 -1
  8. package/dist_ts/classes.authoritybroker.d.ts +23 -8
  9. package/dist_ts/classes.authoritybroker.js +155 -32
  10. package/dist_ts/classes.authorityclient.d.ts +22 -2
  11. package/dist_ts/classes.authorityclient.js +98 -13
  12. package/dist_ts/classes.authoritydaemon.d.ts +21 -3
  13. package/dist_ts/classes.authoritydaemon.js +77 -32
  14. package/dist_ts/classes.authoritydatabase.d.ts +21 -3
  15. package/dist_ts/classes.authoritydatabase.js +98 -12
  16. package/dist_ts/classes.authorityimport.d.ts +16 -4
  17. package/dist_ts/classes.authorityimport.js +75 -24
  18. package/dist_ts/classes.authoritymodels.js +5 -3
  19. package/dist_ts/classes.authoritypreuse.js +8 -3
  20. package/dist_ts/classes.authorityservice.d.ts +10 -11
  21. package/dist_ts/classes.authorityservice.js +14 -23
  22. package/dist_ts/classes.cli.d.ts +10 -2
  23. package/dist_ts/classes.cli.js +12 -4
  24. package/dist_ts/classes.codexmanaged.d.ts +0 -1
  25. package/dist_ts/classes.codexmanaged.js +13 -26
  26. package/dist_ts/classes.legacyfence.d.ts +53 -0
  27. package/dist_ts/classes.legacyfence.js +189 -0
  28. package/dist_ts/classes.operations.d.ts +15 -3
  29. package/dist_ts/classes.operations.js +22 -4
  30. package/dist_ts/classes.service.d.ts +21 -2
  31. package/dist_ts/classes.service.js +35 -8
  32. package/dist_ts/classes.tui.d.ts +2 -1
  33. package/dist_ts/classes.tui.js +3 -2
  34. package/dist_ts/codexcontract.d.ts +30 -0
  35. package/dist_ts/codexcontract.js +174 -0
  36. package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
  37. package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
  38. package/dist_ts/ts_migration/index.js +3 -1
  39. package/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
  40. package/package.json +11 -11
  41. package/readme.md +194 -24
  42. package/ts/00_commitinfo_data.ts +1 -1
  43. package/ts/authority-contract.ts +90 -4
  44. package/ts/authority-import-contract.ts +6 -0
  45. package/ts/authority-paths.ts +69 -0
  46. package/ts/authority-runtime-contract.ts +11 -1
  47. package/ts/classes.authoritybroker.ts +153 -33
  48. package/ts/classes.authorityclient.ts +102 -15
  49. package/ts/classes.authoritydaemon.ts +89 -27
  50. package/ts/classes.authoritydatabase.ts +98 -12
  51. package/ts/classes.authorityimport.ts +102 -25
  52. package/ts/classes.authoritymodels.ts +4 -1
  53. package/ts/classes.authoritypreuse.ts +7 -1
  54. package/ts/classes.authorityservice.ts +15 -30
  55. package/ts/classes.cli.ts +14 -3
  56. package/ts/classes.codexmanaged.ts +10 -19
  57. package/ts/classes.legacyfence.ts +219 -0
  58. package/ts/classes.operations.ts +22 -3
  59. package/ts/classes.service.ts +45 -8
  60. package/ts/classes.tui.ts +3 -1
  61. package/ts/codexcontract.ts +200 -0
  62. package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
  63. package/ts/ts_migration/index.ts +2 -0
  64. package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
@@ -1,6 +1,8 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { authSwitchHome } from './classes.credentialstore.js';
3
3
  import { assertAuthSwitchMutation, type TAuthSwitchMutation } from './mutation.js';
4
+ import { assertAuthSwitchMutationAllowed, assertStatedLegacyFence,
5
+ type TAuthSwitchLegacyFence } from './classes.legacyfence.js';
4
6
  import type { IAuthHarness, IHarnessOutcome } from './interfaces.harness.js';
5
7
 
6
8
  /**
@@ -8,6 +10,8 @@ import type { IAuthHarness, IHarnessOutcome } from './interfaces.harness.js';
8
10
  * through `@modelprofile.com/authswitch/mutation`; they stay exported here for every Node caller.
9
11
  */
10
12
  export { assertAuthSwitchMutation, authSwitchMutationReplacesLogin, type TAuthSwitchMutation } from './mutation.js';
13
+ export { authSwitchHostLegacyFence, type TAuthSwitchLegacyFence,
14
+ type IAuthSwitchLegacyFenceLocations } from './classes.legacyfence.js';
11
15
 
12
16
  export interface IAuthSwitchCoordinationRequest {
13
17
  protocolVersion: 1;
@@ -41,9 +45,19 @@ export const authSwitchEnvironmentId = (env: NodeJS.ProcessEnv = process.env): s
41
45
  openCodeOverride: env.OPENCODE_AUTH_CONTENT !== undefined,
42
46
  })).digest('hex');
43
47
 
44
- export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAuthSwitchMutation): Promise<IHarnessOutcome> => {
48
+ /**
49
+ * The one mutation chokepoint, and therefore the one place the legacy fence can sit.
50
+ *
51
+ * `AuthSwitchOperations.run` delegates here and the AGL-hosted `AuthSwitchService` calls it directly, so a
52
+ * fence inside the class would leave the hosted path open. Where it looks is stated by the caller rather
53
+ * than defaulted, for the reason the authority states its own legacy locations: a fence that reached for
54
+ * `process.env` from inside a test would read whichever host ran it.
55
+ */
56
+ export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAuthSwitchMutation,
57
+ legacyFence: TAuthSwitchLegacyFence): Promise<IHarnessOutcome> => {
45
58
  assertAuthSwitchMutation(mutation);
46
59
  if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
60
+ await assertAuthSwitchMutationAllowed(legacyFence, harness, mutation);
47
61
  switch (mutation.action) {
48
62
  case 'save': return harness.saveCurrent({ keepActive: mutation.keepActive, accountId: mutation.accountId });
49
63
  case 'switch': return harness.switchAccount(mutation.accountId);
@@ -53,10 +67,15 @@ export const runAuthSwitchMutation = async (harness: IAuthHarness, mutation: TAu
53
67
 
54
68
  /** One mutation path for command, guide and TUI. Supervisors own their complete restart transaction. */
55
69
  export class AuthSwitchOperations {
56
- constructor(private readonly coordinator?: TAuthSwitchCoordinator) {}
70
+ /** `legacyFence` is stated, never defaulted: see `runAuthSwitchMutation`. */
71
+ constructor(private readonly coordinator: TAuthSwitchCoordinator | undefined,
72
+ private readonly legacyFence: TAuthSwitchLegacyFence) { assertStatedLegacyFence(legacyFence); }
57
73
  public async run(harness: IAuthHarness, mutation: TAuthSwitchMutation, confirm?: TAuthSwitchConfirmation, prepareLocal?: TAuthSwitchLocalPreparation): Promise<IHarnessOutcome> {
58
74
  assertAuthSwitchMutation(mutation);
59
75
  if (harness.id !== mutation.harnessId) throw new Error('Account operation targets a different harness.');
76
+ // Asked before AGL is, so a coordinated mutation is refused here rather than carried out by a host that
77
+ // may not fence it; `runAuthSwitchMutation` asks again, because the answer can change while it waits.
78
+ await assertAuthSwitchMutationAllowed(this.legacyFence, harness, mutation);
60
79
  if (this.coordinator && mutation.action !== 'remove') {
61
80
  const request: IAuthSwitchCoordinationRequest = { protocolVersion: 1, contextId: authSwitchEnvironmentId(), mutation, waitForIdle: false };
62
81
  let result = await this.coordinator(request);
@@ -68,7 +87,7 @@ export class AuthSwitchOperations {
68
87
  if (result.status === 'busy') return { lines: [], problems: [result.message] };
69
88
  }
70
89
  if (prepareLocal) await prepareLocal();
71
- return runAuthSwitchMutation(harness, mutation);
90
+ return runAuthSwitchMutation(harness, mutation, this.legacyFence);
72
91
  }
73
92
  }
74
93
 
@@ -4,6 +4,9 @@ import { OpenCodeHarness } from './classes.opencodeharness.js';
4
4
  import { ClaudeCodeHarness } from './classes.claudecodeharness.js';
5
5
  import { readAccountList } from './classes.accountlist.js';
6
6
  import { assertAuthSwitchMutation, authSwitchEnvironmentId, runAuthSwitchMutation, type IAuthSwitchCoordinationRequest, type TAuthSwitchCoordinationResult, type TAuthSwitchCoordinator } from './classes.operations.js';
7
+ import { AuthSwitchRefusal } from './authority-contract.js';
8
+ import { assertAuthSwitchMutationAllowed, assertStatedLegacyFence, authSwitchHostLegacyFence,
9
+ type TAuthSwitchLegacyFence } from './classes.legacyfence.js';
7
10
  import type { IAuthHarness, IHarnessLoginHandle, IHarnessLoginOptions, IHarnessLoginProvider, IHarnessLoginResult, IHarnessOutcome } from './interfaces.harness.js';
8
11
  import type { IAccountList } from './interfaces.list.js';
9
12
  import type { TAuthLoginPrompt } from './classes.login.js';
@@ -67,15 +70,51 @@ export const assertAuthSwitchServiceRequest: (value: unknown) => asserts value i
67
70
  export class AuthSwitchService {
68
71
  private readonly operations = new Map<string, IOperation>();
69
72
  private closed = false;
70
- constructor(
71
- private readonly harnesses: IAuthHarness[] = [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()],
72
- private readonly coordinator?: TAuthSwitchCoordinator,
73
- ) {}
73
+ private readonly harnesses: IAuthHarness[];
74
+ private readonly legacyFence: TAuthSwitchLegacyFence;
75
+ /**
76
+ * With no harnesses, the service composes this host's own and fences them with this host's authority.
77
+ *
78
+ * A host that injects its harnesses -- AGL does -- also states the fence for them: only it knows which
79
+ * stores they write, and a default would fence whichever host this process happens to run on.
80
+ */
81
+ constructor();
82
+ constructor(harnesses: IAuthHarness[], coordinator: TAuthSwitchCoordinator | undefined,
83
+ legacyFence: TAuthSwitchLegacyFence);
84
+ constructor(harnessesArg?: IAuthHarness[], private readonly coordinator?: TAuthSwitchCoordinator,
85
+ legacyFenceArg?: TAuthSwitchLegacyFence) {
86
+ this.harnesses = harnessesArg ?? [new CodexHarness(), new OpenCodeHarness(), new ClaudeCodeHarness()];
87
+ const legacyFence: unknown = harnessesArg === undefined ? authSwitchHostLegacyFence() : legacyFenceArg;
88
+ assertStatedLegacyFence(legacyFence);
89
+ this.legacyFence = legacyFence;
90
+ }
74
91
  private harness(id: string): IAuthHarness {
75
92
  const harness = this.harnesses.find(item => item.id === id);
76
93
  if (!harness) throw new Error('Unknown harness.');
77
94
  return harness;
78
95
  }
96
+ /**
97
+ * One hosted mutation: fenced, then coordinated, then -- when no supervisor carries it out -- run here.
98
+ *
99
+ * The fence is asked before the coordinator, as `AuthSwitchOperations` asks it: a refused mutation must not
100
+ * first have its host stop the runtimes it would restart. The local run asks again, because the answer can
101
+ * change while coordination waits. A refusal is reported as the outcome's problem: it is an answer the owner
102
+ * acts on, exactly like the credential-location mismatch `request` reports, and it wrote nothing; letting it
103
+ * fall into the operation's generic failure would hide the one sentence that says what to do instead.
104
+ */
105
+ private async mutate(harness: IAuthHarness,
106
+ coordination: IAuthSwitchCoordinationRequest): Promise<TAuthSwitchCoordinationResult> {
107
+ try {
108
+ await assertAuthSwitchMutationAllowed(this.legacyFence, harness, coordination.mutation);
109
+ const coordinated = this.coordinator ? await this.coordinator(coordination) : { status: 'unavailable' } as const;
110
+ if (coordinated.status !== 'unavailable') return coordinated;
111
+ return { status: 'complete',
112
+ outcome: await runAuthSwitchMutation(harness, coordination.mutation, this.legacyFence) };
113
+ } catch (error) {
114
+ if (error instanceof AuthSwitchRefusal) return { status: 'complete', outcome: { lines: [], problems: [error.message] } };
115
+ throw error;
116
+ }
117
+ }
79
118
  private snapshot(operation: IOperation): IAuthSwitchServiceOperation { return structuredClone(operation.status); }
80
119
  public async request(request: TAuthSwitchServiceRequest): Promise<IAuthSwitchServiceOperation> {
81
120
  assertAuthSwitchServiceRequest(request);
@@ -111,10 +150,8 @@ export class AuthSwitchService {
111
150
  operation.status.result = { status: 'complete', outcome: { lines: [], problems: ['AGL and authswitch use different credential locations. Use matching credential environment settings before switching.'] } };
112
151
  break;
113
152
  }
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);
153
+ operation.status.result = publicResult(
154
+ await this.mutate(this.harness(request.coordination.mutation.harnessId), request.coordination));
118
155
  break;
119
156
  }
120
157
  case 'login': {
package/ts/classes.tui.ts CHANGED
@@ -13,7 +13,9 @@ const preuseResultLine = (resultArg: TPreuseAccountResult): string => resultArg.
13
13
 
14
14
  /** Owns account-management actions; terminal rendering and interaction belong to smartconsole. */
15
15
  export class AuthSwitchTui {
16
- constructor(private readonly harnesses: readonly IAuthHarness[], private readonly out: plugins.smartconsole.SmartConsole, private readonly operations = new AuthSwitchOperations()) {}
16
+ /** The guide is reached through `AuthSwitchCli`, and mutates through that command line's own operations and fence. */
17
+ constructor(private readonly harnesses: readonly IAuthHarness[], private readonly out: plugins.smartconsole.SmartConsole,
18
+ private readonly operations: AuthSwitchOperations) {}
17
19
 
18
20
  public async run(preferredArg?: IAuthHarness): Promise<number> {
19
21
  let harness = preferredArg ?? this.harnesses[0];
@@ -0,0 +1,200 @@
1
+ import * as plugins from './plugins.js';
2
+ import { AuthSwitchRefusal } from './authority-contract.js';
3
+
4
+ /**
5
+ * What managed Codex needs from the Codex it starts, checked at every start instead of pinning one release.
6
+ *
7
+ * The floor is `@modelprofile.com/mcp-crossharness`'s: 0.156.0 is the first release that publishes the
8
+ * `--listen unix://` alias managed Codex connects through, and every other surface it uses is older. Above
9
+ * the floor nothing is assumed. Codex describes its own app-server protocol (`app-server
10
+ * generate-json-schema`), and that description must still offer every request managed Codex sends and the one
11
+ * it answers, with the fields it sends. The external-token login in particular is marked unstable upstream,
12
+ * so a release that drops or reshapes it is refused at start, by name, rather than failing a session later
13
+ * -- the refresh callback, for one, would otherwise only be missed at the first expired token.
14
+ */
15
+ export const MANAGED_CODEX_MINIMUM_VERSION = plugins.crossharness.CODEX_UNIX_SOCKET_MINIMUM_VERSION;
16
+
17
+ /** The verified release, exactly as `codex --version` printed it; the app-server must report the same. */
18
+ export interface IManagedCodexContract {
19
+ version: string;
20
+ }
21
+
22
+ export interface IManagedCodexContractOptions {
23
+ executable: string;
24
+ env: NodeJS.ProcessEnv;
25
+ /** A private, existing directory; the schema is written below it and removed before this returns. */
26
+ scratchDirectory: string;
27
+ }
28
+
29
+ type TSchema = Record<string, unknown>;
30
+
31
+ const supportInstruction = 'Managed Codex needs an authswitch release that supports this Codex, or a Codex '
32
+ + 'release that still offers it.';
33
+ const maximumSchemaFileBytes = 16 * 1024 * 1024;
34
+
35
+ const isRecord = (value: unknown): value is TSchema =>
36
+ value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ const strings = (value: unknown): string[] =>
38
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
39
+
40
+ const run = (executable: string, args: string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<string | null> =>
41
+ new Promise(resolve => {
42
+ plugins.childProcess.execFile(executable, args, { timeout: timeoutMs, maxBuffer: 64 * 1024,
43
+ windowsHide: true, env }, (error, stdout) => resolve(error ? null : stdout));
44
+ });
45
+
46
+ /** Follows local `#/definitions/<name>` references; any other reference resolves to nothing. */
47
+ const resolve = (root: TSchema, node: unknown): TSchema | null => {
48
+ let current = node;
49
+ for (let depth = 0; depth < 8 && isRecord(current); depth++) {
50
+ const ref = current.$ref;
51
+ if (typeof ref !== 'string') return current;
52
+ const match = /^#\/definitions\/([^/]+)$/.exec(ref);
53
+ const definitions = root.definitions;
54
+ current = match && isRecord(definitions) ? definitions[match[1]] : undefined;
55
+ }
56
+ return null;
57
+ };
58
+
59
+ /** The schema alternatives a node offers: itself, or each member of its `oneOf`/`anyOf`. */
60
+ const alternatives = (root: TSchema, node: unknown): TSchema[] => {
61
+ const resolved = resolve(root, node);
62
+ if (!resolved) return [];
63
+ const members = [...(Array.isArray(resolved.oneOf) ? resolved.oneOf : []),
64
+ ...(Array.isArray(resolved.anyOf) ? resolved.anyOf : [])];
65
+ return members.length ? members.flatMap(member => alternatives(root, member)) : [resolved];
66
+ };
67
+
68
+ /** Every string a node accepts as an enum value, through references and alternatives. */
69
+ const enumValues = (root: TSchema, node: unknown): string[] =>
70
+ alternatives(root, node).flatMap(member => strings(member.enum));
71
+
72
+ const acceptsNull = (root: TSchema, node: unknown): boolean => alternatives(root, node)
73
+ .some(member => member.type === 'null' || strings(member.type).includes('null'));
74
+
75
+ /** The object alternative whose `discriminator` property is exactly `value`, or the node itself. */
76
+ const variant = (root: TSchema, node: unknown, discriminator: string, value: string): TSchema | null =>
77
+ alternatives(root, node).find(member => {
78
+ const properties = member.properties;
79
+ const tag = isRecord(properties) ? resolve(root, properties[discriminator]) : null;
80
+ return tag !== null && strings(tag.enum).length === 1 && strings(tag.enum)[0] === value;
81
+ }) ?? null;
82
+
83
+ /** Declares every field managed Codex sends, and requires none it does not send. */
84
+ const acceptsFields = (object: TSchema | null, sent: readonly string[]): object is TSchema => {
85
+ if (!object || !isRecord(object.properties)) return false;
86
+ const properties = object.properties;
87
+ return sent.every(field => Object.hasOwn(properties, field))
88
+ && strings(object.required).every(field => sent.includes(field));
89
+ };
90
+
91
+ const property = (object: TSchema | null, name: string): unknown =>
92
+ object && isRecord(object.properties) ? object.properties[name] : undefined;
93
+
94
+ const requestMethods = (root: TSchema): string[] => alternatives(root, root)
95
+ .flatMap(member => isRecord(member.properties) ? strings(resolve(root, member.properties.method)?.enum) : []);
96
+
97
+ /**
98
+ * Each surface managed Codex uses, and whether a protocol description still offers it. Reads nothing but
99
+ * the schema files named here, so a release that moves a file fails the element that needs it.
100
+ */
101
+ const elements: ReadonlyArray<{ name: string; files: string[]; offered: (schemas: TSchema[]) => boolean }> = [
102
+ {
103
+ name: 'the requests account/login/start, thread/start, thread/resume and turn/start',
104
+ files: ['ClientRequest.json'],
105
+ offered: ([client]) => ['account/login/start', 'thread/start', 'thread/resume', 'turn/start']
106
+ .every(method => requestMethods(client).includes(method)),
107
+ },
108
+ {
109
+ name: 'the external ChatGPT token login (account/login/start chatgptAuthTokens)',
110
+ files: ['v2/LoginAccountParams.json', 'v2/LoginAccountResponse.json'],
111
+ offered: ([params, response]) =>
112
+ acceptsFields(variant(params, params, 'type', 'chatgptAuthTokens'), ['type', 'accessToken', 'chatgptAccountId'])
113
+ && variant(response, response, 'type', 'chatgptAuthTokens') !== null,
114
+ },
115
+ {
116
+ name: 'the token refresh callback (account/chatgptAuthTokens/refresh)',
117
+ files: ['ServerRequest.json', 'ChatgptAuthTokensRefreshParams.json', 'ChatgptAuthTokensRefreshResponse.json'],
118
+ offered: ([server, params, response]) => requestMethods(server).includes('account/chatgptAuthTokens/refresh')
119
+ && enumValues(params, property(params, 'reason')).includes('unauthorized')
120
+ && acceptsFields(response, ['accessToken', 'chatgptAccountId', 'chatgptPlanType'])
121
+ && acceptsNull(response, property(response, 'chatgptPlanType')),
122
+ },
123
+ {
124
+ name: 'thread/start and thread/resume with a working directory, approval policy and model',
125
+ files: ['v2/ThreadStartParams.json', 'v2/ThreadResumeParams.json'],
126
+ offered: ([start, resume]) => acceptsFields(start, ['cwd', 'approvalPolicy', 'model'])
127
+ && acceptsFields(resume, ['threadId', 'cwd', 'approvalPolicy', 'model'])
128
+ && [start, resume].every(schema => ['never', 'on-request']
129
+ .every(policy => enumValues(schema, property(schema, 'approvalPolicy')).includes(policy))),
130
+ },
131
+ {
132
+ name: 'turn/start with text input, approval policy and model',
133
+ files: ['v2/TurnStartParams.json'],
134
+ offered: ([turn]) => {
135
+ if (!acceptsFields(turn, ['threadId', 'input', 'approvalPolicy', 'model'])) return false;
136
+ const input = resolve(turn, property(turn, 'input'));
137
+ return acceptsFields(variant(turn, input?.items, 'type', 'text'), ['type', 'text', 'text_elements'])
138
+ && ['never', 'on-request']
139
+ .every(policy => enumValues(turn, property(turn, 'approvalPolicy')).includes(policy));
140
+ },
141
+ },
142
+ ];
143
+
144
+ const readSchema = async (directory: string, relative: string): Promise<TSchema | null> => {
145
+ try {
146
+ const file = plugins.path.join(directory, ...relative.split('/'));
147
+ const stat = await plugins.fs.promises.lstat(file);
148
+ if (!stat.isFile() || stat.size > maximumSchemaFileBytes) return null;
149
+ const value: unknown = JSON.parse(await plugins.fs.promises.readFile(file, 'utf8'));
150
+ return isRecord(value) ? value : null;
151
+ } catch { return null; }
152
+ };
153
+
154
+ const refuse = (instruction: string): never => { throw new AuthSwitchRefusal('codex_unsupported', instruction); };
155
+
156
+ /**
157
+ * Proves, before anything is recorded or started, that `executable` is a Codex managed Codex can run: its
158
+ * version is the floor or newer, and its own protocol description offers every surface managed Codex uses.
159
+ * Every refusal is `codex_unsupported` and names the version or the missing surface.
160
+ */
161
+ export const verifyManagedCodexContract = async (options: IManagedCodexContractOptions): Promise<IManagedCodexContract> => {
162
+ const printed = await run(options.executable, ['--version'], options.env, 5_000);
163
+ const version = /^codex-cli (\S+)$/.exec(printed?.trim() ?? '')?.[1] ?? null;
164
+ try {
165
+ plugins.crossharness.requireCodexVersion(version, MANAGED_CODEX_MINIMUM_VERSION);
166
+ } catch (error) {
167
+ if (!(error instanceof plugins.crossharness.CodexVersionUnsupportedError)) throw error;
168
+ refuse(error.reason === 'unreadable'
169
+ ? `This Codex did not report a readable version. Install Codex ${MANAGED_CODEX_MINIMUM_VERSION} or newer.`
170
+ : `Codex ${version} is older than ${MANAGED_CODEX_MINIMUM_VERSION}, the first release managed Codex runs. `
171
+ + 'Update Codex, then start managed Codex again.');
172
+ }
173
+ const verified = version!;
174
+ const directory = await plugins.fs.promises.mkdtemp(plugins.path.join(options.scratchDirectory, 'schema-'));
175
+ try {
176
+ if (await run(options.executable, ['app-server', 'generate-json-schema', '--out', directory],
177
+ options.env, 30_000) === null) {
178
+ refuse(`Codex ${verified} did not describe its app-server protocol, so managed Codex cannot check it. `
179
+ + supportInstruction);
180
+ }
181
+ for (const element of elements) {
182
+ const schemas = await Promise.all(element.files.map(file => readSchema(directory, file)));
183
+ if (schemas.some(schema => schema === null) || !element.offered(schemas as TSchema[])) {
184
+ refuse(`Codex ${verified} does not offer ${element.name}. ${supportInstruction}`);
185
+ }
186
+ }
187
+ } finally {
188
+ await plugins.fs.promises.rm(directory, { recursive: true, force: true });
189
+ }
190
+ return { version: verified };
191
+ };
192
+
193
+ /** The app-server a start connected to must be the release that was verified; Codex can replace itself. */
194
+ export const requireVerifiedCodexServer = (contract: IManagedCodexContract, serverVersion: string | undefined): void => {
195
+ if (serverVersion === contract.version) return;
196
+ const reported = serverVersion !== undefined && plugins.crossharness.parseCodexVersion(serverVersion)
197
+ ? `Codex ${serverVersion}` : 'an unreadable version';
198
+ refuse(`The Codex app-server reports ${reported}, not the checked Codex ${contract.version}; Codex was `
199
+ + 'replaced while managed Codex started. Start managed Codex again.');
200
+ };
@@ -0,0 +1,19 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /**
4
+ * Refuses a store holding a Claude container setup grant that a native tool would own.
5
+ *
6
+ * Until 9.1.0 the grant record allowed a container setup grant to be owned by `legacy_native`. No release
7
+ * ever wrote one: the importer creates only Claude host, OpenCode and managed OpenAI grants, and no route
8
+ * creates a container setup grant at all. 9.1.0 narrows the record to what the authority means by it -- the
9
+ * authority holds that token, or nobody does -- so a row outside that would fail every read of its account.
10
+ * Such a row was never written by this package, and nothing about it can be repaired by guessing, so the
11
+ * store is refused before the first write and left exactly as it was.
12
+ */
13
+ export const refuseNativeContainerSetupOwners = async (db: plugins.nosqldb.SmartdataDb): Promise<void> => {
14
+ const grants = db.mongoDb.collection('authswitch_authority_grants');
15
+ if (await grants.findOne({ purpose: 'claude_container_setup', owner: { $nin: ['daemon', 'none'] } })) {
16
+ throw new Error('Authswitch authority store holds a Claude container setup login owned by a native tool, '
17
+ + 'which no release could have recorded; set the store aside and sign in to each account again.');
18
+ }
19
+ };
@@ -2,6 +2,7 @@ import * as plugins from '../plugins.js';
2
2
  import { migrateAuthorityMeta } from './0001_authority_meta.js';
3
3
  import { removeAuthorityBackupRecords } from './0002_remove_backup_records.js';
4
4
  import { addClaudeHandoffProofFailure } from './0003_claude_handoff_proof.js';
5
+ import { refuseNativeContainerSetupOwners } from './0004_container_setup_owner.js';
5
6
 
6
7
  /**
7
8
  * Runs on every start of an existing store, in this fixed order. Each module recognises the published shapes it
@@ -11,4 +12,5 @@ export const runAuthorityMigrations = async (db: plugins.nosqldb.SmartdataDb): P
11
12
  await migrateAuthorityMeta(db);
12
13
  await removeAuthorityBackupRecords(db);
13
14
  await addClaudeHandoffProofFailure(db);
15
+ await refuseNativeContainerSetupOwners(db);
14
16
  };
@@ -175,7 +175,10 @@ export const readAuthSwitchStores = (locations: ILegacySourceLocations): ILegacy
175
175
  try { entries = plugins.fs.readdirSync(locations.authSwitchHome, { withFileTypes: true }); }
176
176
  catch (error) {
177
177
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { sources, problems };
178
- return { sources, problems: [`${locations.authSwitchHome} could not be listed`] };
178
+ // The store is named by what it is, never by where it is: this report goes to a CLI and to a backend,
179
+ // and the location of a credential store is not something either of them may be told. Everything
180
+ // below names an entry inside the store instead, which is what the owner needs to find it.
181
+ return { sources, problems: ['the authswitch store could not be listed'] };
179
182
  }
180
183
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
181
184
  const path = plugins.path.join(locations.authSwitchHome, entry.name);