@moxxy/plugin-provider-codex-agent 0.0.0-stage → 0.46.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/account-limit.d.ts +11 -0
  3. package/dist/account-limit.d.ts.map +1 -0
  4. package/dist/account-limit.js +24 -0
  5. package/dist/account-limit.js.map +1 -0
  6. package/dist/agent-provider.d.ts +62 -0
  7. package/dist/agent-provider.d.ts.map +1 -0
  8. package/dist/agent-provider.js +209 -0
  9. package/dist/agent-provider.js.map +1 -0
  10. package/dist/codex-session.d.ts +220 -0
  11. package/dist/codex-session.d.ts.map +1 -0
  12. package/dist/codex-session.js +1113 -0
  13. package/dist/codex-session.js.map +1 -0
  14. package/dist/constants.d.ts +15 -0
  15. package/dist/constants.d.ts.map +1 -0
  16. package/dist/constants.js +15 -0
  17. package/dist/constants.js.map +1 -0
  18. package/dist/executable.d.ts +16 -0
  19. package/dist/executable.d.ts.map +1 -0
  20. package/dist/executable.js +43 -0
  21. package/dist/executable.js.map +1 -0
  22. package/dist/index.d.ts +9 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +79 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/login.d.ts +37 -0
  27. package/dist/login.d.ts.map +1 -0
  28. package/dist/login.js +127 -0
  29. package/dist/login.js.map +1 -0
  30. package/dist/offered-models.d.ts +12 -0
  31. package/dist/offered-models.d.ts.map +1 -0
  32. package/dist/offered-models.js +53 -0
  33. package/dist/offered-models.js.map +1 -0
  34. package/dist/own-settings.d.ts +9 -0
  35. package/dist/own-settings.d.ts.map +1 -0
  36. package/dist/own-settings.js +34 -0
  37. package/dist/own-settings.js.map +1 -0
  38. package/dist/protocol.d.ts +279 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +30 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/subagents.d.ts +20 -0
  43. package/dist/subagents.d.ts.map +1 -0
  44. package/dist/subagents.js +90 -0
  45. package/dist/subagents.js.map +1 -0
  46. package/dist/translate.d.ts +87 -0
  47. package/dist/translate.d.ts.map +1 -0
  48. package/dist/translate.js +334 -0
  49. package/dist/translate.js.map +1 -0
  50. package/dist/view-image.d.ts +4 -0
  51. package/dist/view-image.d.ts.map +1 -0
  52. package/dist/view-image.js +47 -0
  53. package/dist/view-image.js.map +1 -0
  54. package/package.json +82 -3
  55. package/src/account-limit.ts +25 -0
  56. package/src/agent-cli.test.ts +76 -0
  57. package/src/agent-provider.test.ts +1556 -0
  58. package/src/agent-provider.ts +234 -0
  59. package/src/codex-session.ts +1258 -0
  60. package/src/constants.ts +18 -0
  61. package/src/event-coverage.test.ts +200 -0
  62. package/src/executable.test.ts +53 -0
  63. package/src/executable.ts +49 -0
  64. package/src/fake-codex.testkit.ts +576 -0
  65. package/src/fixtures/codex-app-server-0.155.1.json +122 -0
  66. package/src/fixtures/codex-app-server-0.163.0.json +125 -0
  67. package/src/fixtures/codex-model-list-0.163.0.json +436 -0
  68. package/src/index.test.ts +45 -0
  69. package/src/index.ts +99 -0
  70. package/src/login.test.ts +93 -0
  71. package/src/login.ts +167 -0
  72. package/src/offered-models.test.ts +87 -0
  73. package/src/offered-models.ts +65 -0
  74. package/src/own-settings.test.ts +45 -0
  75. package/src/own-settings.ts +39 -0
  76. package/src/protocol.ts +256 -0
  77. package/src/subagents.test.ts +47 -0
  78. package/src/subagents.ts +98 -0
  79. package/src/translate.test.ts +160 -0
  80. package/src/translate.ts +362 -0
  81. package/src/view-image.test.ts +29 -0
  82. package/src/view-image.ts +47 -0
  83. package/README.md +0 -3
@@ -0,0 +1,45 @@
1
+ /** The plugin entry: the conversation's Codex ends when the conversation does. */
2
+ import { describe, expect, it } from 'vitest';
3
+ import { collectTurn } from '@moxxy/core';
4
+ import { assertDefined } from '@moxxy/sdk';
5
+ import { createFakeSession } from '@moxxy/testing';
6
+ import { defaultModePlugin } from '@moxxy/mode-default';
7
+ import { CodexAgentProvider, codexSessions, codexAgentPlugin } from './index.js';
8
+ import { FakeCodex } from './fake-codex.testkit.js';
9
+
10
+ describe('the Codex plugin', () => {
11
+ it('starts a new conversation on GPT-6 Luna while the person chose no default model', async () => {
12
+ const { codexAgentProviderDef } = await import('./index.js');
13
+ const provider = new CodexAgentProvider({ startAppServer: new FakeCodex(async () => undefined).start });
14
+ const session = createFakeSession({ provider, plugins: [defaultModePlugin] });
15
+
16
+ expect(session.getInfo().defaultModel).toBe('gpt-6-luna');
17
+ expect(codexAgentProviderDef.starterModel).toBe('gpt-6-luna');
18
+ await session.close();
19
+ });
20
+
21
+ it('closes the conversation running Codex when the conversation shuts down', async () => {
22
+ const codex = new FakeCodex(async (turn) => {
23
+ await turn.say('hi');
24
+ });
25
+ const provider = new CodexAgentProvider({ startAppServer: codex.start });
26
+ const session = createFakeSession({ provider, plugins: [defaultModePlugin] });
27
+ await collectTurn(session, 'hello');
28
+ expect(codexSessions().size).toBe(1);
29
+
30
+ const onShutdown = codexAgentPlugin.hooks?.onShutdown;
31
+ assertDefined(onShutdown, 'the plugin onShutdown hook');
32
+ await onShutdown(session.appContext());
33
+
34
+ expect(codex.last.closed).toBe(true);
35
+ expect(codexSessions().size).toBe(0);
36
+ await session.close();
37
+ });
38
+
39
+ it("lists the models the person's Codex offers, and still knows its newest when Codex is not asked", async () => {
40
+ const { codexAgentProviderDef, codexAgentModels } = await import('./index.js');
41
+
42
+ expect(typeof codexAgentProviderDef.agentCli?.models).toBe('function');
43
+ expect(codexAgentModels.map((m) => m.id)).toContain('gpt-6.1-sol');
44
+ });
45
+ });
package/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { homedir } from 'node:os';
2
+ import { defineProvider, definePlugin, MoxxyError } from '@moxxy/sdk';
3
+ import { CODEX_CLI, checkAgentCli } from '@moxxy/agent-connection-kit';
4
+ import { CODEX_AGENT_NAME, CODEX_AGENT_PROVIDER_ID, CODEX_SERVICE_NAME } from './constants.js';
5
+ import { CODEX_AGENT_STARTER_MODEL, codexAgentModels, codexSessions, createCodexAgentClient, type CodexAgentProviderConfig } from './agent-provider.js';
6
+ import { codexTarget, spawnCodexAppServer } from './executable.js';
7
+ import { codexOwnAutoApprove } from './own-settings.js';
8
+ import { codexOfferedModels } from './offered-models.js';
9
+ import { authError, checkCodexAuth, codexLogin, codexLogout, codexSignIn, codexStatus, resolveCodexExecutable } from './login.js';
10
+
11
+ export const codexAgentProviderDef = defineProvider({
12
+ name: CODEX_AGENT_PROVIDER_ID,
13
+ agentName: CODEX_AGENT_NAME,
14
+ models: codexAgentModels,
15
+ starterModel: CODEX_AGENT_STARTER_MODEL,
16
+ agentCli: {
17
+ check: (opts) => {
18
+ const command = resolveCodexExecutable();
19
+ return checkAgentCli(CODEX_CLI, { command, refresh: opts?.refresh === true }, { signIn: () => codexSignIn(command) });
20
+ },
21
+ // Asked outside any project, so no folder's config narrows the list.
22
+ models: () => codexOfferedModels(homedir(), spawnCodexAppServer(resolveCodexExecutable())),
23
+ },
24
+ ownAutoApprove: ({ cwd }) => codexOwnAutoApprove(cwd, spawnCodexAppServer(resolveCodexExecutable())),
25
+ createClient: (config) => createCodexAgentClient(providerConfigFrom(config)),
26
+ async resolveCredentials({ providerConfig, host }) {
27
+ const config = { ...providerConfig, cwd: host.cwd };
28
+ const executable = resolveCodexExecutable(config);
29
+ const status = await checkCodexAuth(executable);
30
+ if (status.state !== 'signed-in') throw authError(status);
31
+ try {
32
+ codexTarget(executable);
33
+ } catch (err) {
34
+ throw new MoxxyError({
35
+ code: 'AUTH_INVALID',
36
+ message: err instanceof Error ? err.message : String(err),
37
+ context: { provider: CODEX_AGENT_PROVIDER_ID, executable },
38
+ });
39
+ }
40
+ return { ...config, executable };
41
+ },
42
+ // No validateKey: readiness is reported by the installed Codex CLI.
43
+ auth: {
44
+ kind: 'oauth',
45
+ serviceName: CODEX_SERVICE_NAME,
46
+ login: codexLogin,
47
+ logout: codexLogout,
48
+ status: codexStatus,
49
+ },
50
+ });
51
+
52
+ export const codexAgentPlugin = definePlugin({
53
+ name: '@moxxy/plugin-provider-codex-agent',
54
+ version: '0.0.0',
55
+ providers: [codexAgentProviderDef],
56
+ hooks: {
57
+ // The conversation's Codex ends with it; its thread stays saved by Codex, to resume.
58
+ onShutdown: (ctx) => codexSessions().close(String(ctx.sessionId)),
59
+ },
60
+ });
61
+
62
+ export default codexAgentPlugin;
63
+
64
+ function providerConfigFrom(config: Readonly<Record<string, unknown>>): CodexAgentProviderConfig {
65
+ const text = (key: string) => (typeof config[key] === 'string' && config[key] !== '' ? (config[key] as string) : undefined);
66
+ const executable = text('executable');
67
+ const model = text('model');
68
+ const defaultModel = text('defaultModel');
69
+ const cwd = text('cwd');
70
+ return {
71
+ ...(executable ? { executable } : {}),
72
+ ...(model ? { model } : {}),
73
+ ...(defaultModel ? { defaultModel } : {}),
74
+ ...(cwd ? { cwd } : {}),
75
+ };
76
+ }
77
+
78
+ export { CODEX_AGENT_PROVIDER_ID, CODEX_SERVICE_NAME, CODEX_EXECUTABLE_ENV } from './constants.js';
79
+ export {
80
+ CODEX_AGENT_DEFAULT_MODEL,
81
+ CODEX_AGENT_STARTER_MODEL,
82
+ CodexAgentProvider,
83
+ codexAgentModels,
84
+ codexSessions,
85
+ createCodexAgentClient,
86
+ type CodexAgentProviderConfig,
87
+ } from './agent-provider.js';
88
+ export type { CodexConnection, StartCodexAppServer } from './codex-session.js';
89
+ export { codexTarget, spawnCodexAppServer } from './executable.js';
90
+ export {
91
+ __setCodexCommandRunner,
92
+ checkCodexAuth,
93
+ codexLogin,
94
+ codexLogout,
95
+ codexStatus,
96
+ resolveCodexExecutable,
97
+ type CodexAuthState,
98
+ type CodexAuthStatus,
99
+ } from './login.js';
@@ -0,0 +1,93 @@
1
+ import { afterAll, beforeEach, describe, expect, it } from 'vitest';
2
+ import type { ProviderAuthContext } from '@moxxy/sdk';
3
+ import { __setCodexCommandRunner, checkCodexAuth, codexLogin, codexLogout, codexStatus, resolveCodexExecutable } from './login.js';
4
+
5
+ function ctx(extra: Partial<ProviderAuthContext> = {}): ProviderAuthContext {
6
+ return {
7
+ headless: false,
8
+ write: () => {},
9
+ vault: { get: async () => null, set: async () => {}, delete: async () => false },
10
+ ...extra,
11
+ };
12
+ }
13
+
14
+ const run = (stdout: string, code = 0, stderr = '') => Promise.resolve({ code, stdout, stderr });
15
+
16
+ beforeEach(() => {
17
+ delete process.env.CODEX_EXECUTABLE;
18
+ });
19
+
20
+ afterAll(() => {
21
+ __setCodexCommandRunner(async () => ({ code: 1, stdout: '', stderr: 'not configured' }));
22
+ });
23
+
24
+ describe('Codex sign-in, read from the person own Codex', () => {
25
+ it('reads a ChatGPT sign-in from `codex login status`', async () => {
26
+ const calls: string[][] = [];
27
+ __setCodexCommandRunner(async (_exe, args) => {
28
+ calls.push([...args]);
29
+ return run('Logged in using ChatGPT\n');
30
+ });
31
+ await expect(checkCodexAuth()).resolves.toMatchObject({ state: 'signed-in', executable: 'codex', accountId: 'ChatGPT' });
32
+ expect(calls).toEqual([['login', 'status']]);
33
+ });
34
+
35
+ it('reads a signed-out Codex', async () => {
36
+ __setCodexCommandRunner(async () => run('', 1, 'Not logged in\n'));
37
+ await expect(checkCodexAuth()).resolves.toMatchObject({ state: 'signed-out' });
38
+ });
39
+
40
+ it('says Codex is missing when it is not installed', async () => {
41
+ __setCodexCommandRunner(async () => ({ code: 1, stdout: '', stderr: '', error: Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }) }));
42
+ await expect(checkCodexAuth()).resolves.toMatchObject({ state: 'missing', message: expect.stringContaining('CODEX_EXECUTABLE') });
43
+ });
44
+
45
+ it('uses the configured Codex, then CODEX_EXECUTABLE, then codex on PATH', () => {
46
+ expect(resolveCodexExecutable({ executable: '/opt/codex' })).toBe('/opt/codex');
47
+ process.env.CODEX_EXECUTABLE = '/env/codex';
48
+ expect(resolveCodexExecutable()).toBe('/env/codex');
49
+ delete process.env.CODEX_EXECUTABLE;
50
+ expect(resolveCodexExecutable()).toBe('codex');
51
+ });
52
+
53
+ it('signs in with Codex own `codex login` only when signed out, and checks it worked', async () => {
54
+ const calls: string[][] = [];
55
+ __setCodexCommandRunner(async (_exe, args) => {
56
+ calls.push([...args]);
57
+ if (args[0] === 'login' && args.length === 1) return run('Successfully logged in\n');
58
+ return calls.length > 2 ? run('Logged in using ChatGPT\n') : run('', 1, 'Not logged in\n');
59
+ });
60
+ await expect(codexLogin(ctx())).resolves.toEqual({ accountId: 'ChatGPT' });
61
+ expect(calls).toEqual([['login', 'status'], ['login'], ['login', 'status']]);
62
+ });
63
+
64
+ it('in the desktop, hands the sign-in page to the desktop to open', async () => {
65
+ const urls: string[] = [];
66
+ let signedIn = false;
67
+ __setCodexCommandRunner(async (_exe, args, opts) => {
68
+ if (args.length === 1) {
69
+ opts?.onOutput?.('Starting local login server on http://localhost:1455.\nIf your browser did not open, navigate to this URL to authenticate:\n\nhttps://auth.openai.com/oauth/authorize?client_id=app_x&state=s\n');
70
+ signedIn = true;
71
+ return run('');
72
+ }
73
+ return signedIn ? run('Logged in using ChatGPT\n') : run('', 1, 'Not logged in\n');
74
+ });
75
+ await codexLogin(ctx({ noOpen: true, onAuthUrl: (url) => urls.push(url) }));
76
+ expect(urls).toEqual(['https://auth.openai.com/oauth/authorize?client_id=app_x&state=s']);
77
+ });
78
+
79
+ it('signs out with `codex logout`', async () => {
80
+ const calls: string[][] = [];
81
+ __setCodexCommandRunner(async (_exe, args) => {
82
+ calls.push([...args]);
83
+ return args[0] === 'logout' ? run('Successfully logged out\n') : run('Logged in using ChatGPT\n');
84
+ });
85
+ await expect(codexLogout(ctx())).resolves.toBe(true);
86
+ expect(calls).toEqual([['login', 'status'], ['logout']]);
87
+ });
88
+
89
+ it('reports the state for status surfaces', async () => {
90
+ __setCodexCommandRunner(async () => run('', 1, 'Not logged in\n'));
91
+ await expect(codexStatus(ctx())).resolves.toMatchObject({ authState: 'signed-out', accountId: null });
92
+ });
93
+ });
package/src/login.ts ADDED
@@ -0,0 +1,167 @@
1
+ import { MoxxyError } from '@moxxy/sdk';
2
+ import { spawnExecutableTarget } from '@moxxy/sdk/server';
3
+ import type { AgentSignIn } from '@moxxy/agent-connection-kit';
4
+ import type { ProviderAuthContext, ProviderOAuthResult, ProviderOAuthStatus } from '@moxxy/sdk';
5
+ import {
6
+ CODEX_AGENT_PROVIDER_ID,
7
+ CODEX_EXECUTABLE_ENV,
8
+ CODEX_LOGIN_ARGS,
9
+ CODEX_LOGIN_STATUS_ARGS,
10
+ CODEX_LOGOUT_ARGS,
11
+ } from './constants.js';
12
+ import { codexTarget } from './executable.js';
13
+
14
+ export type CodexAuthState = 'signed-in' | 'signed-out' | 'missing' | 'error';
15
+
16
+ export interface CodexAuthStatus {
17
+ readonly state: CodexAuthState;
18
+ readonly executable: string;
19
+ /** How Codex is signed in (`ChatGPT`, `an API key`); Codex does not print the account. */
20
+ readonly accountId?: string;
21
+ readonly message: string;
22
+ }
23
+
24
+ export interface CodexCommandResult {
25
+ readonly code: number;
26
+ readonly stdout: string;
27
+ readonly stderr: string;
28
+ readonly error?: NodeJS.ErrnoException;
29
+ }
30
+
31
+ interface CommandOptions {
32
+ /** Hand Codex the terminal (a CLI login). */
33
+ readonly inherit?: boolean;
34
+ /** See Codex's output as it comes (a GUI login, to find the sign-in page). */
35
+ readonly onOutput?: (chunk: string) => void;
36
+ }
37
+
38
+ type CommandRunner = (executable: string, args: readonly string[], options?: CommandOptions) => Promise<CodexCommandResult>;
39
+
40
+ const SIGN_IN_PAGE = /https:\/\/auth\.openai\.com\/\S+/;
41
+
42
+ let runCommandImpl: CommandRunner = runCommand;
43
+ /** Test seam. */
44
+ export function __setCodexCommandRunner(runner: CommandRunner): void {
45
+ runCommandImpl = runner;
46
+ }
47
+
48
+ export function resolveCodexExecutable(config: Readonly<Record<string, unknown>> = {}): string {
49
+ const configured = config.executable;
50
+ if (typeof configured === 'string' && configured.trim()) return configured;
51
+ return process.env[CODEX_EXECUTABLE_ENV]?.trim() || 'codex';
52
+ }
53
+
54
+ /** Read the sign-in from the person's own Codex (`codex login status`); Moxxy never touches its tokens. */
55
+ export async function checkCodexAuth(executable = resolveCodexExecutable()): Promise<CodexAuthStatus> {
56
+ const result = await runCommandImpl(executable, CODEX_LOGIN_STATUS_ARGS);
57
+ if (result.error?.code === 'ENOENT') {
58
+ return { state: 'missing', executable, message: `Codex CLI not found: ${executable}. Install Codex or set ${CODEX_EXECUTABLE_ENV}.` };
59
+ }
60
+ if (result.error) return { state: 'error', executable, message: `Could not run Codex: ${result.error.message}` };
61
+ const text = `${result.stdout}\n${result.stderr}`.trim();
62
+ if (/not logged in/i.test(text)) {
63
+ return { state: 'signed-out', executable, message: `Codex is installed but signed out. Run \`moxxy login ${CODEX_AGENT_PROVIDER_ID}\`.` };
64
+ }
65
+ const using = /logged in using (.+)/i.exec(text);
66
+ if (using?.[1] && result.code === 0) {
67
+ const accountId = using[1].trim();
68
+ return { state: 'signed-in', executable, accountId, message: `Codex is signed in using ${accountId}.` };
69
+ }
70
+ return { state: 'error', executable, message: `Codex login status failed${text ? `: ${text}` : ` with exit code ${result.code}`}.` };
71
+ }
72
+
73
+ /** Who Codex is signed in as, for the connection's row. */
74
+ export async function codexSignIn(executable = resolveCodexExecutable()): Promise<AgentSignIn> {
75
+ const status = await checkCodexAuth(executable);
76
+ return { signedIn: status.state === 'signed-in', ...(status.accountId ? { account: status.accountId } : {}) };
77
+ }
78
+
79
+ export async function codexLogin(ctx: ProviderAuthContext): Promise<ProviderOAuthResult> {
80
+ const executable = resolveCodexExecutable(ctx.providerConfig);
81
+ let status = await checkCodexAuth(executable);
82
+ if (status.state === 'signed-in') return { accountId: status.accountId ?? null };
83
+ if (status.state !== 'signed-out') throw authError(status);
84
+
85
+ // A GUI host (the desktop) has no terminal: show Codex's output and let the host open the sign-in page.
86
+ let opened = false;
87
+ const login = await runCommandImpl(
88
+ executable,
89
+ CODEX_LOGIN_ARGS,
90
+ ctx.noOpen
91
+ ? {
92
+ onOutput: (chunk) => {
93
+ ctx.write(chunk);
94
+ const page = SIGN_IN_PAGE.exec(chunk);
95
+ if (page && !opened) {
96
+ opened = true;
97
+ ctx.onAuthUrl?.(page[0]);
98
+ }
99
+ },
100
+ }
101
+ : { inherit: true },
102
+ );
103
+ if (login.error) throw authError({ state: login.error.code === 'ENOENT' ? 'missing' : 'error', executable, message: login.error.message });
104
+ if (login.code !== 0) {
105
+ throw new MoxxyError({
106
+ code: 'AUTH_DENIED',
107
+ message: `Codex login failed${login.stderr ? `: ${login.stderr.trim()}` : ` with exit code ${login.code}`}.`,
108
+ hint: 'Run `codex login` directly for more detail, then retry.',
109
+ context: { provider: CODEX_AGENT_PROVIDER_ID },
110
+ });
111
+ }
112
+ status = await checkCodexAuth(executable);
113
+ if (status.state !== 'signed-in') throw authError(status);
114
+ return { accountId: status.accountId ?? null };
115
+ }
116
+
117
+ export async function codexLogout(ctx: ProviderAuthContext): Promise<boolean> {
118
+ const executable = resolveCodexExecutable(ctx.providerConfig);
119
+ const before = await checkCodexAuth(executable);
120
+ if (before.state !== 'signed-in') return false;
121
+ const result = await runCommandImpl(executable, CODEX_LOGOUT_ARGS, { inherit: true });
122
+ if (result.error || result.code !== 0) {
123
+ throw new MoxxyError({ code: 'AUTH_INVALID', message: `Codex logout failed${result.stderr ? `: ${result.stderr}` : '.'}` });
124
+ }
125
+ return true;
126
+ }
127
+
128
+ export async function codexStatus(ctx: ProviderAuthContext): Promise<ProviderOAuthStatus | null> {
129
+ const status = await checkCodexAuth(resolveCodexExecutable(ctx.providerConfig));
130
+ return { accountId: status.accountId ?? null, authState: status.state, message: status.message };
131
+ }
132
+
133
+ export function authError(status: CodexAuthStatus): MoxxyError {
134
+ return new MoxxyError({
135
+ code: status.state === 'signed-out' ? 'AUTH_NO_CREDENTIALS' : 'AUTH_INVALID',
136
+ message: status.message,
137
+ hint: status.state === 'signed-out' ? `Run \`moxxy login ${CODEX_AGENT_PROVIDER_ID}\`.` : undefined,
138
+ context: { provider: CODEX_AGENT_PROVIDER_ID, executable: status.executable, state: status.state },
139
+ });
140
+ }
141
+
142
+ async function runCommand(executable: string, args: readonly string[], options: CommandOptions = {}): Promise<CodexCommandResult> {
143
+ let target;
144
+ try {
145
+ target = codexTarget(executable);
146
+ } catch (err) {
147
+ const error = Object.assign(err instanceof Error ? err : new Error(String(err)), { code: 'ENOENT' }) as NodeJS.ErrnoException;
148
+ return { code: 1, stdout: '', stderr: '', error };
149
+ }
150
+ return await new Promise((resolve) => {
151
+ const child = spawnExecutableTarget(target, [...args], options.inherit ? { stdio: 'inherit' } : { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
152
+ let stdout = '';
153
+ let stderr = '';
154
+ child.stdout?.setEncoding('utf8');
155
+ child.stderr?.setEncoding('utf8');
156
+ child.stdout?.on('data', (chunk: string) => {
157
+ stdout += chunk;
158
+ options.onOutput?.(chunk);
159
+ });
160
+ child.stderr?.on('data', (chunk: string) => {
161
+ stderr += chunk;
162
+ options.onOutput?.(chunk);
163
+ });
164
+ child.once('error', (error: NodeJS.ErrnoException) => resolve({ code: 1, stdout, stderr, error }));
165
+ child.once('close', (code) => resolve({ code: code ?? 1, stdout, stderr }));
166
+ });
167
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The models the person's Codex offers their account, as its own picker lists
3
+ * them: a short-lived `codex app-server` is asked `model/list`, page by page,
4
+ * and closed again. The pages are a recorded Codex 0.163.0 answer; Codex is
5
+ * the external boundary.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { FakeCodex } from './fake-codex.testkit.js';
10
+ import { codexOfferedModels } from './offered-models.js';
11
+
12
+ const recorded = JSON.parse(readFileSync(new URL('./fixtures/codex-model-list-0.163.0.json', import.meta.url), 'utf8')) as {
13
+ pages: ReadonlyArray<{ data: ReadonlyArray<Record<string, unknown>>; nextCursor?: string | null }>;
14
+ };
15
+ const nothing = async () => undefined;
16
+
17
+ describe("the models the person's Codex offers", () => {
18
+ it('lists every page, in Codex order, and leaves no Codex running', async () => {
19
+ const codex = new FakeCodex(nothing, { modelPages: recorded.pages });
20
+
21
+ const models = await codexOfferedModels('/work/app', codex.start);
22
+
23
+ expect(models.map((m) => m.id)).toEqual([
24
+ 'gpt-6.1-sol',
25
+ 'gpt-6-astra',
26
+ 'gpt-6-sol',
27
+ 'gpt-6-luna',
28
+ 'gpt-5.6-sol',
29
+ 'gpt-5.6-terra',
30
+ 'gpt-5.6-luna',
31
+ ]);
32
+ expect(codex.last.sent('model/list')).toEqual([{}, { cursor: '4' }]);
33
+ expect(codex.last.closed).toBe(true);
34
+ });
35
+
36
+ it('describes each model as an agent model: tools, images, reasoning, and fast where Codex offers it', async () => {
37
+ const codex = new FakeCodex(nothing, { modelPages: recorded.pages });
38
+
39
+ const [sol] = await codexOfferedModels('/work/app', codex.start);
40
+
41
+ expect(sol).toMatchObject({ id: 'gpt-6.1-sol', supportsTools: true, supportsImages: true, supportsReasoning: true, supportsFast: true });
42
+ expect(sol?.contextWindow).toBe(500_000);
43
+ });
44
+
45
+ it('starts the GPT-6 models at the 272k window Codex runs them with by default', async () => {
46
+ const codex = new FakeCodex(nothing, { modelPages: recorded.pages });
47
+
48
+ const offered = await codexOfferedModels('/work/app', codex.start);
49
+
50
+ const gpt6 = offered.filter((model) => model.id.startsWith('gpt-6-'));
51
+ expect(gpt6.length).toBeGreaterThan(0);
52
+ expect(gpt6.map((model) => model.contextWindow)).toEqual(gpt6.map(() => 272_000));
53
+ });
54
+
55
+ it('gives a model Moxxy does not know a 400k window', async () => {
56
+ const [first] = recorded.pages;
57
+ if (!first) throw new Error('recording has no pages');
58
+ const codex = new FakeCodex(nothing, { modelPages: [{ data: [{ ...first.data[0], id: 'gpt-7-sol', model: 'gpt-7-sol' }], nextCursor: null }] });
59
+
60
+ const [next] = await codexOfferedModels('/work/app', codex.start);
61
+
62
+ expect(next).toMatchObject({ id: 'gpt-7-sol', contextWindow: 400_000, maxOutputTokens: 128_000, supportsTools: true });
63
+ });
64
+
65
+ it('leaves out models Codex hides from its picker', async () => {
66
+ const [first] = recorded.pages;
67
+ if (!first) throw new Error('recording has no pages');
68
+ const hidden = { ...first.data[0], id: 'gpt-internal', model: 'gpt-internal', hidden: true };
69
+ const codex = new FakeCodex(nothing, { modelPages: [{ data: [hidden, first.data[1]], nextCursor: null }] });
70
+
71
+ expect((await codexOfferedModels('/work/app', codex.start)).map((m) => m.id)).toEqual(['gpt-6-astra']);
72
+ });
73
+
74
+ it('fails when Codex cannot be started, so the last list stays', async () => {
75
+ const missing = async () => Promise.reject(new Error('spawn codex ENOENT'));
76
+
77
+ await expect(codexOfferedModels('/work/app', missing)).rejects.toThrow(/ENOENT/);
78
+ });
79
+
80
+ it('gives up on a Codex that does not answer', async () => {
81
+ const codex = new FakeCodex(nothing, { modelPages: recorded.pages });
82
+ codex.silent = true;
83
+
84
+ await expect(codexOfferedModels('/work/app', codex.start, { timeoutMs: 50 })).rejects.toThrow(/did not answer/);
85
+ expect(codex.last.closed).toBe(true);
86
+ });
87
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The models the person's Codex offers their account, as its own picker lists
3
+ * them: a short-lived `codex app-server` answers `model/list`, page by page,
4
+ * and is closed. Codex caches the list for a few minutes; the hourly check
5
+ * picks up a change after that.
6
+ */
7
+ import type { ModelDescriptor } from '@moxxy/sdk';
8
+ import { JsonRpcLineClient } from '@moxxy/agent-connection-kit';
9
+ import { codexAgentModels } from './agent-provider.js';
10
+ import type { StartCodexAppServer } from './codex-session.js';
11
+ import type { CodexModelList } from './protocol.js';
12
+
13
+ /** Codex answers in well under a second; one stuck on startup is not waited on longer. */
14
+ const OFFERED_MODELS_TIMEOUT_MS = 15_000;
15
+ /** `model/list` says nothing of the window; a model Moxxy does not know gets this one. */
16
+ const UNKNOWN_MODEL: Omit<ModelDescriptor, 'id'> = {
17
+ contextWindow: 400_000,
18
+ maxOutputTokens: 128_000,
19
+ supportsTools: true,
20
+ supportsStreaming: true,
21
+ supportsImages: true,
22
+ supportsDocuments: false,
23
+ supportsAudio: false,
24
+ supportsReasoning: true,
25
+ hostedTools: ['web_search'],
26
+ };
27
+
28
+ export async function codexOfferedModels(
29
+ cwd: string,
30
+ start: StartCodexAppServer,
31
+ opts: { readonly timeoutMs?: number } = {},
32
+ ): Promise<ReadonlyArray<ModelDescriptor>> {
33
+ let connection: Awaited<ReturnType<StartCodexAppServer>> | undefined;
34
+ let client: JsonRpcLineClient | undefined;
35
+ const timer = setTimeout(() => client?.close(new Error('Codex did not answer in time.')), opts.timeoutMs ?? OFFERED_MODELS_TIMEOUT_MS);
36
+ try {
37
+ connection = await start({ cwd });
38
+ client = new JsonRpcLineClient({ readable: connection.readable, writable: connection.writable });
39
+ await client.request('initialize', { clientInfo: { name: 'moxxy', title: 'Moxxy', version: '1' }, capabilities: { experimentalApi: true } });
40
+ client.notify('initialized');
41
+ const models: ModelDescriptor[] = [];
42
+ let cursor: string | null | undefined;
43
+ do {
44
+ const page: CodexModelList = await client.request<CodexModelList>('model/list', cursor ? { cursor } : {});
45
+ for (const model of page.data) if (!model.hidden) models.push(describe(model));
46
+ cursor = page.nextCursor;
47
+ } while (cursor);
48
+ return models;
49
+ } finally {
50
+ clearTimeout(timer);
51
+ client?.close();
52
+ connection?.close();
53
+ }
54
+ }
55
+
56
+ function describe(model: CodexModelList['data'][number]): ModelDescriptor {
57
+ const known = codexAgentModels.find((descriptor) => descriptor.id === model.id) ?? UNKNOWN_MODEL;
58
+ return {
59
+ ...known,
60
+ id: model.id,
61
+ supportsImages: model.inputModalities?.includes('image') ?? known.supportsImages ?? false,
62
+ supportsReasoning: (model.supportedReasoningEfforts?.length ?? 0) > 0,
63
+ supportsFast: model.additionalSpeedTiers?.includes('fast') ?? false,
64
+ };
65
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * How the person's own Codex runs, read as a conversation opens so its
3
+ * auto-approve is the truth before the first message: a short-lived
4
+ * `codex app-server` is asked for the folder's config and closed again,
5
+ * without starting a thread. Codex is the external boundary.
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import { FakeCodex } from './fake-codex.testkit.js';
9
+ import { codexOwnAutoApprove } from './own-settings.js';
10
+
11
+ const nothing = async () => undefined;
12
+
13
+ describe("the auto-approve of the person's own Codex", () => {
14
+ it('is on when their config runs Codex with no approvals and full access, and leaves no Codex running', async () => {
15
+ const codex = new FakeCodex(nothing);
16
+
17
+ expect(await codexOwnAutoApprove('/work/app', codex.start)).toBe(true);
18
+ expect(codex.last.cwd).toBe('/work/app');
19
+ expect(codex.last.sent('config/read')).toEqual([{ cwd: '/work/app' }]);
20
+ expect(codex.last.sent('thread/start')).toEqual([]);
21
+ expect(codex.last.closed).toBe(true);
22
+ });
23
+
24
+ it('is off when their config asks, or keeps Codex in a sandbox', async () => {
25
+ const asking = new FakeCodex(nothing, { approvalPolicy: 'on-request' });
26
+ const sandboxed = new FakeCodex(nothing, { sandbox: { type: 'readOnly', networkAccess: false } });
27
+
28
+ expect(await codexOwnAutoApprove('/work/app', asking.start)).toBe(false);
29
+ expect(await codexOwnAutoApprove('/work/app', sandboxed.start)).toBe(false);
30
+ });
31
+
32
+ it('is unknown when Codex cannot be started', async () => {
33
+ const missing = async () => Promise.reject(new Error('spawn codex ENOENT'));
34
+
35
+ expect(await codexOwnAutoApprove('/work/app', missing)).toBeUndefined();
36
+ });
37
+
38
+ it('is unknown, and closes Codex, when it does not answer in time', async () => {
39
+ const codex = new FakeCodex(nothing);
40
+ codex.silent = true;
41
+
42
+ expect(await codexOwnAutoApprove('/work/app', codex.start, { timeoutMs: 50 })).toBeUndefined();
43
+ expect(codex.last.closed).toBe(true);
44
+ });
45
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * How the person's own Codex runs, read without starting a thread: a
3
+ * short-lived `codex app-server` answers `config/read` for the folder (the
4
+ * effective config, profiles and project config included) and is closed.
5
+ */
6
+ import { JsonRpcLineClient } from '@moxxy/agent-connection-kit';
7
+ import type { StartCodexAppServer } from './codex-session.js';
8
+ import type { CodexConfigRead } from './protocol.js';
9
+
10
+ /** Codex answers in well under a second; one stuck on startup is not waited on longer. */
11
+ const OWN_SETTINGS_TIMEOUT_MS = 15_000;
12
+
13
+ /**
14
+ * Whether the person's own Codex runs in `cwd` with no approvals and full
15
+ * access, as Moxxy's auto-approve does; `undefined` when Codex cannot say.
16
+ */
17
+ export async function codexOwnAutoApprove(
18
+ cwd: string,
19
+ start: StartCodexAppServer,
20
+ opts: { readonly timeoutMs?: number } = {},
21
+ ): Promise<boolean | undefined> {
22
+ let connection: Awaited<ReturnType<StartCodexAppServer>> | undefined;
23
+ let client: JsonRpcLineClient | undefined;
24
+ const timer = setTimeout(() => client?.close(new Error('Codex did not answer in time.')), opts.timeoutMs ?? OWN_SETTINGS_TIMEOUT_MS);
25
+ try {
26
+ connection = await start({ cwd });
27
+ client = new JsonRpcLineClient({ readable: connection.readable, writable: connection.writable });
28
+ await client.request('initialize', { clientInfo: { name: 'moxxy', title: 'Moxxy', version: '1' }, capabilities: { experimentalApi: true } });
29
+ client.notify('initialized');
30
+ const { config } = await client.request<CodexConfigRead>('config/read', { cwd });
31
+ return config.approval_policy === 'never' && config.sandbox_mode === 'danger-full-access';
32
+ } catch {
33
+ return undefined;
34
+ } finally {
35
+ clearTimeout(timer);
36
+ client?.close();
37
+ connection?.close();
38
+ }
39
+ }