@bridge4dev/runner 0.11.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/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/adapters/claude.d.ts +19 -0
- package/dist/adapters/claude.js +631 -0
- package/dist/adapters/codex-home.d.ts +61 -0
- package/dist/adapters/codex-home.js +234 -0
- package/dist/adapters/codex-protocol.d.ts +59 -0
- package/dist/adapters/codex-protocol.js +204 -0
- package/dist/adapters/codex.d.ts +61 -0
- package/dist/adapters/codex.js +1406 -0
- package/dist/adapters/types.d.ts +183 -0
- package/dist/adapters/types.js +5 -0
- package/dist/async-queue.d.ts +11 -0
- package/dist/async-queue.js +50 -0
- package/dist/attachments.d.ts +72 -0
- package/dist/attachments.js +149 -0
- package/dist/auth-relay.d.ts +57 -0
- package/dist/auth-relay.js +289 -0
- package/dist/config.d.ts +96 -0
- package/dist/config.js +73 -0
- package/dist/fsview.d.ts +20 -0
- package/dist/fsview.js +122 -0
- package/dist/git.d.ts +54 -0
- package/dist/git.js +168 -0
- package/dist/gitops.d.ts +136 -0
- package/dist/gitops.js +596 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +352 -0
- package/dist/journal.d.ts +118 -0
- package/dist/journal.js +300 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +33 -0
- package/dist/policy.d.ts +17 -0
- package/dist/policy.js +272 -0
- package/dist/protocol.d.ts +754 -0
- package/dist/protocol.js +154 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +221 -0
- package/dist/status-file.d.ts +14 -0
- package/dist/status-file.js +29 -0
- package/dist/supervisor.d.ts +216 -0
- package/dist/supervisor.js +1648 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/ws-client.d.ts +30 -0
- package/dist/ws-client.js +171 -0
- package/package.json +52 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { stateDir } from '../paths.js';
|
|
5
|
+
import { log } from '../log.js';
|
|
6
|
+
// A dedicated CODEX_HOME for the runner. This is a security control, not tidiness.
|
|
7
|
+
//
|
|
8
|
+
// The host user's ~/.codex/config.toml is theirs to write, and in the wild it
|
|
9
|
+
// contains exactly the settings we must never inherit — the owner's own file on
|
|
10
|
+
// our dogfood server has `approval_policy = "never"`,
|
|
11
|
+
// `sandbox_mode = "danger-full-access"` and a live DevBridge MCP key in
|
|
12
|
+
// plaintext inside a URL. Inheriting it would give every client session full
|
|
13
|
+
// disk access with zero approval prompts, using someone else's key.
|
|
14
|
+
//
|
|
15
|
+
// Verified live: with CODEX_HOME pointed here, `config/read` reports exactly two
|
|
16
|
+
// layers (ours + the always-present system file) and the user's config is not
|
|
17
|
+
// among them.
|
|
18
|
+
const CONFIG_FILENAME = 'config.toml';
|
|
19
|
+
const AUTH_FILENAME = 'auth.json';
|
|
20
|
+
/**
|
|
21
|
+
* Records how this home is meant to be authenticated. Without it a plain
|
|
22
|
+
* auth.json is ambiguous — it could be our own device login, or codex having
|
|
23
|
+
* replaced our symlink. A MISSING marker always reads as `own`: treating an
|
|
24
|
+
* unknown file as a link would let us move somebody's real credential around.
|
|
25
|
+
*/
|
|
26
|
+
const MODE_FILENAME = '.devbridge-auth-mode';
|
|
27
|
+
/**
|
|
28
|
+
* Minimal config. Deliberately has NO `[mcp_servers]` section: a per-thread
|
|
29
|
+
* config overlay MERGES with the home config at the map-key level (verified
|
|
30
|
+
* live), so anything declared here would leak into every session's thread and
|
|
31
|
+
* could not be removed by the overlay.
|
|
32
|
+
*/
|
|
33
|
+
const CONFIG_BODY = [
|
|
34
|
+
'# Managed by @bridge4dev/runner — do not edit.',
|
|
35
|
+
'# Sessions get their approval policy, sandbox and MCP servers per thread;',
|
|
36
|
+
'# these are only the fallbacks for a thread that sets nothing.',
|
|
37
|
+
'approval_policy = "on-request"',
|
|
38
|
+
'sandbox_mode = "workspace-write"',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
export function codexHomePath() {
|
|
42
|
+
return path.join(stateDir(), 'codex-home');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Throwaway home for a device-code login. The flow runs here and is promoted
|
|
46
|
+
* into the real home only on success, so an abandoned or timed-out sign-in
|
|
47
|
+
* cannot destroy a credential that was working.
|
|
48
|
+
*/
|
|
49
|
+
export function stagingCodexHomePath() {
|
|
50
|
+
return path.join(stateDir(), 'codex-login');
|
|
51
|
+
}
|
|
52
|
+
function readMode(dir) {
|
|
53
|
+
try {
|
|
54
|
+
const value = fs.readFileSync(path.join(dir, MODE_FILENAME), 'utf8').trim();
|
|
55
|
+
return value === 'link' || value === 'own' ? value : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function writeMode(dir, mode) {
|
|
62
|
+
try {
|
|
63
|
+
fs.writeFileSync(path.join(dir, MODE_FILENAME), mode, { mode: 0o600 });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
log.warn('codex: could not record the auth mode', { error: String(error) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Create (or refresh) the runner's CODEX_HOME and return it.
|
|
71
|
+
*
|
|
72
|
+
* `auth: 'link'` (default) symlinks the host user's `~/.codex/auth.json` so the
|
|
73
|
+
* runner uses their ChatGPT subscription and — importantly — shares one
|
|
74
|
+
* credential store with their own CLI, so a token refresh on either side keeps
|
|
75
|
+
* both working. `auth: 'own'` leaves the home unauthenticated until a
|
|
76
|
+
* device-code login writes into it, which is the choice for full isolation.
|
|
77
|
+
*
|
|
78
|
+
* A home under the OS temp dir still works, but codex then refuses to install
|
|
79
|
+
* its helper binaries ("Refusing to create helper binaries under temporary
|
|
80
|
+
* dir") — which can quietly break the agent's shell tooling, so it is worth a
|
|
81
|
+
* warning rather than a failure.
|
|
82
|
+
*/
|
|
83
|
+
export function ensureCodexHome(options = {}) {
|
|
84
|
+
const dir = codexHomePath();
|
|
85
|
+
if (isUnderTempDir(dir)) {
|
|
86
|
+
log.warn('codex: CODEX_HOME is under the temp dir — codex will skip its helper binaries', {
|
|
87
|
+
path: dir,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
91
|
+
// The directory carries secrets: the per-thread config overlay (MCP key
|
|
92
|
+
// included) is persisted inside codex's own sqlite logs.
|
|
93
|
+
fs.chmodSync(dir, 0o700);
|
|
94
|
+
const configFile = path.join(dir, CONFIG_FILENAME);
|
|
95
|
+
const current = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : null;
|
|
96
|
+
if (current !== CONFIG_BODY) {
|
|
97
|
+
fs.writeFileSync(configFile, CONFIG_BODY, { mode: 0o600 });
|
|
98
|
+
// `mode` only applies when the file is CREATED — an existing file keeps its
|
|
99
|
+
// old, possibly wider permissions (QA-100 MINOR-8).
|
|
100
|
+
fs.chmodSync(configFile, 0o600);
|
|
101
|
+
}
|
|
102
|
+
return { path: dir, auth: ensureAuth(dir, options) };
|
|
103
|
+
}
|
|
104
|
+
function ensureAuth(dir, options) {
|
|
105
|
+
const target = path.join(dir, AUTH_FILENAME);
|
|
106
|
+
const userAuth = path.join(options.homedir ?? os.homedir(), '.codex', AUTH_FILENAME);
|
|
107
|
+
const recorded = readMode(dir);
|
|
108
|
+
// The marker is the persisted decision. Callers that do not name a mode —
|
|
109
|
+
// the per-session repair, the health probe — must inherit it, or an
|
|
110
|
+
// isolated home would be re-linked to the host credential by the next probe
|
|
111
|
+
// that happens to run.
|
|
112
|
+
const mode = options.auth ?? recorded ?? 'link';
|
|
113
|
+
const existing = lstatOrNull(target);
|
|
114
|
+
// A real file is a usable credential, full stop. We do NOT try to be clever
|
|
115
|
+
// and push it back into the host user's ~/.codex: that file is theirs, we
|
|
116
|
+
// cannot know which copy is newer, and getting it wrong costs them their
|
|
117
|
+
// login. If codex replaced our symlink with its own atomic write, the
|
|
118
|
+
// credential it wrote is right here — keep using it, and record that this
|
|
119
|
+
// home now owns its login so we never overwrite it later.
|
|
120
|
+
if (existing?.isFile()) {
|
|
121
|
+
if (recorded === 'link') {
|
|
122
|
+
log.warn("codex: auth.json is no longer a shared link — keeping it as this runner's own", {
|
|
123
|
+
path: target,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
writeMode(dir, 'own');
|
|
127
|
+
return 'own';
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'own') {
|
|
130
|
+
if (existing?.isSymbolicLink())
|
|
131
|
+
fs.rmSync(target, { force: true });
|
|
132
|
+
writeMode(dir, 'own');
|
|
133
|
+
return 'missing';
|
|
134
|
+
}
|
|
135
|
+
if (existing?.isSymbolicLink()) {
|
|
136
|
+
// Repoint if the user's file moved or the link went stale.
|
|
137
|
+
const resolved = fs.readlinkSync(target);
|
|
138
|
+
if (resolved === userAuth && fs.existsSync(userAuth)) {
|
|
139
|
+
writeMode(dir, 'link');
|
|
140
|
+
return 'linked';
|
|
141
|
+
}
|
|
142
|
+
fs.rmSync(target, { force: true });
|
|
143
|
+
}
|
|
144
|
+
if (!fs.existsSync(userAuth))
|
|
145
|
+
return 'missing';
|
|
146
|
+
try {
|
|
147
|
+
fs.symlinkSync(userAuth, target);
|
|
148
|
+
writeMode(dir, 'link');
|
|
149
|
+
// Loud on purpose: the link going missing under a live daemon is exactly
|
|
150
|
+
// the failure that made the panel report "login expired" about a perfectly
|
|
151
|
+
// valid credential, and nothing recorded it. Now it does.
|
|
152
|
+
if (!existing) {
|
|
153
|
+
log.info('codex: linked the host auth.json into the isolated home', { path: target });
|
|
154
|
+
}
|
|
155
|
+
return 'linked';
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
log.warn('codex: could not link the host auth.json', { error: String(error) });
|
|
159
|
+
return 'missing';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Cheap re-assert of the home's credential, safe to call on every session start
|
|
164
|
+
* and before every probe. Does NOT rewrite the config or touch codex's caches —
|
|
165
|
+
* it only answers "is the credential still where we left it, and if not, can we
|
|
166
|
+
* put it back". This is what makes a credential disappearing under a running
|
|
167
|
+
* daemon self-healing instead of permanent.
|
|
168
|
+
*/
|
|
169
|
+
export function repairCodexAuth(options = {}) {
|
|
170
|
+
const dir = codexHomePath();
|
|
171
|
+
if (!fs.existsSync(dir))
|
|
172
|
+
return ensureCodexHome(options);
|
|
173
|
+
return { path: dir, auth: ensureAuth(dir, options) };
|
|
174
|
+
}
|
|
175
|
+
function isUnderTempDir(dir) {
|
|
176
|
+
try {
|
|
177
|
+
const tmp = fs.realpathSync(os.tmpdir());
|
|
178
|
+
return path.resolve(dir).startsWith(tmp + path.sep);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function lstatOrNull(file) {
|
|
185
|
+
try {
|
|
186
|
+
return fs.lstatSync(file);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Drop a linked auth.json so a device-code login writes our own file instead.
|
|
194
|
+
*
|
|
195
|
+
* Only ever called AFTER a login has actually succeeded in the staging home —
|
|
196
|
+
* never as a pre-step. Detaching first meant an abandoned or timed-out sign-in
|
|
197
|
+
* left the server permanently "not signed in", recoverable only by restarting
|
|
198
|
+
* the daemon.
|
|
199
|
+
*/
|
|
200
|
+
export function detachLinkedAuth(dir = codexHomePath()) {
|
|
201
|
+
const target = path.join(dir, AUTH_FILENAME);
|
|
202
|
+
if (lstatOrNull(target)?.isSymbolicLink())
|
|
203
|
+
fs.rmSync(target, { force: true });
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Promote a credential produced by a staging login into the real home, and
|
|
207
|
+
* record that this home now owns its own login.
|
|
208
|
+
*/
|
|
209
|
+
export function adoptLoginResult(stagingDir, dir = codexHomePath()) {
|
|
210
|
+
const source = path.join(stagingDir, AUTH_FILENAME);
|
|
211
|
+
if (!lstatOrNull(source)?.isFile())
|
|
212
|
+
return false;
|
|
213
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
214
|
+
detachLinkedAuth(dir);
|
|
215
|
+
const target = path.join(dir, AUTH_FILENAME);
|
|
216
|
+
fs.copyFileSync(source, target);
|
|
217
|
+
fs.chmodSync(target, 0o600);
|
|
218
|
+
writeMode(dir, 'own');
|
|
219
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** Seed a throwaway home for the device-code flow (config only, no credential). */
|
|
223
|
+
export function prepareStagingHome() {
|
|
224
|
+
const dir = stagingCodexHomePath();
|
|
225
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
226
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
fs.writeFileSync(path.join(dir, CONFIG_FILENAME), CONFIG_BODY, { mode: 0o600 });
|
|
228
|
+
return dir;
|
|
229
|
+
}
|
|
230
|
+
/** Remove the staging home whatever the outcome — it may hold a credential. */
|
|
231
|
+
export function discardStagingHome() {
|
|
232
|
+
fs.rmSync(stagingCodexHomePath(), { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=codex-home.js.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface RpcErrorBody {
|
|
2
|
+
code: number;
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
5
|
+
export declare class RpcError extends Error {
|
|
6
|
+
readonly code: number;
|
|
7
|
+
readonly method: string;
|
|
8
|
+
constructor(code: number, message: string, method: string);
|
|
9
|
+
}
|
|
10
|
+
export interface ServerRequest {
|
|
11
|
+
id: number | string;
|
|
12
|
+
method: string;
|
|
13
|
+
params: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface AppServerOptions {
|
|
16
|
+
command: string;
|
|
17
|
+
args: string[];
|
|
18
|
+
cwd?: string;
|
|
19
|
+
env: Record<string, string>;
|
|
20
|
+
onNotification: (method: string, params: Record<string, unknown>) => void;
|
|
21
|
+
onServerRequest: (request: ServerRequest) => void;
|
|
22
|
+
/** Fired once when the child is gone — the session's end-of-stream signal. */
|
|
23
|
+
onExit: (info: {
|
|
24
|
+
code: number | null;
|
|
25
|
+
signal: string | null;
|
|
26
|
+
}) => void;
|
|
27
|
+
onStderr?: (text: string) => void;
|
|
28
|
+
requestTimeoutMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export declare class AppServerClient {
|
|
31
|
+
private readonly opts;
|
|
32
|
+
private readonly child;
|
|
33
|
+
private readonly pending;
|
|
34
|
+
private nextId;
|
|
35
|
+
private stdoutBuffer;
|
|
36
|
+
private exited;
|
|
37
|
+
constructor(opts: AppServerOptions);
|
|
38
|
+
get pid(): number | undefined;
|
|
39
|
+
get alive(): boolean;
|
|
40
|
+
private finish;
|
|
41
|
+
private failAll;
|
|
42
|
+
private onStdout;
|
|
43
|
+
private onLine;
|
|
44
|
+
private writeLine;
|
|
45
|
+
request<T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
46
|
+
notify(method: string, params?: Record<string, unknown>): void;
|
|
47
|
+
respond(id: number | string, result: Record<string, unknown>): void;
|
|
48
|
+
respondError(id: number | string, code: number, message: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Graceful stop: closing stdin is a clean shutdown path (exit 0 within a few
|
|
51
|
+
* seconds, verified live), with SIGTERM as the follow-up and SIGKILL as the
|
|
52
|
+
* backstop. The child does NOT die just because we go away.
|
|
53
|
+
*/
|
|
54
|
+
kill(): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function asRecord(value: unknown): Record<string, unknown>;
|
|
57
|
+
export declare function str(value: unknown): string | undefined;
|
|
58
|
+
export declare function num(value: unknown): number | undefined;
|
|
59
|
+
//# sourceMappingURL=codex-protocol.d.ts.map
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { log } from '../log.js';
|
|
3
|
+
export class RpcError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
method;
|
|
6
|
+
constructor(code, message, method) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.method = method;
|
|
10
|
+
this.name = 'RpcError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
14
|
+
/** A line longer than this means the far end is misbehaving — drop the buffer. */
|
|
15
|
+
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
export class AppServerClient {
|
|
17
|
+
opts;
|
|
18
|
+
child;
|
|
19
|
+
pending = new Map();
|
|
20
|
+
nextId = 1;
|
|
21
|
+
stdoutBuffer = '';
|
|
22
|
+
exited = false;
|
|
23
|
+
constructor(opts) {
|
|
24
|
+
this.opts = opts;
|
|
25
|
+
this.child = spawn(opts.command, opts.args, {
|
|
26
|
+
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
27
|
+
env: opts.env,
|
|
28
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
this.child.stdout.setEncoding('utf8');
|
|
31
|
+
this.child.stdout.on('data', (chunk) => this.onStdout(chunk));
|
|
32
|
+
this.child.stderr.setEncoding('utf8');
|
|
33
|
+
this.child.stderr.on('data', (chunk) => opts.onStderr?.(chunk));
|
|
34
|
+
this.child.on('error', (error) => {
|
|
35
|
+
// Spawn failure (binary missing) surfaces here, not as a JSON-RPC error.
|
|
36
|
+
this.failAll(error);
|
|
37
|
+
this.finish({ code: null, signal: null });
|
|
38
|
+
});
|
|
39
|
+
this.child.on('exit', (code, signal) => {
|
|
40
|
+
this.failAll(new Error(`codex app-server exited (code ${code ?? 'null'})`));
|
|
41
|
+
this.finish({ code, signal });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
get pid() {
|
|
45
|
+
return this.child.pid;
|
|
46
|
+
}
|
|
47
|
+
get alive() {
|
|
48
|
+
return !this.exited;
|
|
49
|
+
}
|
|
50
|
+
finish(info) {
|
|
51
|
+
if (this.exited)
|
|
52
|
+
return;
|
|
53
|
+
this.exited = true;
|
|
54
|
+
this.opts.onExit(info);
|
|
55
|
+
}
|
|
56
|
+
failAll(error) {
|
|
57
|
+
for (const [id, entry] of [...this.pending]) {
|
|
58
|
+
this.pending.delete(id);
|
|
59
|
+
clearTimeout(entry.timer);
|
|
60
|
+
entry.reject(error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
onStdout(chunk) {
|
|
64
|
+
this.stdoutBuffer += chunk;
|
|
65
|
+
if (this.stdoutBuffer.length > MAX_LINE_BYTES) {
|
|
66
|
+
log.warn('codex: stdout line over cap, dropping buffer');
|
|
67
|
+
this.stdoutBuffer = '';
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let newline = this.stdoutBuffer.indexOf('\n');
|
|
71
|
+
while (newline !== -1) {
|
|
72
|
+
const line = this.stdoutBuffer.slice(0, newline).trim();
|
|
73
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1);
|
|
74
|
+
if (line)
|
|
75
|
+
this.onLine(line);
|
|
76
|
+
newline = this.stdoutBuffer.indexOf('\n');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
onLine(line) {
|
|
80
|
+
let message;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(line);
|
|
83
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
84
|
+
return;
|
|
85
|
+
message = parsed;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Non-JSON chatter on stdout (banners, warnings) is not fatal.
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const hasId = message['id'] !== undefined && message['id'] !== null;
|
|
92
|
+
const method = typeof message['method'] === 'string' ? message['method'] : null;
|
|
93
|
+
const params = asRecord(message['params']);
|
|
94
|
+
if (method && hasId) {
|
|
95
|
+
this.opts.onServerRequest({
|
|
96
|
+
id: message['id'],
|
|
97
|
+
method,
|
|
98
|
+
params,
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (method) {
|
|
103
|
+
this.opts.onNotification(method, params);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!hasId)
|
|
107
|
+
return;
|
|
108
|
+
const entry = this.pending.get(message['id']);
|
|
109
|
+
if (!entry)
|
|
110
|
+
return; // late reply to a timed-out request
|
|
111
|
+
this.pending.delete(message['id']);
|
|
112
|
+
clearTimeout(entry.timer);
|
|
113
|
+
const error = asRecord(message['error']);
|
|
114
|
+
if (message['error'] !== undefined && message['error'] !== null) {
|
|
115
|
+
entry.reject(new RpcError(typeof error['code'] === 'number' ? error['code'] : -1, typeof error['message'] === 'string' ? error['message'] : 'unknown error', entry.method));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
entry.resolve(message['result']);
|
|
119
|
+
}
|
|
120
|
+
writeLine(payload) {
|
|
121
|
+
if (this.exited)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
this.child.stdin.write(JSON.stringify(payload) + '\n');
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
log.warn('codex: write failed', { error: String(error) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
request(method, params, timeoutMs = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
131
|
+
if (this.exited)
|
|
132
|
+
return Promise.reject(new Error('codex app-server is gone'));
|
|
133
|
+
const id = this.nextId++;
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
this.pending.delete(id);
|
|
137
|
+
reject(new RpcError(-32000, `${method} timed out after ${timeoutMs}ms`, method));
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
timer.unref();
|
|
140
|
+
this.pending.set(id, {
|
|
141
|
+
resolve: resolve,
|
|
142
|
+
reject,
|
|
143
|
+
method,
|
|
144
|
+
timer,
|
|
145
|
+
});
|
|
146
|
+
// `jsonrpc` is sent for correctness on our side even though the server
|
|
147
|
+
// does not echo it back.
|
|
148
|
+
this.writeLine({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
notify(method, params) {
|
|
152
|
+
this.writeLine({ jsonrpc: '2.0', method, ...(params ? { params } : {}) });
|
|
153
|
+
}
|
|
154
|
+
respond(id, result) {
|
|
155
|
+
this.writeLine({ jsonrpc: '2.0', id, result });
|
|
156
|
+
}
|
|
157
|
+
respondError(id, code, message) {
|
|
158
|
+
this.writeLine({ jsonrpc: '2.0', id, error: { code, message } });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Graceful stop: closing stdin is a clean shutdown path (exit 0 within a few
|
|
162
|
+
* seconds, verified live), with SIGTERM as the follow-up and SIGKILL as the
|
|
163
|
+
* backstop. The child does NOT die just because we go away.
|
|
164
|
+
*/
|
|
165
|
+
kill() {
|
|
166
|
+
if (this.exited)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
this.child.stdin.end();
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// already closed
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
this.child.kill('SIGTERM');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// already gone
|
|
179
|
+
}
|
|
180
|
+
const hard = setTimeout(() => {
|
|
181
|
+
if (!this.exited) {
|
|
182
|
+
try {
|
|
183
|
+
this.child.kill('SIGKILL');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// already gone
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}, 5_000);
|
|
190
|
+
hard.unref();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function asRecord(value) {
|
|
194
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
195
|
+
? value
|
|
196
|
+
: {};
|
|
197
|
+
}
|
|
198
|
+
export function str(value) {
|
|
199
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
200
|
+
}
|
|
201
|
+
export function num(value) {
|
|
202
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
203
|
+
}
|
|
204
|
+
//# sourceMappingURL=codex-protocol.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type CodexHome } from './codex-home.js';
|
|
2
|
+
import { AppServerClient, type ServerRequest } from './codex-protocol.js';
|
|
3
|
+
import { type AgentAdapter, type AgentSession, type EffortOption, type SessionSpec } from './types.js';
|
|
4
|
+
export interface CodexAdapterDeps {
|
|
5
|
+
/** Injected in tests to drive a scripted app-server. */
|
|
6
|
+
spawnClient?: (options: {
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
onNotification: (method: string, params: Record<string, unknown>) => void;
|
|
9
|
+
onServerRequest: (request: ServerRequest) => void;
|
|
10
|
+
onExit: (info: {
|
|
11
|
+
code: number | null;
|
|
12
|
+
signal: string | null;
|
|
13
|
+
}) => void;
|
|
14
|
+
onStderr: (text: string) => void;
|
|
15
|
+
}) => AppServerClient;
|
|
16
|
+
/**
|
|
17
|
+
* Injected in tests. Production leaves it unset on purpose: the home has to
|
|
18
|
+
* be re-evaluated on EVERY session, not frozen at daemon boot. A boot-time
|
|
19
|
+
* snapshot is how a credential that vanished mid-day kept being reported as
|
|
20
|
+
* "linked" while every session failed with "login expired".
|
|
21
|
+
*/
|
|
22
|
+
codexHome?: CodexHome;
|
|
23
|
+
/**
|
|
24
|
+
* Re-assert the credential. Defaults to the real filesystem repair; tests
|
|
25
|
+
* that inject `codexHome` get no repair at all, so the injected value stays
|
|
26
|
+
* authoritative and nothing touches the host's ~/.codex.
|
|
27
|
+
*/
|
|
28
|
+
repairHome?: () => CodexHome;
|
|
29
|
+
/**
|
|
30
|
+
* How this runner's CODEX_HOME is authenticated (`[codex] auth` in
|
|
31
|
+
* config.toml). Carried here because every repair has to honour it — a
|
|
32
|
+
* repair that defaults to `link` would undo an owner's decision to keep the
|
|
33
|
+
* runner's credential separate.
|
|
34
|
+
*/
|
|
35
|
+
authMode?: 'link' | 'own';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Codex's own wording for "the sign-in did not work".
|
|
39
|
+
*
|
|
40
|
+
* A bare `401`/`unauthorized` is deliberately NOT here: those appear in MCP
|
|
41
|
+
* server errors and proxy failures too, and matching them turned unrelated
|
|
42
|
+
* problems into "your Codex login expired". A 401 only counts when the same
|
|
43
|
+
* message also talks about tokens or signing in — and never when it names MCP.
|
|
44
|
+
*/
|
|
45
|
+
export declare function isAuthError(message: string): boolean;
|
|
46
|
+
/** The dashboard shows "label — description"; the wire wants the label. */
|
|
47
|
+
export declare function stripOptionHint(text: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* `supportedReasoningEfforts` from `model/list`: a list of
|
|
50
|
+
* `{reasoningEffort, description}`. Codex 0.145 ships six levels on the Sol
|
|
51
|
+
* line (low…ultra) and four on the older models, so the labels are derived,
|
|
52
|
+
* never enumerated on our side.
|
|
53
|
+
*/
|
|
54
|
+
export declare function readEfforts(value: unknown): EffortOption[];
|
|
55
|
+
export declare class CodexAdapter implements AgentAdapter {
|
|
56
|
+
private readonly deps;
|
|
57
|
+
readonly id: "codex";
|
|
58
|
+
constructor(deps?: CodexAdapterDeps);
|
|
59
|
+
startSession(spec: SessionSpec): AgentSession;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=codex.d.ts.map
|