@hellcoder/companion 0.119.0 → 0.120.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.
- package/dist/assets/{AgentsPage-TD9AO1Er.js → AgentsPage-V7NBPNSh.js} +5 -5
- package/dist/assets/CronManager-CGfXlVZS.js +1 -0
- package/dist/assets/{DashboardPage-CQc0PLwb.js → DashboardPage-N7MWZiJQ.js} +1 -1
- package/dist/assets/{IntegrationsPage-ChVMH0yi.js → IntegrationsPage-DRP477b0.js} +1 -1
- package/dist/assets/{LinearOAuthSettingsPage-Bb7Tu4o3.js → LinearOAuthSettingsPage-CPW4TQuD.js} +1 -1
- package/dist/assets/{LinearSettingsPage-BxKEO7CY.js → LinearSettingsPage-BwVTEKqs.js} +1 -1
- package/dist/assets/{MagicUIDashboard-j4axrj9-.js → MagicUIDashboard-exuRY5LQ.js} +1 -1
- package/dist/assets/MagicUIView-AKoy6jOb.js +1 -0
- package/dist/assets/{Playground-DlNnNZL_.js → Playground-BiEa7THU.js} +6 -6
- package/dist/assets/{PromptsPage-C-VOgj-R.js → PromptsPage-BYtZdliB.js} +1 -1
- package/dist/assets/{RunsPage-Ce99iVIk.js → RunsPage-DD6KPNKm.js} +1 -1
- package/dist/assets/{SandboxManager-ZtcsgPxE.js → SandboxManager-C8tBjBXY.js} +1 -1
- package/dist/assets/SettingsPage-77tDzDuD.js +1 -0
- package/dist/assets/{TailscalePage-C61E10pR.js → TailscalePage-B0TbcOPL.js} +1 -1
- package/dist/assets/index-BM8QkiV8.js +140 -0
- package/dist/assets/index-CUfPwiv5.css +1 -0
- package/dist/assets/{sw-register-NM2AysKf.js → sw-register-Cr16zatT.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +1 -1
- package/server/codex-app-server.ts +61 -0
- package/server/codex-auth.test.ts +254 -0
- package/server/codex-auth.ts +315 -0
- package/server/codex-models.ts +2 -43
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +62 -17
- package/server/protocol/codex-upstream/README.md +1 -1
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +52 -4
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +4 -1
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +1 -1
- package/server/routes/codex-auth-routes.test.ts +115 -0
- package/server/routes/codex-auth-routes.ts +49 -0
- package/server/routes.ts +2 -0
- package/dist/assets/CronManager-DQXJXJO2.js +0 -1
- package/dist/assets/MagicUIView-D6jNdxHM.js +0 -1
- package/dist/assets/SettingsPage-QEkLsk5w.js +0 -1
- package/dist/assets/index-CnVoJw43.js +0 -140
- package/dist/assets/index-DB85gIpd.css +0 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { PassThrough } from "node:stream";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the Codex ChatGPT device-code login manager.
|
|
7
|
+
*
|
|
8
|
+
* The manager drives a real `codex app-server` child process over newline
|
|
9
|
+
* delimited JSON-RPC, so these tests stub the spawn boundary with a fake child
|
|
10
|
+
* that speaks the same framing. That lets us assert the state machine
|
|
11
|
+
* (pending → success/error/canceled), idempotency, and the auth propagation
|
|
12
|
+
* behaviour without needing an authenticated Codex install.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const spawnMock = vi.fn();
|
|
16
|
+
const buildSpawnMock = vi.fn(() => ({ cmd: ["codex", "app-server"], env: {} }));
|
|
17
|
+
|
|
18
|
+
vi.mock("node:child_process", () => ({ spawn: spawnMock }));
|
|
19
|
+
vi.mock("./codex-app-server.js", () => ({ buildCodexAppServerSpawn: buildSpawnMock }));
|
|
20
|
+
|
|
21
|
+
/** A fake `codex app-server` child speaking NDJSON JSON-RPC. */
|
|
22
|
+
class FakeChild extends EventEmitter {
|
|
23
|
+
stdout = new PassThrough();
|
|
24
|
+
stdin: { write: (chunk: string) => boolean };
|
|
25
|
+
killed = false;
|
|
26
|
+
/** Every request the manager sent, parsed. */
|
|
27
|
+
sent: Array<{ method: string; id?: number; params?: unknown }> = [];
|
|
28
|
+
/** Response factories keyed by method; return undefined to stay silent. */
|
|
29
|
+
responders: Record<string, (params: unknown) => unknown> = {};
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
super();
|
|
33
|
+
this.stdin = {
|
|
34
|
+
write: (chunk: string) => {
|
|
35
|
+
for (const line of chunk.split("\n")) {
|
|
36
|
+
if (!line.trim()) continue;
|
|
37
|
+
const msg = JSON.parse(line);
|
|
38
|
+
this.sent.push(msg);
|
|
39
|
+
if (msg.method === "initialize") {
|
|
40
|
+
this.reply(msg.id, { userAgent: "test" });
|
|
41
|
+
} else if (this.responders[msg.method]) {
|
|
42
|
+
const result = this.responders[msg.method](msg.params);
|
|
43
|
+
if (result !== undefined && typeof msg.id === "number") this.reply(msg.id, result);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
reply(id: number, result: unknown): void {
|
|
52
|
+
// Async so the manager's promise handlers are already attached.
|
|
53
|
+
setImmediate(() => this.stdout.write(JSON.stringify({ id, result }) + "\n"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
notify(method: string, params: unknown): void {
|
|
57
|
+
this.stdout.write(JSON.stringify({ method, params }) + "\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
kill(): void {
|
|
61
|
+
this.killed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let child: FakeChild;
|
|
66
|
+
let auth: typeof import("./codex-auth.js");
|
|
67
|
+
|
|
68
|
+
/** Poll until `predicate` holds, so we don't rely on fixed sleeps. */
|
|
69
|
+
async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (predicate()) return;
|
|
73
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
74
|
+
}
|
|
75
|
+
throw new Error("Condition not met in time");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEVICE_CODE_RESULT = {
|
|
79
|
+
type: "chatgptDeviceCode",
|
|
80
|
+
loginId: "login-1",
|
|
81
|
+
userCode: "ABCD-1234",
|
|
82
|
+
verificationUrl: "https://auth.openai.com/codex/device",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
vi.resetModules();
|
|
87
|
+
child = new FakeChild();
|
|
88
|
+
spawnMock.mockReset();
|
|
89
|
+
spawnMock.mockImplementation(() => child);
|
|
90
|
+
buildSpawnMock.mockReturnValue({ cmd: ["codex", "app-server"], env: {} });
|
|
91
|
+
auth = await import("./codex-auth.js");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
auth._resetCodexAuthState();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("startDeviceLogin", () => {
|
|
99
|
+
it("returns the device code and enters the pending state", async () => {
|
|
100
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
101
|
+
|
|
102
|
+
const status = await auth.startDeviceLogin();
|
|
103
|
+
|
|
104
|
+
expect(status.state).toBe("pending");
|
|
105
|
+
expect(status.userCode).toBe("ABCD-1234");
|
|
106
|
+
expect(status.verificationUrl).toBe("https://auth.openai.com/codex/device");
|
|
107
|
+
// Device code must be requested explicitly — the plain `chatgpt` type would
|
|
108
|
+
// use a localhost OAuth callback, which breaks for a remote server.
|
|
109
|
+
const start = child.sent.find((m) => m.method === "account/login/start");
|
|
110
|
+
expect(start?.params).toEqual({ type: "chatgptDeviceCode" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("is idempotent while a login is in flight so the user's code stays valid", async () => {
|
|
114
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
115
|
+
|
|
116
|
+
const first = await auth.startDeviceLogin();
|
|
117
|
+
const second = await auth.startDeviceLogin();
|
|
118
|
+
|
|
119
|
+
expect(second).toEqual(first);
|
|
120
|
+
// Only one app-server spawned: a second spawn would orphan the first child
|
|
121
|
+
// and invalidate a code the user may already be typing.
|
|
122
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("transitions to success when Codex reports login completed", async () => {
|
|
126
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
127
|
+
await auth.startDeviceLogin();
|
|
128
|
+
|
|
129
|
+
child.notify("account/login/completed", { loginId: "login-1", success: true });
|
|
130
|
+
|
|
131
|
+
await until(() => auth.getLoginStatus().state === "success");
|
|
132
|
+
expect(child.killed).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("surfaces the upstream error message when login fails", async () => {
|
|
136
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
137
|
+
await auth.startDeviceLogin();
|
|
138
|
+
|
|
139
|
+
child.notify("account/login/completed", {
|
|
140
|
+
loginId: "login-1", success: false, error: "Login was not completed",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await until(() => auth.getLoginStatus().state === "error");
|
|
144
|
+
expect(auth.getLoginStatus().error).toBe("Login was not completed");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("ignores a completion notification for a superseded login id", async () => {
|
|
148
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
149
|
+
await auth.startDeviceLogin();
|
|
150
|
+
|
|
151
|
+
child.notify("account/login/completed", { loginId: "some-older-login", success: true });
|
|
152
|
+
|
|
153
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
154
|
+
expect(auth.getLoginStatus().state).toBe("pending");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("fails clearly when the CLI is too old to return a device code", async () => {
|
|
158
|
+
// An older Codex answers the RPC but without device-code fields.
|
|
159
|
+
child.responders["account/login/start"] = () => ({ type: "chatgpt", authUrl: "https://x" });
|
|
160
|
+
|
|
161
|
+
await expect(auth.startDeviceLogin()).rejects.toThrow(/device code/i);
|
|
162
|
+
expect(child.killed).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("reports an error when the codex binary cannot be resolved", async () => {
|
|
166
|
+
buildSpawnMock.mockReturnValue(null as never);
|
|
167
|
+
await expect(auth.startDeviceLogin()).rejects.toThrow(/not found/i);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("marks the login failed if the app-server dies before completing", async () => {
|
|
171
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
172
|
+
await auth.startDeviceLogin();
|
|
173
|
+
|
|
174
|
+
child.emit("exit", 1);
|
|
175
|
+
|
|
176
|
+
await until(() => auth.getLoginStatus().state === "error");
|
|
177
|
+
expect(auth.getLoginStatus().error).toMatch(/exited/i);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("cancelLogin", () => {
|
|
182
|
+
it("cancels an in-flight login and kills the child", async () => {
|
|
183
|
+
child.responders["account/login/start"] = () => DEVICE_CODE_RESULT;
|
|
184
|
+
await auth.startDeviceLogin();
|
|
185
|
+
|
|
186
|
+
const status = await auth.cancelLogin();
|
|
187
|
+
|
|
188
|
+
expect(status.state).toBe("canceled");
|
|
189
|
+
expect(child.killed).toBe(true);
|
|
190
|
+
// The terminal result is retained so a poll arriving after cancel sees it.
|
|
191
|
+
expect(auth.getLoginStatus().state).toBe("canceled");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("is a no-op when nothing is in flight", async () => {
|
|
195
|
+
expect((await auth.cancelLogin()).state).toBe("idle");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("getAccountStatus", () => {
|
|
200
|
+
it("reports a signed-in ChatGPT account with email and plan", async () => {
|
|
201
|
+
child.responders["account/read"] = () => ({
|
|
202
|
+
account: { type: "chatgpt", email: "user@example.com", planType: "pro" },
|
|
203
|
+
requiresOpenaiAuth: false,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const status = await auth.getAccountStatus();
|
|
207
|
+
|
|
208
|
+
expect(status).toMatchObject({
|
|
209
|
+
cliAvailable: true, authenticated: true, method: "chatgpt",
|
|
210
|
+
email: "user@example.com", planType: "pro",
|
|
211
|
+
});
|
|
212
|
+
expect(child.killed).toBe(true); // short-lived probe must not leak a process
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("distinguishes API-key auth from a ChatGPT subscription", async () => {
|
|
216
|
+
child.responders["account/read"] = () => ({ account: { type: "apiKey" }, requiresOpenaiAuth: false });
|
|
217
|
+
|
|
218
|
+
const status = await auth.getAccountStatus();
|
|
219
|
+
|
|
220
|
+
expect(status.method).toBe("apiKey");
|
|
221
|
+
expect(status.authenticated).toBe(true);
|
|
222
|
+
expect(status.email).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("reports unauthenticated when Codex has no account", async () => {
|
|
226
|
+
child.responders["account/read"] = () => ({ account: null, requiresOpenaiAuth: true });
|
|
227
|
+
|
|
228
|
+
const status = await auth.getAccountStatus();
|
|
229
|
+
|
|
230
|
+
expect(status.authenticated).toBe(false);
|
|
231
|
+
expect(status.method).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("reports the CLI as unavailable rather than throwing", async () => {
|
|
235
|
+
buildSpawnMock.mockReturnValue(null as never);
|
|
236
|
+
|
|
237
|
+
const status = await auth.getAccountStatus();
|
|
238
|
+
|
|
239
|
+
expect(status.cliAvailable).toBe(false);
|
|
240
|
+
expect(status.error).toMatch(/not found/i);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("logout", () => {
|
|
245
|
+
it("calls account/logout and reports success", async () => {
|
|
246
|
+
child.responders["account/logout"] = () => ({});
|
|
247
|
+
|
|
248
|
+
const res = await auth.logout();
|
|
249
|
+
|
|
250
|
+
expect(res.ok).toBe(true);
|
|
251
|
+
expect(child.sent.some((m) => m.method === "account/logout")).toBe(true);
|
|
252
|
+
expect(child.killed).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildCodexAppServerSpawn } from "./codex-app-server.js";
|
|
5
|
+
import { DEFAULT_COMPANION_CODEX_HOME, getLegacyCodexHome } from "./codex-home.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codex ChatGPT-subscription login, driven from the Companion UI.
|
|
9
|
+
*
|
|
10
|
+
* Why device code: the plain `chatgpt` login type returns an OAuth `authUrl`
|
|
11
|
+
* that redirects to a localhost callback owned by the Codex process. That only
|
|
12
|
+
* works when the browser and the CLI are on the same machine, which is exactly
|
|
13
|
+
* not the case for Companion (the whole point is driving a remote box from a
|
|
14
|
+
* browser). `chatgptDeviceCode` instead returns a short user code plus a
|
|
15
|
+
* verification URL, so the user can approve on whatever device their browser is
|
|
16
|
+
* on. This removes the need to SSH in and run `codex login` by hand.
|
|
17
|
+
*
|
|
18
|
+
* Lifecycle: `account/login/start` needs the same app-server process to stay
|
|
19
|
+
* alive until the login resolves, because Codex polls the device-code endpoint
|
|
20
|
+
* internally and reports the outcome via the `account/login/completed`
|
|
21
|
+
* notification. So we hold one child process for the duration of a login and
|
|
22
|
+
* expose a pollable status, following the same poll-a-status-endpoint pattern
|
|
23
|
+
* the image-pull and dashboard-run features already use.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Device codes are short-lived upstream; give up well before that silently. */
|
|
27
|
+
const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
|
28
|
+
/** Bound short-lived RPCs (account read/logout) so a wedged CLI can't hang a request. */
|
|
29
|
+
const RPC_TIMEOUT_MS = 15_000;
|
|
30
|
+
|
|
31
|
+
export type CodexLoginState = "idle" | "pending" | "success" | "error" | "canceled";
|
|
32
|
+
|
|
33
|
+
export interface CodexLoginStatus {
|
|
34
|
+
state: CodexLoginState;
|
|
35
|
+
/** The code the user types at `verificationUrl`. Present while pending. */
|
|
36
|
+
userCode?: string;
|
|
37
|
+
verificationUrl?: string;
|
|
38
|
+
loginId?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
/** Epoch ms after which this attempt is abandoned. */
|
|
41
|
+
expiresAt?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CodexAccountStatus {
|
|
45
|
+
/** False when the codex binary can't be resolved at all. */
|
|
46
|
+
cliAvailable: boolean;
|
|
47
|
+
authenticated: boolean;
|
|
48
|
+
/** How Codex is authenticated, when it is. */
|
|
49
|
+
method: "chatgpt" | "apiKey" | null;
|
|
50
|
+
email: string | null;
|
|
51
|
+
planType: string | null;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ActiveLogin {
|
|
56
|
+
child: ChildProcess;
|
|
57
|
+
status: CodexLoginStatus;
|
|
58
|
+
timer: ReturnType<typeof setTimeout>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let activeLogin: ActiveLogin | null = null;
|
|
62
|
+
/** Terminal result of the most recent attempt, so a poll after completion still sees it. */
|
|
63
|
+
let lastResult: CodexLoginStatus = { state: "idle" };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Env for auth operations. Pinned to the real Codex home (~/.codex) because
|
|
67
|
+
* that is the file cli-launcher seeds per-session Codex homes from, and what
|
|
68
|
+
* `hasContainerCodexAuth()` probes. Writing anywhere else would produce a login
|
|
69
|
+
* that appears to succeed but never reaches sessions.
|
|
70
|
+
*/
|
|
71
|
+
function authEnv(): Record<string, string | undefined> {
|
|
72
|
+
return { CODEX_HOME: getLegacyCodexHome() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Minimal newline-delimited JSON-RPC client over a spawned app-server. */
|
|
76
|
+
function createRpcClient(child: ChildProcess, onNotification?: (method: string, params: Record<string, unknown>) => void) {
|
|
77
|
+
let nextId = 1;
|
|
78
|
+
const pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
79
|
+
let buf = "";
|
|
80
|
+
|
|
81
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
82
|
+
buf += chunk.toString();
|
|
83
|
+
let idx: number;
|
|
84
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
85
|
+
const line = buf.slice(0, idx);
|
|
86
|
+
buf = buf.slice(idx + 1);
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let msg: { id?: number; result?: unknown; error?: { message?: string }; method?: string; params?: Record<string, unknown> };
|
|
89
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
90
|
+
|
|
91
|
+
if (typeof msg.id === "number" && (msg.result !== undefined || msg.error !== undefined)) {
|
|
92
|
+
const waiter = pending.get(msg.id);
|
|
93
|
+
if (waiter) {
|
|
94
|
+
pending.delete(msg.id);
|
|
95
|
+
if (msg.error) waiter.reject(new Error(msg.error.message || "Codex RPC error"));
|
|
96
|
+
else waiter.resolve(msg.result);
|
|
97
|
+
}
|
|
98
|
+
} else if (msg.method) {
|
|
99
|
+
onNotification?.(msg.method, msg.params || {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const send = (obj: Record<string, unknown>) => {
|
|
105
|
+
try { child.stdin?.write(JSON.stringify(obj) + "\n"); } catch { /* pipe closed */ }
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const call = (method: string, params?: unknown, timeoutMs = RPC_TIMEOUT_MS): Promise<unknown> =>
|
|
109
|
+
new Promise((resolve, reject) => {
|
|
110
|
+
const id = nextId++;
|
|
111
|
+
const timer = setTimeout(() => {
|
|
112
|
+
pending.delete(id);
|
|
113
|
+
reject(new Error(`Codex RPC "${method}" timed out`));
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
pending.set(id, {
|
|
116
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
117
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
118
|
+
});
|
|
119
|
+
send({ method, id, params: params ?? {} });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const rejectAll = (err: Error) => {
|
|
123
|
+
for (const [, waiter] of pending) waiter.reject(err);
|
|
124
|
+
pending.clear();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return { call, notify: (method: string, params?: unknown) => send({ method, params: params ?? {} }), rejectAll };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Spawn an app-server and complete the `initialize`/`initialized` handshake. */
|
|
131
|
+
async function startAppServer(onNotification?: (m: string, p: Record<string, unknown>) => void) {
|
|
132
|
+
const plan = buildCodexAppServerSpawn("codex", authEnv());
|
|
133
|
+
if (!plan) throw new Error("Codex CLI not found on PATH");
|
|
134
|
+
|
|
135
|
+
const child = spawn(plan.cmd[0], plan.cmd.slice(1), { env: plan.env, stdio: ["pipe", "pipe", "ignore"] });
|
|
136
|
+
const rpc = createRpcClient(child, onNotification);
|
|
137
|
+
|
|
138
|
+
child.on("error", (e) => rpc.rejectAll(e instanceof Error ? e : new Error(String(e))));
|
|
139
|
+
child.on("exit", () => rpc.rejectAll(new Error("Codex app-server exited")));
|
|
140
|
+
|
|
141
|
+
await rpc.call("initialize", {
|
|
142
|
+
clientInfo: { name: "thecompanion", title: "The Companion", version: "1.0.0" },
|
|
143
|
+
capabilities: { experimentalApi: true },
|
|
144
|
+
});
|
|
145
|
+
rpc.notify("initialized", {});
|
|
146
|
+
return { child, rpc };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function killChild(child: ChildProcess): void {
|
|
150
|
+
try { child.kill(); } catch { /* already dead */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* After a successful login, push the fresh auth.json into Codex homes that were
|
|
155
|
+
* already materialised for existing sessions. cli-launcher only seeds auth.json
|
|
156
|
+
* when the destination is absent, so without this an existing session would
|
|
157
|
+
* keep using the stale (or missing) credentials it was created with.
|
|
158
|
+
*/
|
|
159
|
+
export function propagateAuthToSessionHomes(): number {
|
|
160
|
+
const src = join(getLegacyCodexHome(), "auth.json");
|
|
161
|
+
if (!existsSync(src)) return 0;
|
|
162
|
+
|
|
163
|
+
let copied = 0;
|
|
164
|
+
let entries: string[];
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(DEFAULT_COMPANION_CODEX_HOME);
|
|
167
|
+
} catch {
|
|
168
|
+
return 0; // No per-session homes yet.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const dir = join(DEFAULT_COMPANION_CODEX_HOME, entry);
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(dir, { recursive: true });
|
|
175
|
+
copyFileSync(src, join(dir, "auth.json"));
|
|
176
|
+
copied++;
|
|
177
|
+
} catch {
|
|
178
|
+
// Best effort: one unwritable session home must not fail the login.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return copied;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Current login status (in-flight attempt, else the last terminal result). */
|
|
185
|
+
export function getLoginStatus(): CodexLoginStatus {
|
|
186
|
+
return activeLogin ? { ...activeLogin.status } : { ...lastResult };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function finishLogin(state: CodexLoginState, error?: string): void {
|
|
190
|
+
if (!activeLogin) return;
|
|
191
|
+
clearTimeout(activeLogin.timer);
|
|
192
|
+
killChild(activeLogin.child);
|
|
193
|
+
lastResult = { ...activeLogin.status, state, error };
|
|
194
|
+
delete lastResult.expiresAt;
|
|
195
|
+
activeLogin = null;
|
|
196
|
+
if (state === "success") propagateAuthToSessionHomes();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Begin a ChatGPT device-code login. Idempotent while one is in flight: a
|
|
201
|
+
* second call returns the same code rather than orphaning the first child
|
|
202
|
+
* process and invalidating a code the user may already be typing.
|
|
203
|
+
*/
|
|
204
|
+
export async function startDeviceLogin(): Promise<CodexLoginStatus> {
|
|
205
|
+
if (activeLogin) return { ...activeLogin.status };
|
|
206
|
+
|
|
207
|
+
const { child, rpc } = await startAppServer((method, params) => {
|
|
208
|
+
if (method !== "account/login/completed") return;
|
|
209
|
+
if (!activeLogin) return;
|
|
210
|
+
// Ignore results for a superseded attempt.
|
|
211
|
+
const loginId = typeof params.loginId === "string" ? params.loginId : undefined;
|
|
212
|
+
if (loginId && activeLogin.status.loginId && loginId !== activeLogin.status.loginId) return;
|
|
213
|
+
|
|
214
|
+
if (params.success === true) finishLogin("success");
|
|
215
|
+
else finishLogin("error", typeof params.error === "string" ? params.error : "Login was not completed");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
let result: { loginId?: string; userCode?: string; verificationUrl?: string };
|
|
219
|
+
try {
|
|
220
|
+
result = (await rpc.call("account/login/start", { type: "chatgptDeviceCode" })) as typeof result;
|
|
221
|
+
} catch (e) {
|
|
222
|
+
killChild(child);
|
|
223
|
+
throw e;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!result?.userCode || !result?.verificationUrl) {
|
|
227
|
+
killChild(child);
|
|
228
|
+
throw new Error("Codex did not return a device code. Update the Codex CLI and try again.");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const expiresAt = Date.now() + LOGIN_TIMEOUT_MS;
|
|
232
|
+
activeLogin = {
|
|
233
|
+
child,
|
|
234
|
+
status: {
|
|
235
|
+
state: "pending",
|
|
236
|
+
loginId: result.loginId,
|
|
237
|
+
userCode: result.userCode,
|
|
238
|
+
verificationUrl: result.verificationUrl,
|
|
239
|
+
expiresAt,
|
|
240
|
+
},
|
|
241
|
+
timer: setTimeout(() => finishLogin("error", "Login timed out. Start a new login and try again."), LOGIN_TIMEOUT_MS),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// A child that dies before completing means the login can never resolve.
|
|
245
|
+
child.on("exit", () => {
|
|
246
|
+
if (activeLogin?.child === child) finishLogin("error", "Codex exited before the login completed");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return { ...activeLogin.status };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Cancel an in-flight login, telling Codex to stop polling before we kill it. */
|
|
253
|
+
export async function cancelLogin(): Promise<CodexLoginStatus> {
|
|
254
|
+
if (!activeLogin) return { ...lastResult };
|
|
255
|
+
finishLogin("canceled");
|
|
256
|
+
return { ...lastResult };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Read the current Codex account, spawning a short-lived app-server. */
|
|
260
|
+
export async function getAccountStatus(): Promise<CodexAccountStatus> {
|
|
261
|
+
const base: CodexAccountStatus = {
|
|
262
|
+
cliAvailable: true, authenticated: false, method: null, email: null, planType: null,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
if (!buildCodexAppServerSpawn("codex", authEnv())) {
|
|
266
|
+
return { ...base, cliAvailable: false, error: "Codex CLI not found on PATH" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let child: ChildProcess | null = null;
|
|
270
|
+
try {
|
|
271
|
+
const started = await startAppServer();
|
|
272
|
+
child = started.child;
|
|
273
|
+
const res = (await started.rpc.call("account/read", {})) as {
|
|
274
|
+
account?: { type?: string; email?: string; planType?: string } | null;
|
|
275
|
+
};
|
|
276
|
+
const account = res?.account;
|
|
277
|
+
if (!account?.type) return base;
|
|
278
|
+
return {
|
|
279
|
+
...base,
|
|
280
|
+
authenticated: true,
|
|
281
|
+
method: account.type === "apiKey" ? "apiKey" : "chatgpt",
|
|
282
|
+
email: account.email ?? null,
|
|
283
|
+
planType: account.planType ?? null,
|
|
284
|
+
};
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return { ...base, error: e instanceof Error ? e.message : String(e) };
|
|
287
|
+
} finally {
|
|
288
|
+
if (child) killChild(child);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Sign the current Codex account out. */
|
|
293
|
+
export async function logout(): Promise<{ ok: boolean; error?: string }> {
|
|
294
|
+
let child: ChildProcess | null = null;
|
|
295
|
+
try {
|
|
296
|
+
const started = await startAppServer();
|
|
297
|
+
child = started.child;
|
|
298
|
+
await started.rpc.call("account/logout", {});
|
|
299
|
+
return { ok: true };
|
|
300
|
+
} catch (e) {
|
|
301
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
302
|
+
} finally {
|
|
303
|
+
if (child) killChild(child);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Test helper: drop all in-memory login state. */
|
|
308
|
+
export function _resetCodexAuthState(): void {
|
|
309
|
+
if (activeLogin) {
|
|
310
|
+
clearTimeout(activeLogin.timer);
|
|
311
|
+
killChild(activeLogin.child);
|
|
312
|
+
}
|
|
313
|
+
activeLogin = null;
|
|
314
|
+
lastResult = { state: "idle" };
|
|
315
|
+
}
|
package/server/codex-models.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { getEnrichedPath, resolveBinary } from "./path-resolver.js";
|
|
2
|
+
import { buildCodexAppServerSpawn } from "./codex-app-server.js";
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* A Codex model as surfaced to the frontend model picker.
|
|
@@ -70,45 +68,6 @@ export function _resetCodexModelsCache(): void {
|
|
|
70
68
|
modelsCache.clear();
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
/**
|
|
74
|
-
* Build the spawn command for `codex app-server`, resolving the binary and
|
|
75
|
-
* applying the same sibling-node shim that cli-launcher uses so a CLI shipped
|
|
76
|
-
* with a bundled node still launches correctly.
|
|
77
|
-
*/
|
|
78
|
-
function buildCodexSpawn(binaryName: string): { cmd: string[]; env: NodeJS.ProcessEnv } | null {
|
|
79
|
-
const resolved = resolveBinary(binaryName);
|
|
80
|
-
if (!resolved) return null;
|
|
81
|
-
|
|
82
|
-
const binaryDir = resolve(resolved, "..");
|
|
83
|
-
const siblingNode = join(binaryDir, "node");
|
|
84
|
-
const enrichedPath = getEnrichedPath();
|
|
85
|
-
const pathSep = process.platform === "win32" ? ";" : ":";
|
|
86
|
-
const spawnPath = [binaryDir, ...enrichedPath.split(pathSep)].filter(Boolean).join(pathSep);
|
|
87
|
-
|
|
88
|
-
// `model/list` does not require auth or a sandbox, so we skip --enable flags
|
|
89
|
-
// and run a bare app-server purely for the handshake + listing.
|
|
90
|
-
const args = ["app-server"];
|
|
91
|
-
|
|
92
|
-
let cmd: string[];
|
|
93
|
-
if (existsSync(siblingNode)) {
|
|
94
|
-
let codexScript: string;
|
|
95
|
-
try {
|
|
96
|
-
codexScript = realpathSync(resolved);
|
|
97
|
-
} catch {
|
|
98
|
-
codexScript = resolved;
|
|
99
|
-
}
|
|
100
|
-
cmd = [siblingNode, codexScript, ...args];
|
|
101
|
-
} else {
|
|
102
|
-
const isCmdScript = process.platform === "win32" && (resolved.endsWith(".cmd") || resolved.endsWith(".bat"));
|
|
103
|
-
cmd = isCmdScript ? ["cmd.exe", "/c", resolved, ...args] : [resolved, ...args];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
cmd,
|
|
108
|
-
env: { ...process.env, CLAUDECODE: undefined, PATH: spawnPath },
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
71
|
/**
|
|
113
72
|
* Fetch the list of available Codex models by briefly launching
|
|
114
73
|
* `codex app-server`, performing the initialize handshake, and calling the
|
|
@@ -128,7 +87,7 @@ export async function fetchCodexModels(opts: { binary?: string; timeoutMs?: numb
|
|
|
128
87
|
return cached.models;
|
|
129
88
|
}
|
|
130
89
|
|
|
131
|
-
const spawnInfo =
|
|
90
|
+
const spawnInfo = buildCodexAppServerSpawn(binaryName);
|
|
132
91
|
if (!spawnInfo) return [];
|
|
133
92
|
|
|
134
93
|
return new Promise<CodexModelOption[]>((resolvePromise) => {
|