clilinkapi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -0
- package/clilinkapi.example.json +12 -0
- package/dist/src/auth.js +9 -0
- package/dist/src/cli.js +229 -0
- package/dist/src/config.js +98 -0
- package/dist/src/errors.js +24 -0
- package/dist/src/permissions.js +64 -0
- package/dist/src/providers/codex.js +201 -0
- package/dist/src/providers/registry.js +3 -0
- package/dist/src/providers/rpc.js +171 -0
- package/dist/src/providers/runtime.js +98 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/redaction.js +37 -0
- package/dist/src/requests.js +76 -0
- package/dist/src/sandbox.js +63 -0
- package/dist/src/server.js +164 -0
- package/dist/src/sessions.js +19 -0
- package/dist/src/startup.js +46 -0
- package/dist/src/tool-sessions.js +54 -0
- package/docs/architecture.md +13 -0
- package/docs/configuration.md +463 -0
- package/docs/n8n.md +43 -0
- package/docs/security.md +32 -0
- package/docs/verification.md +28 -0
- package/examples/client.mjs +13 -0
- package/package.json +22 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { inspectWorkspace } from '../config.js';
|
|
3
|
+
import { CliLinkAPIError, normalizeError } from '../errors.js';
|
|
4
|
+
import { profileArgs, probeIsolation, requireNativePlatform } from '../sandbox.js';
|
|
5
|
+
import { Rpc } from './rpc.js';
|
|
6
|
+
import { verifyRuntime, disabledSkillArgs } from './runtime.js';
|
|
7
|
+
import { ToolSessions } from '../tool-sessions.js';
|
|
8
|
+
const modelPage = z.object({ data: z.array(z.object({ model: z.string(), hidden: z.boolean(), supportedReasoningEfforts: z.array(z.object({ reasoningEffort: z.string() })), defaultReasoningEffort: z.string(), isDefault: z.boolean() })), nextCursor: z.string().nullable() });
|
|
9
|
+
const agentItem = z.object({ id: z.string(), type: z.literal('agentMessage'), text: z.string(), phase: z.enum(['commentary', 'final_answer']).nullable().optional() });
|
|
10
|
+
const usageSchema = z.object({ inputTokens: z.number().int().nonnegative(), outputTokens: z.number().int().nonnegative(), totalTokens: z.number().int().nonnegative() });
|
|
11
|
+
export class ResponseExtractor {
|
|
12
|
+
threadId;
|
|
13
|
+
finalIds = new Set();
|
|
14
|
+
streamed = new Map();
|
|
15
|
+
finalText;
|
|
16
|
+
usage;
|
|
17
|
+
constructor(threadId) {
|
|
18
|
+
this.threadId = threadId;
|
|
19
|
+
}
|
|
20
|
+
consume(event) {
|
|
21
|
+
const p = event.params;
|
|
22
|
+
if (p.threadId !== this.threadId)
|
|
23
|
+
return;
|
|
24
|
+
if (event.method === 'error')
|
|
25
|
+
throw normalizeError(new Error(JSON.stringify(p.error)));
|
|
26
|
+
if (event.method === 'item/started' || event.method === 'item/completed') {
|
|
27
|
+
const item = agentItem.safeParse(p.item);
|
|
28
|
+
if (!item.success)
|
|
29
|
+
return;
|
|
30
|
+
if (item.data.phase === 'final_answer') {
|
|
31
|
+
if (this.finalIds.size && !this.finalIds.has(item.data.id))
|
|
32
|
+
throw new CliLinkAPIError(502, 'ambiguous_final_response', 'Codex emitted more than one final-answer item.');
|
|
33
|
+
this.finalIds.add(item.data.id);
|
|
34
|
+
if (event.method === 'item/completed') {
|
|
35
|
+
if ((this.streamed.get(item.data.id) ?? '') !== item.data.text) {
|
|
36
|
+
// No fake token stream: final text is used only by non-streaming clients.
|
|
37
|
+
if (this.streamed.has(item.data.id))
|
|
38
|
+
throw new CliLinkAPIError(502, 'upstream_protocol', 'Codex final text disagrees with streamed deltas.');
|
|
39
|
+
}
|
|
40
|
+
this.finalText = item.data.text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Unlabelled historical runtimes cannot distinguish commentary safely.
|
|
44
|
+
}
|
|
45
|
+
if (event.method === 'item/agentMessage/delta') {
|
|
46
|
+
const delta = z.object({ itemId: z.string(), delta: z.string() }).parse(p);
|
|
47
|
+
if (!this.finalIds.has(delta.itemId))
|
|
48
|
+
return;
|
|
49
|
+
const text = (this.streamed.get(delta.itemId) ?? '') + delta.delta;
|
|
50
|
+
if (Buffer.byteLength(text) > 2 * 1024 * 1024)
|
|
51
|
+
throw new CliLinkAPIError(502, 'response_too_large', 'Codex response exceeded clilinkapi limits.');
|
|
52
|
+
this.streamed.set(delta.itemId, text);
|
|
53
|
+
return { type: 'delta', text: delta.delta };
|
|
54
|
+
}
|
|
55
|
+
if (event.method === 'thread/tokenUsage/updated') {
|
|
56
|
+
const result = z.object({ tokenUsage: z.object({ last: usageSchema }) }).parse(p).tokenUsage.last;
|
|
57
|
+
this.usage = { prompt_tokens: result.inputTokens, completion_tokens: result.outputTokens, total_tokens: result.totalTokens };
|
|
58
|
+
}
|
|
59
|
+
if (event.method === 'turn/completed') {
|
|
60
|
+
const turn = z.object({ turn: z.object({ status: z.string(), error: z.unknown().optional() }) }).parse(p).turn;
|
|
61
|
+
if (turn.status !== 'completed')
|
|
62
|
+
throw normalizeError(new Error(JSON.stringify(turn.error ?? turn.status)));
|
|
63
|
+
if (this.finalText === undefined)
|
|
64
|
+
throw new CliLinkAPIError(502, 'missing_final_response', 'Codex completed without a labelled final assistant response.');
|
|
65
|
+
return { type: 'complete', text: this.finalText, ...(this.usage ? { usage: this.usage } : {}) };
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export class CodexProvider {
|
|
71
|
+
config;
|
|
72
|
+
filename;
|
|
73
|
+
capabilities = { streaming: true, sessions: false };
|
|
74
|
+
active = new Set();
|
|
75
|
+
tools;
|
|
76
|
+
constructor(config, filename) {
|
|
77
|
+
this.config = config;
|
|
78
|
+
this.filename = filename;
|
|
79
|
+
this.tools = new ToolSessions(config.compatibility.maxPendingTools, config.compatibility.toolTimeoutMs, state => this.dispose(state.rpc));
|
|
80
|
+
}
|
|
81
|
+
async open(signal, args = []) {
|
|
82
|
+
await verifyRuntime(this.config.provider.codexHome);
|
|
83
|
+
const rpc = new Rpc(this.config.provider.codexHome, args);
|
|
84
|
+
this.active.add(rpc);
|
|
85
|
+
try {
|
|
86
|
+
await rpc.initialize(signal);
|
|
87
|
+
const account = z.object({ account: z.object({ type: z.string() }).nullable() }).parse(await rpc.request('account/read', { refreshToken: true }, signal));
|
|
88
|
+
if (account.account?.type !== 'chatgpt')
|
|
89
|
+
throw new CliLinkAPIError(503, 'upstream_authentication', 'Runtime requires ChatGPT sign-in. Run clilinkapi login as the clilinkapi runtime user.');
|
|
90
|
+
return rpc;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
await this.dispose(rpc);
|
|
94
|
+
throw normalizeError(error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async dispose(rpc) { await rpc.close(); this.active.delete(rpc); }
|
|
98
|
+
async models(signal) {
|
|
99
|
+
const rpc = await this.open(signal);
|
|
100
|
+
try {
|
|
101
|
+
const models = [];
|
|
102
|
+
let cursor = null;
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
do {
|
|
105
|
+
const page = modelPage.parse(await rpc.request('model/list', { limit: 100, includeHidden: false, cursor }, signal));
|
|
106
|
+
for (const item of page.data)
|
|
107
|
+
if (!item.hidden && (!this.config.provider.allowedModels.length || this.config.provider.allowedModels.includes(item.model)))
|
|
108
|
+
models.push({ id: item.model, efforts: item.supportedReasoningEfforts.map(e => e.reasoningEffort), defaultEffort: item.defaultReasoningEffort, isDefault: item.isDefault });
|
|
109
|
+
cursor = page.nextCursor;
|
|
110
|
+
if (cursor && (seen.has(cursor) || seen.size >= 20))
|
|
111
|
+
throw new Error('Invalid catalog pagination');
|
|
112
|
+
if (cursor)
|
|
113
|
+
seen.add(cursor);
|
|
114
|
+
} while (cursor);
|
|
115
|
+
return models;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw normalizeError(error);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await this.dispose(rpc);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async *generate(input) {
|
|
125
|
+
requireNativePlatform(this.config.provider.allowUnqualifiedWindowsExecution);
|
|
126
|
+
const resumed = this.tools.take(input);
|
|
127
|
+
if (!resumed && this.tools.hasWorkspace(input.workspace.path))
|
|
128
|
+
throw new CliLinkAPIError(409, 'workspace_busy', 'Workspace is waiting for an external tool result.');
|
|
129
|
+
if (resumed) {
|
|
130
|
+
let retained = false;
|
|
131
|
+
try {
|
|
132
|
+
resumed.value.rpc.replyTool(resumed.value.requestId, resumed.result);
|
|
133
|
+
for await (const event of this.continueTurn(input, resumed.value)) {
|
|
134
|
+
retained = event.type === 'tool_calls';
|
|
135
|
+
yield event;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (!retained)
|
|
140
|
+
await this.dispose(resumed.value.rpc);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const projectSkills = await inspectWorkspace(input.workspace, this.config.provider.allowSymbolicLinks);
|
|
145
|
+
const rpc = await this.open(input.signal, [...profileArgs(input.workspace, [this.filename, this.config.provider.codexHome]), ...disabledSkillArgs(this.config.provider.codexHome, projectSkills, this.config.provider.allowProjectSkills)]);
|
|
146
|
+
let threadId;
|
|
147
|
+
let turnId;
|
|
148
|
+
let retained = false;
|
|
149
|
+
try {
|
|
150
|
+
await probeIsolation(rpc, input.workspace, this.config.provider.codexHome, input.signal, this.config.provider.allowUnqualifiedWindowsExecution);
|
|
151
|
+
const dynamicTools = input.request?.tool_choice === 'none' ? [] : (input.request?.tools ?? []).map(t => ({ type: 'function', name: t.function.name, description: t.function.description ?? '', inputSchema: t.function.parameters }));
|
|
152
|
+
const response = z.object({ thread: z.object({ id: z.string() }), model: z.string(), approvalPolicy: z.string(), activePermissionProfile: z.object({ id: z.string() }) }).parse(await rpc.request('thread/start', { model: input.model, modelProvider: 'openai', allowProviderModelFallback: false, cwd: input.workspace.path, runtimeWorkspaceRoots: [input.workspace.path], permissions: 'clilinkapi', approvalPolicy: 'never', approvalsReviewer: 'user', ephemeral: true, environments: [], selectedCapabilityRoots: [], dynamicTools, developerInstructions: input.instructions || null }, input.signal));
|
|
153
|
+
if (response.model !== input.model || response.approvalPolicy !== 'never' || response.activePermissionProfile.id !== 'clilinkapi')
|
|
154
|
+
throw new CliLinkAPIError(503, 'runtime_policy_mismatch', 'Codex did not honor the requested model or permission policy.');
|
|
155
|
+
threadId = response.thread.id;
|
|
156
|
+
const turn = z.object({ turn: z.object({ id: z.string() }) }).parse(await rpc.request('turn/start', { threadId, input: [{ type: 'text', text: input.prompt, text_elements: [] }], model: input.model, effort: input.effort, approvalPolicy: 'never', approvalsReviewer: 'user' }, input.signal));
|
|
157
|
+
turnId = turn.turn.id;
|
|
158
|
+
const extractor = new ResponseExtractor(threadId);
|
|
159
|
+
for await (const event of this.continueTurn(input, { rpc, threadId, turnId, extractor, requestId: 0 })) {
|
|
160
|
+
retained = event.type === 'tool_calls';
|
|
161
|
+
yield event;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (input.signal.aborted)
|
|
166
|
+
throw new CliLinkAPIError(499, 'cancelled', 'Request cancelled.');
|
|
167
|
+
throw normalizeError(error);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
if (!retained) {
|
|
171
|
+
if (threadId && turnId)
|
|
172
|
+
await rpc.request('turn/interrupt', { threadId, turnId }, AbortSignal.timeout(1500)).catch(() => undefined);
|
|
173
|
+
await this.dispose(rpc);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async *continueTurn(input, state) {
|
|
178
|
+
while (true) {
|
|
179
|
+
const notification = await state.rpc.next(input.signal);
|
|
180
|
+
if (notification.method === 'item/tool/call') {
|
|
181
|
+
const call = z.object({ threadId: z.string(), turnId: z.string(), tool: z.string(), namespace: z.string().nullable().optional(), arguments: z.record(z.string(), z.unknown()) }).parse(notification.params);
|
|
182
|
+
if (notification.requestId === undefined || call.threadId !== state.threadId || call.turnId !== state.turnId || call.namespace || input.request?.tool_choice === 'none' || !input.request?.tools?.some(t => t.function.name === call.tool))
|
|
183
|
+
throw new CliLinkAPIError(502, 'unexpected_tool', 'Runtime requested an unregistered external tool.');
|
|
184
|
+
const args = JSON.stringify(call.arguments);
|
|
185
|
+
if (Buffer.byteLength(args) > 200000 || [this.config.auth.apiKey, this.config.provider.codexHome, this.filename].some(secret => args.includes(secret) || args.includes(JSON.stringify(secret).slice(1, -1))))
|
|
186
|
+
throw new CliLinkAPIError(502, 'unsafe_tool_arguments', 'External tool arguments exceeded output limits or contained private runtime data.');
|
|
187
|
+
const tool = this.tools.put(input, { ...state, requestId: notification.requestId }, call.tool, call.arguments);
|
|
188
|
+
yield { type: 'tool_calls', calls: [tool] };
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const event = state.extractor.consume(notification);
|
|
192
|
+
if (event) {
|
|
193
|
+
yield event;
|
|
194
|
+
if (event.type === 'complete')
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async close() { await this.tools.closeAll(); await Promise.all([...this.active].map(rpc => this.dispose(rpc))); }
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=codex.js.map
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { CliLinkAPIError } from '../errors.js';
|
|
5
|
+
import { codexBinary, runtimeEnv, hardeningArgs, disabledSkillArgs } from './runtime.js';
|
|
6
|
+
export class Rpc {
|
|
7
|
+
child;
|
|
8
|
+
sequence = 0;
|
|
9
|
+
buffer = '';
|
|
10
|
+
failure;
|
|
11
|
+
pending = new Map();
|
|
12
|
+
queue = [];
|
|
13
|
+
waiter;
|
|
14
|
+
closing = false;
|
|
15
|
+
constructor(home, extraArgs = []) {
|
|
16
|
+
this.child = spawn(codexBinary(), [...hardeningArgs, ...disabledSkillArgs(home), ...extraArgs, 'app-server', '--listen', 'stdio://'], { cwd: home, env: runtimeEnv(home), windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
17
|
+
this.child.stdout.setEncoding('utf8');
|
|
18
|
+
this.child.stdout.on('data', (chunk) => {
|
|
19
|
+
this.buffer += chunk;
|
|
20
|
+
if (Buffer.byteLength(this.buffer) > 4 * 1024 * 1024)
|
|
21
|
+
return this.fail(new CliLinkAPIError(502, 'upstream_overflow', 'Codex output exceeded protocol limits.'));
|
|
22
|
+
let end;
|
|
23
|
+
while ((end = this.buffer.indexOf('\n')) >= 0) {
|
|
24
|
+
const line = this.buffer.slice(0, end);
|
|
25
|
+
this.buffer = this.buffer.slice(end + 1);
|
|
26
|
+
try {
|
|
27
|
+
this.receive(JSON.parse(line));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
this.fail(new CliLinkAPIError(502, 'upstream_protocol', 'Malformed Codex protocol output.'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// Drain without logging credential-bearing upstream diagnostics.
|
|
35
|
+
this.child.stderr.resume();
|
|
36
|
+
this.child.stdin.on('error', () => this.fail(new CliLinkAPIError(502, 'upstream_closed', 'Codex transport closed.')));
|
|
37
|
+
this.child.on('error', () => this.fail(new CliLinkAPIError(503, 'runtime_unavailable', 'Could not start the native Codex runtime.')));
|
|
38
|
+
this.child.on('exit', () => { if (!this.closing)
|
|
39
|
+
this.fail(new CliLinkAPIError(502, 'upstream_closed', 'Codex exited before completing the request.')); });
|
|
40
|
+
}
|
|
41
|
+
fail(error) { this.failure ??= error; for (const p of this.pending.values()) {
|
|
42
|
+
clearTimeout(p.timer);
|
|
43
|
+
p.reject(error);
|
|
44
|
+
} this.pending.clear(); this.waiter?.(); }
|
|
45
|
+
receive(value) {
|
|
46
|
+
if (!value || typeof value !== 'object')
|
|
47
|
+
throw new Error('Invalid message');
|
|
48
|
+
const message = value;
|
|
49
|
+
if ('id' in message && typeof message.method === 'string') {
|
|
50
|
+
if (message.method === 'item/tool/call' && (typeof message.id === 'string' || typeof message.id === 'number')) {
|
|
51
|
+
if (this.queue.length >= 256)
|
|
52
|
+
throw new Error('Too many requests');
|
|
53
|
+
this.queue.push({ method: message.method, params: message.params, requestId: message.id });
|
|
54
|
+
this.waiter?.();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Only registered dynamic tools reach the provider; approvals and questions are denied.
|
|
58
|
+
this.child.stdin.write(JSON.stringify({ id: message.id, error: { code: -32601, message: 'CliLinkAPI denies interactive requests and escalation.' } }) + '\n');
|
|
59
|
+
this.fail(new CliLinkAPIError(403, 'approval_required', 'Execution requires permissions or interaction outside the configured policy.'));
|
|
60
|
+
}
|
|
61
|
+
else if (typeof message.id === 'number') {
|
|
62
|
+
const p = this.pending.get(message.id);
|
|
63
|
+
if (!p)
|
|
64
|
+
return;
|
|
65
|
+
clearTimeout(p.timer);
|
|
66
|
+
this.pending.delete(message.id);
|
|
67
|
+
if (message.error)
|
|
68
|
+
p.reject(new Error(JSON.stringify(message.error)));
|
|
69
|
+
else
|
|
70
|
+
p.resolve(message.result);
|
|
71
|
+
}
|
|
72
|
+
else if (typeof message.method === 'string') {
|
|
73
|
+
// Keep only execution notifications. No prompts/tools are retained by this transport.
|
|
74
|
+
if (!['item/started', 'item/completed', 'item/agentMessage/delta', 'turn/completed', 'thread/tokenUsage/updated', 'error'].includes(message.method))
|
|
75
|
+
return;
|
|
76
|
+
const params = (message.params ?? {});
|
|
77
|
+
if (message.method === 'item/started' || message.method === 'item/completed') {
|
|
78
|
+
const item = params.item;
|
|
79
|
+
if (item?.type !== 'agentMessage')
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.queue.length >= 256)
|
|
83
|
+
return this.fail(new CliLinkAPIError(502, 'upstream_overflow', 'Codex event queue exceeded its limit.'));
|
|
84
|
+
this.queue.push({ method: message.method, params: (message.params ?? {}) });
|
|
85
|
+
this.waiter?.();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async request(method, params, signal) {
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
if (this.failure)
|
|
91
|
+
throw this.failure;
|
|
92
|
+
const id = ++this.sequence;
|
|
93
|
+
const response = new Promise((resolve, reject) => {
|
|
94
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new CliLinkAPIError(504, 'upstream_timeout', 'Codex protocol request timed out.')); }, 30000);
|
|
95
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
96
|
+
});
|
|
97
|
+
const abort = () => { const p = this.pending.get(id); if (p) {
|
|
98
|
+
clearTimeout(p.timer);
|
|
99
|
+
this.pending.delete(id);
|
|
100
|
+
p.reject(new CliLinkAPIError(499, 'cancelled', 'Request cancelled.'));
|
|
101
|
+
} };
|
|
102
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
103
|
+
this.child.stdin.write(JSON.stringify({ method, params, id }) + '\n');
|
|
104
|
+
try {
|
|
105
|
+
return await response;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
signal.removeEventListener('abort', abort);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async initialize(signal) {
|
|
112
|
+
await this.request('initialize', { clientInfo: { name: 'clilinkapi', version: '0.1.0' }, capabilities: { experimentalApi: true } }, signal);
|
|
113
|
+
this.child.stdin.write('{"method":"initialized","params":{}}\n');
|
|
114
|
+
}
|
|
115
|
+
replyTool(id, text) {
|
|
116
|
+
if (this.failure)
|
|
117
|
+
throw this.failure;
|
|
118
|
+
this.child.stdin.write(JSON.stringify({ id, result: { contentItems: [{ type: 'inputText', text }], success: true } }) + '\n');
|
|
119
|
+
}
|
|
120
|
+
async next(signal) {
|
|
121
|
+
while (true) {
|
|
122
|
+
signal.throwIfAborted();
|
|
123
|
+
if (this.failure)
|
|
124
|
+
throw this.failure;
|
|
125
|
+
const event = this.queue.shift();
|
|
126
|
+
if (event)
|
|
127
|
+
return event;
|
|
128
|
+
await new Promise(resolve => { this.waiter = () => resolve(); signal.addEventListener('abort', this.waiter, { once: true }); }).finally(() => { if (this.waiter)
|
|
129
|
+
signal.removeEventListener('abort', this.waiter); this.waiter = undefined; });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async close() {
|
|
133
|
+
if (this.closing)
|
|
134
|
+
return;
|
|
135
|
+
this.closing = true;
|
|
136
|
+
this.fail(new CliLinkAPIError(499, 'cancelled', 'Execution closed.'));
|
|
137
|
+
const exited = this.child.exitCode !== null || this.child.signalCode !== null;
|
|
138
|
+
if (!exited) {
|
|
139
|
+
const exit = once(this.child, 'exit').catch(() => undefined);
|
|
140
|
+
if (process.platform !== 'win32' && this.child.pid) {
|
|
141
|
+
try {
|
|
142
|
+
process.kill(-this.child.pid, 'SIGTERM');
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
this.child.kill();
|
|
146
|
+
}
|
|
147
|
+
const timer = setTimeout(() => { try {
|
|
148
|
+
process.kill(-this.child.pid, 'SIGKILL');
|
|
149
|
+
}
|
|
150
|
+
catch { /* already gone */ } }, 1500);
|
|
151
|
+
await exit;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-this.child.pid, 'SIGKILL');
|
|
155
|
+
}
|
|
156
|
+
catch { /* no surviving descendants */ }
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
if (this.child.pid)
|
|
160
|
+
await promisify(execFile)('taskkill.exe', ['/PID', String(this.child.pid), '/T', '/F'], { windowsHide: true, timeout: 5000 }).catch(() => this.child.kill());
|
|
161
|
+
else
|
|
162
|
+
this.child.kill();
|
|
163
|
+
await exit;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
this.child.stdout.destroy();
|
|
167
|
+
this.child.stderr.destroy();
|
|
168
|
+
this.child.stdin.destroy();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=rpc.js.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { readdir, lstat } from 'node:fs/promises';
|
|
6
|
+
import { CliLinkAPIError } from '../errors.js';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
export const RUNTIME_VERSION = '0.155.0';
|
|
9
|
+
// Bundled by the pinned runtime itself, including during login/app-server startup.
|
|
10
|
+
const bundledSkills = ['imagegen', 'openai-docs', 'plugin-creator', 'review-agent', 'skill-creator', 'skill-installer'];
|
|
11
|
+
export function disabledSkillArgs(home, projectSkills = [], allowProjectSkills = false) {
|
|
12
|
+
// 0.155.0 matches the SKILL.md file, not its containing directory.
|
|
13
|
+
const paths = [...bundledSkills.map(name => path.join(home, 'skills', '.system', name, 'SKILL.md')), ...(allowProjectSkills ? [] : projectSkills)];
|
|
14
|
+
return ['-c', `skills.config=[${paths.map(filename => `{path=${JSON.stringify(filename)},enabled=false}`).join(',')}]`];
|
|
15
|
+
}
|
|
16
|
+
export async function verifyRuntimeHome(home) {
|
|
17
|
+
const reject = (entry) => { throw new CliLinkAPIError(503, 'runtime_configuration', `Use a dedicated Codex home with no custom config, hooks, plugins, rules or skills. Remove or relocate the blocked entry: ${entry}.`); };
|
|
18
|
+
for (const name of ['config.toml', 'AGENTS.md', 'AGENTS.override.md', 'rules', 'plugins', 'hooks.json']) {
|
|
19
|
+
try {
|
|
20
|
+
await lstat(path.join(home, name));
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT')
|
|
24
|
+
continue;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
reject(name);
|
|
28
|
+
}
|
|
29
|
+
for (const name of await readdir(home))
|
|
30
|
+
if ((await lstat(path.join(home, name))).isSymbolicLink())
|
|
31
|
+
reject(name);
|
|
32
|
+
const skills = path.join(home, 'skills');
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = await readdir(skills);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT')
|
|
39
|
+
return;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
for (const name of entries)
|
|
43
|
+
if (name !== '.system')
|
|
44
|
+
reject('skills/' + name);
|
|
45
|
+
if (!entries.length)
|
|
46
|
+
return;
|
|
47
|
+
const walk = async (directory) => {
|
|
48
|
+
const info = await lstat(directory);
|
|
49
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && info.nlink > 1))
|
|
50
|
+
reject('skills');
|
|
51
|
+
if (info.isDirectory())
|
|
52
|
+
for (const name of await readdir(directory))
|
|
53
|
+
await walk(path.join(directory, name));
|
|
54
|
+
else if (path.basename(directory).toLowerCase() === 'skill.md' && !bundledSkills.some(name => directory === path.join(skills, '.system', name, 'SKILL.md')))
|
|
55
|
+
reject('skills/.system');
|
|
56
|
+
};
|
|
57
|
+
await walk(path.join(skills, '.system'));
|
|
58
|
+
for (const name of await readdir(path.join(skills, '.system')))
|
|
59
|
+
if (!bundledSkills.includes(name) && name !== '.codex-system-skills.marker')
|
|
60
|
+
reject('skills/.system/' + name);
|
|
61
|
+
}
|
|
62
|
+
export function codexBinary() {
|
|
63
|
+
const target = { 'linux-x64': 'x86_64-unknown-linux-musl', 'linux-arm64': 'aarch64-unknown-linux-musl', 'darwin-x64': 'x86_64-apple-darwin', 'darwin-arm64': 'aarch64-apple-darwin', 'win32-x64': 'x86_64-pc-windows-msvc', 'win32-arm64': 'aarch64-pc-windows-msvc' };
|
|
64
|
+
const platform = `${process.platform}-${process.arch}`;
|
|
65
|
+
if (!target[platform])
|
|
66
|
+
throw new Error('This platform has no supported native Codex binary.');
|
|
67
|
+
return path.join(path.dirname(require.resolve(`@openai/codex-${platform}/package.json`)), 'vendor', target[platform], 'bin', process.platform === 'win32' ? 'codex.exe' : 'codex');
|
|
68
|
+
}
|
|
69
|
+
export function runtimeEnv(home) {
|
|
70
|
+
const env = { CODEX_HOME: home };
|
|
71
|
+
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'SystemDrive', 'COMSPEC', 'HOME', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'TMPDIR', 'TMP', 'TEMP', 'LANG'])
|
|
72
|
+
if (process.env[name])
|
|
73
|
+
env[name] = process.env[name];
|
|
74
|
+
return env;
|
|
75
|
+
}
|
|
76
|
+
export async function verifyRuntime(home) {
|
|
77
|
+
const { stdout } = await promisify(execFile)(codexBinary(), ['--version'], { env: runtimeEnv(home), timeout: 10000, windowsHide: true });
|
|
78
|
+
if (stdout.trim() !== `codex-cli ${RUNTIME_VERSION}`)
|
|
79
|
+
throw new CliLinkAPIError(503, 'runtime_version', 'Codex runtime version is not the audited pinned version.');
|
|
80
|
+
await verifyRuntimeHome(home);
|
|
81
|
+
}
|
|
82
|
+
export const hardeningArgs = [
|
|
83
|
+
'-c', 'forced_login_method="chatgpt"', '-c', 'model_provider="openai"',
|
|
84
|
+
'-c', 'approval_policy="never"', '-c', 'web_search="disabled"',
|
|
85
|
+
'-c', 'shell_environment_policy.inherit="none"',
|
|
86
|
+
'-c', 'features.apps=false', '-c', 'features.multi_agent=false',
|
|
87
|
+
'-c', 'features.js_repl=false', '-c', 'features.code_mode=false',
|
|
88
|
+
'-c', 'features.memories=false', '-c', 'features.hooks=false',
|
|
89
|
+
'-c', 'features.plugins=false', '-c', 'features.remote_plugin=false',
|
|
90
|
+
'-c', 'features.browser_use=false', '-c', 'features.browser_use_external=false',
|
|
91
|
+
'-c', 'features.computer_use=false', '-c', 'features.in_app_browser=false',
|
|
92
|
+
'-c', 'features.image_generation=false', '-c', 'features.view_image=false',
|
|
93
|
+
'-c', 'features.code_mode_host=false', '-c', 'features.multi_agent_v2=false',
|
|
94
|
+
'-c', 'features.shell_snapshot=false', '-c', 'features.workspace_dependencies=false',
|
|
95
|
+
'-c', 'features.skip_host_skill_discovery=true', '-c', 'features.skill_search=false',
|
|
96
|
+
'-c', 'features.skill_mcp_dependency_install=false', '-c', 'features.unbounded_connection_retries=false',
|
|
97
|
+
];
|
|
98
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Redaction applies equally to buffered and streamed text. Keep an unfinished word
|
|
2
|
+
// plus a suffix long enough to catch known secrets split across delta boundaries.
|
|
3
|
+
export class Redactor {
|
|
4
|
+
pending = '';
|
|
5
|
+
values;
|
|
6
|
+
constructor(values) { this.values = [...new Set(values.flatMap(v => [v, v.replaceAll('\\', '/'), v.replaceAll('\\', '\\\\')]))].sort((a, b) => b.length - a.length); }
|
|
7
|
+
clean(text) {
|
|
8
|
+
for (const value of this.values)
|
|
9
|
+
text = text.replaceAll(value, '[redacted]');
|
|
10
|
+
return text.replace(/(?:[A-Za-z]:[\\/]|(?<![:/\w])\/(?!\/))[^^\s"'`<>]*/g, '[host-path]');
|
|
11
|
+
}
|
|
12
|
+
push(text, final = false) {
|
|
13
|
+
this.pending += text;
|
|
14
|
+
if (final) {
|
|
15
|
+
const out = this.clean(this.pending);
|
|
16
|
+
this.pending = '';
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
const keep = Math.max(256, ...this.values.map(v => v.length));
|
|
20
|
+
if (this.pending.length <= keep)
|
|
21
|
+
return '';
|
|
22
|
+
const boundary = this.pending.lastIndexOf(' ', this.pending.length - keep);
|
|
23
|
+
if (boundary < 0)
|
|
24
|
+
return '';
|
|
25
|
+
// Avoid cutting a known secret containing whitespace.
|
|
26
|
+
let cut = boundary + 1;
|
|
27
|
+
for (const value of this.values) {
|
|
28
|
+
const start = this.pending.indexOf(value);
|
|
29
|
+
if (start >= 0 && start < cut && start + value.length > cut)
|
|
30
|
+
cut = start;
|
|
31
|
+
}
|
|
32
|
+
const out = this.clean(this.pending.slice(0, cut));
|
|
33
|
+
this.pending = this.pending.slice(cut);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=redaction.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { CliLinkAPIError } from './errors.js';
|
|
3
|
+
const name = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
|
|
4
|
+
const text = z.union([z.string().max(200000), z.array(z.strictObject({ type: z.literal('text'), text: z.string() })).max(256).transform(parts => parts.map(p => p.text).join(''))]);
|
|
5
|
+
export const toolCallSchema = z.strictObject({ id: z.string().min(1).max(256), type: z.literal('function'), function: z.strictObject({ name, arguments: z.string().max(200000) }) });
|
|
6
|
+
const message = z.discriminatedUnion('role', [
|
|
7
|
+
z.strictObject({ role: z.literal('system'), content: text }), z.strictObject({ role: z.literal('developer'), content: text }), z.strictObject({ role: z.literal('user'), content: text }),
|
|
8
|
+
z.strictObject({ role: z.literal('assistant'), content: text.nullable().optional(), tool_calls: z.array(toolCallSchema).min(1).max(32).optional() }),
|
|
9
|
+
z.strictObject({ role: z.literal('tool'), content: text, tool_call_id: z.string().min(1).max(256) })
|
|
10
|
+
]);
|
|
11
|
+
const tool = z.strictObject({ type: z.literal('function'), function: z.strictObject({ name, description: z.string().max(20000).optional(), parameters: z.record(z.string(), z.unknown()).default({ type: 'object', properties: {} }), strict: z.literal(false).nullable().optional() }) });
|
|
12
|
+
const requestSchema = z.strictObject({
|
|
13
|
+
model: z.string().min(1).optional(), reasoning_effort: z.string().optional(), stream: z.boolean().default(false), messages: z.array(message).min(1).max(256),
|
|
14
|
+
tools: z.array(tool).max(64).optional(), tool_choice: z.enum(['auto', 'none']).optional(), parallel_tool_calls: z.boolean().optional(), n: z.literal(1).optional(), stream_options: z.strictObject({ include_usage: z.boolean() }).optional(),
|
|
15
|
+
response_format: z.strictObject({ type: z.literal('text') }).optional(),
|
|
16
|
+
temperature: z.null().optional(), top_p: z.null().optional(), frequency_penalty: z.union([z.literal(0), z.null()]).optional(), presence_penalty: z.union([z.literal(0), z.null()]).optional(),
|
|
17
|
+
max_tokens: z.null().optional(), max_completion_tokens: z.null().optional(), stop: z.union([z.null(), z.array(z.never()).max(0)]).optional(), logprobs: z.union([z.literal(false), z.null()]).optional()
|
|
18
|
+
});
|
|
19
|
+
export function parseRequest(input) {
|
|
20
|
+
const parsed = requestSchema.safeParse(input);
|
|
21
|
+
if (!parsed.success)
|
|
22
|
+
throw new CliLinkAPIError(400, 'unsupported_request', 'Unsupported Chat Completions request. Use text messages, function tools, tool_choice auto/none, n=1. Leave temperature, top_p, token limits, strict tool schemas and response formats unset.');
|
|
23
|
+
if (!parsed.data.messages.some(m => m.role === 'user'))
|
|
24
|
+
throw new CliLinkAPIError(400, 'missing_user_message', 'At least one user message is required.');
|
|
25
|
+
const data = parsed.data;
|
|
26
|
+
if (new Set(data.tools?.map(t => t.function.name)).size !== (data.tools?.length ?? 0))
|
|
27
|
+
throw new CliLinkAPIError(400, 'duplicate_tool', 'Tool names must be unique.');
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const pending = new Set();
|
|
30
|
+
for (const m of data.messages) {
|
|
31
|
+
if (m.role === 'tool') {
|
|
32
|
+
if (!pending.delete(m.tool_call_id))
|
|
33
|
+
throw new CliLinkAPIError(400, 'invalid_tool_history', 'Tool results must match an unanswered tool call.');
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
if (pending.size)
|
|
37
|
+
throw new CliLinkAPIError(400, 'invalid_tool_history', 'Supply all tool results before continuing the conversation.');
|
|
38
|
+
if (m.role === 'assistant')
|
|
39
|
+
for (const call of m.tool_calls ?? []) {
|
|
40
|
+
if (seen.has(call.id))
|
|
41
|
+
throw new CliLinkAPIError(400, 'invalid_tool_history', 'Duplicate tool call ID.');
|
|
42
|
+
try {
|
|
43
|
+
const args = JSON.parse(call.function.arguments);
|
|
44
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
45
|
+
throw new Error();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new CliLinkAPIError(400, 'invalid_tool_arguments', 'Tool arguments must be a JSON object.');
|
|
49
|
+
}
|
|
50
|
+
seen.add(call.id);
|
|
51
|
+
pending.add(call.id);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (pending.size)
|
|
56
|
+
throw new CliLinkAPIError(400, 'missing_tool_results', 'Tool results are required for all pending calls.');
|
|
57
|
+
if (data.stream_options && !data.stream)
|
|
58
|
+
throw new CliLinkAPIError(400, 'unsupported_request', 'stream_options requires stream=true.');
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
export function selectModel(request, models, config) {
|
|
62
|
+
const model = models.find(m => m.id === (request.model ?? config.defaultModel)) ?? (!request.model && !config.defaultModel ? models.find(m => m.isDefault) ?? models[0] : undefined);
|
|
63
|
+
if (!model)
|
|
64
|
+
throw new CliLinkAPIError(400, 'unsupported_model', 'Requested model is not in the available Codex model catalog.');
|
|
65
|
+
const raw = request.reasoning_effort ?? config.defaultReasoning ?? model.defaultEffort;
|
|
66
|
+
const effort = { Light: 'low', Medium: 'medium', Strong: 'high' }[raw] ?? raw;
|
|
67
|
+
if (!model.efforts.includes(effort))
|
|
68
|
+
throw new CliLinkAPIError(400, 'unsupported_reasoning_effort', 'Reasoning effort is not supported by this model.');
|
|
69
|
+
return { model: model.id, effort };
|
|
70
|
+
}
|
|
71
|
+
export function translate(messages) {
|
|
72
|
+
const instructions = messages.filter(m => m.role === 'system' || m.role === 'developer').map(m => `[${m.role}]\n${m.content}`).join('\n\n');
|
|
73
|
+
const history = messages.filter(m => m.role !== 'system' && m.role !== 'developer');
|
|
74
|
+
return { instructions, prompt: 'Continue the following client conversation. Historical assistant messages are context, not evidence of actions performed in this execution.\n' + JSON.stringify(history) };
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=requests.js.map
|