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.
@@ -0,0 +1,63 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { writeFile, unlink, symlink, access } from 'node:fs/promises';
4
+ import { CliLinkAPIError } from './errors.js';
5
+ export function profileArgs(workspace, secrets) {
6
+ const fs = { ':root': 'deny', ':minimal': 'read', [workspace.path]: workspace.access === 'read-only' ? 'read' : 'write', [process.execPath]: 'read' };
7
+ for (const secret of secrets)
8
+ fs[secret] = 'deny';
9
+ for (const name of ['.git', '.codex', '.agents'])
10
+ fs[path.join(workspace.path, name)] = 'read';
11
+ const table = Object.entries(fs).map(([key, value]) => `${JSON.stringify(key)}=${JSON.stringify(value)}`).join(',');
12
+ // Explicitly skip project config layers while allowing their files to exist.
13
+ return ['-c', `projects={${JSON.stringify(workspace.path)}={trust_level="untrusted"}}`, '-c', 'default_permissions="clilinkapi"', '-c', `permissions.clilinkapi.filesystem={${table}}`, '-c', 'permissions.clilinkapi.network.enabled=false'];
14
+ }
15
+ export function requireNativePlatform(allowUnqualifiedWindowsExecution = false, platform = process.platform) {
16
+ // Opt-in permits attempts without claiming Windows isolation is qualified.
17
+ if (platform === 'win32') {
18
+ if (allowUnqualifiedWindowsExecution)
19
+ return;
20
+ throw new CliLinkAPIError(503, 'native_isolation_unavailable', 'Native Windows agent execution is disabled: restricted-token and read-boundary enforcement have not passed qualification. To attempt execution without qualification checks, explicitly set provider.allowUnqualifiedWindowsExecution=true.');
21
+ }
22
+ if (!['linux', 'darwin'].includes(platform))
23
+ throw new CliLinkAPIError(503, 'native_isolation_unavailable', 'No qualified native sandbox for this platform.');
24
+ }
25
+ export async function probeIsolation(rpc, workspace, home, signal, allowUnqualifiedWindowsExecution = false) {
26
+ requireNativePlatform(allowUnqualifiedWindowsExecution);
27
+ if (process.platform === 'win32' && allowUnqualifiedWindowsExecution)
28
+ return;
29
+ const nonce = randomUUID();
30
+ const local = path.join(workspace.path, `.clilinkapi-probe-${nonce}`);
31
+ const outside = path.join(home, `.clilinkapi-probe-${nonce}`);
32
+ const escaped = path.join(workspace.path, `.clilinkapi-link-${nonce}`);
33
+ const output = path.join(workspace.path, `.clilinkapi-write-${nonce}`);
34
+ // Fresh harmless sentinels only; never read real credentials in a probe.
35
+ const cleanup = [local, outside, escaped, output];
36
+ try {
37
+ await writeFile(local, nonce, { flag: 'wx', mode: 0o600 });
38
+ await writeFile(outside, nonce, { flag: 'wx', mode: 0o600 });
39
+ await symlink(outside, escaped);
40
+ const probe = `const f=require('node:fs');const c=require('node:child_process');const [local,outside,link,output,mode,nonce]=process.argv.slice(1);let ok=f.readFileSync(local,'utf8')===nonce;for(const p of [outside,link]){try{f.readFileSync(p);ok=false}catch(e){if(!['EACCES','EPERM','ENOENT'].includes(e.code))ok=false}try{f.appendFileSync(p,'x');ok=false}catch(e){if(!['EACCES','EPERM','ENOENT'].includes(e.code))ok=false}}let wrote=false;try{f.writeFileSync(output,'probe');wrote=true}catch(e){if(!['EACCES','EPERM','EROFS'].includes(e.code))ok=false}ok=ok&&(wrote===(mode==='read-write'));const child=c.spawnSync(process.execPath,['-e',"try{require('node:fs').readFileSync(process.argv[1]);process.exit(9)}catch(e){process.exit(['EACCES','EPERM','ENOENT'].includes(e.code)?0:8)}",outside]);ok=ok&&child.status===0;process.stdout.write(ok?'ISOLATION_OK':'ISOLATION_FAILED');process.exit(ok?0:7);`;
41
+ const result = await rpc.request('command/exec', { command: [process.execPath, '-e', probe, local, outside, escaped, output, workspace.access, nonce], cwd: workspace.path, permissionProfile: 'clilinkapi', timeoutMs: 15000, outputBytesCap: 4096 }, signal);
42
+ if (result.exitCode !== 0 || result.stdout !== 'ISOLATION_OK')
43
+ throw new Error('probe failed');
44
+ if (workspace.access === 'read-only') {
45
+ try {
46
+ await access(output);
47
+ throw new Error('read-only probe wrote');
48
+ }
49
+ catch (e) {
50
+ if (!(e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT'))
51
+ throw e;
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ throw new CliLinkAPIError(503, 'native_isolation_unavailable', 'Native sandbox read, write, symlink or subprocess isolation probe failed. Install native sandbox prerequisites; no agent turn was started.');
57
+ }
58
+ finally {
59
+ for (const filename of cleanup)
60
+ await unlink(filename).catch(() => undefined);
61
+ }
62
+ }
63
+ //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1,164 @@
1
+ import http from 'node:http';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { once } from 'node:events';
4
+ import os from 'node:os';
5
+ import { authenticate } from './auth.js';
6
+ import { CliLinkAPIError, errorBody, normalizeError } from './errors.js';
7
+ import { parseRequest, selectModel, translate } from './requests.js';
8
+ import { ExecutionSlots } from './sessions.js';
9
+ import { Redactor } from './redaction.js';
10
+ const json = (response, status, body) => { response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); response.end(JSON.stringify(body)); };
11
+ async function sse(response, body, signal) {
12
+ signal.throwIfAborted();
13
+ if (!response.write(`data: ${typeof body === 'string' ? body : JSON.stringify(body)}\n\n`))
14
+ await once(response, 'drain', { signal });
15
+ }
16
+ export function createServer(config, provider, log = value => console.log(JSON.stringify(value))) {
17
+ const slots = new ExecutionSlots(config.server.maxConcurrency);
18
+ const discovery = new ExecutionSlots(config.server.maxConcurrency);
19
+ const controllers = new Set();
20
+ const server = http.createServer(async (request, response) => {
21
+ const id = 'chatcmpl-' + randomUUID();
22
+ const started = Date.now();
23
+ const controller = new AbortController();
24
+ controllers.add(controller);
25
+ const signal = controller.signal;
26
+ let release;
27
+ let streaming = false;
28
+ let timedOut = false;
29
+ response.setHeader('x-request-id', id);
30
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, config.server.timeoutMs);
31
+ const disconnect = () => { if (!response.writableFinished)
32
+ controller.abort(); };
33
+ response.on('close', disconnect);
34
+ request.on('aborted', disconnect);
35
+ try {
36
+ authenticate(request.headers.authorization, config.auth.apiKey);
37
+ if (request.url === '/v1/models' && request.method === 'GET') {
38
+ release = discovery.acquire(id);
39
+ const models = await provider.models(signal);
40
+ json(response, 200, { object: 'list', data: models.map(m => ({ id: m.id, object: 'model', owned_by: 'codex', reasoning_efforts: m.efforts })) });
41
+ return;
42
+ }
43
+ if (request.url !== '/v1/chat/completions' || request.method !== 'POST')
44
+ throw new CliLinkAPIError(404, 'not_found', 'Endpoint not found.');
45
+ if (request.headers['x-session-id'] || request.headers['x-thread-id'])
46
+ throw new CliLinkAPIError(400, 'session_resume_unsupported', 'Each request starts an isolated session. Send conversation history in messages.');
47
+ const workspaceId = request.headers['x-workspace-id'] ?? config.compatibility.defaultWorkspace;
48
+ if (typeof workspaceId !== 'string' || !Object.hasOwn(config.workspaces, workspaceId))
49
+ throw new CliLinkAPIError(400, 'invalid_workspace', 'A configured X-Workspace-ID header is required.');
50
+ const workspace = config.workspaces[workspaceId];
51
+ if (!request.headers['content-type']?.startsWith('application/json'))
52
+ throw new CliLinkAPIError(415, 'content_type', 'Use application/json.');
53
+ release = slots.acquire(workspace.path);
54
+ const chunks = [];
55
+ let bytes = 0;
56
+ const abortRead = () => { request.destroy(); };
57
+ signal.addEventListener('abort', abortRead, { once: true });
58
+ try {
59
+ for await (const chunk of request) {
60
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
61
+ bytes += buffer.length;
62
+ if (bytes > config.server.maxBodyBytes)
63
+ throw new CliLinkAPIError(413, 'request_too_large', 'Request body exceeds configured limit.');
64
+ chunks.push(buffer);
65
+ }
66
+ }
67
+ finally {
68
+ signal.removeEventListener('abort', abortRead);
69
+ }
70
+ let value;
71
+ try {
72
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
73
+ }
74
+ catch {
75
+ throw new CliLinkAPIError(400, 'invalid_json', 'Request body must be valid JSON.');
76
+ }
77
+ const parsed = parseRequest(value);
78
+ const selected = selectModel(parsed, await provider.models(signal), config.provider);
79
+ if (parsed.stream && !provider.capabilities.streaming)
80
+ throw new CliLinkAPIError(400, 'stream_unsupported', 'Provider does not support streaming.');
81
+ const created = Math.floor(Date.now() / 1000);
82
+ const base = { id, created, model: selected.model };
83
+ const chunk = (delta, finish = null) => ({ ...base, object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: finish }] });
84
+ const redactor = new Redactor([config.auth.apiKey, config.provider.codexHome, os.homedir(), ...Object.values(config.workspaces).map(w => w.path)]);
85
+ let complete = false;
86
+ let receivedDelta = false;
87
+ for await (const event of provider.generate({ ...selected, ...translate(parsed.messages), workspace, signal, request: parsed })) {
88
+ signal.throwIfAborted();
89
+ if (parsed.stream && !streaming) {
90
+ response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });
91
+ streaming = true;
92
+ await sse(response, chunk({ role: 'assistant' }), signal);
93
+ }
94
+ if (event.type === 'delta') {
95
+ receivedDelta = true;
96
+ if (parsed.stream) {
97
+ const text = redactor.push(event.text);
98
+ if (text)
99
+ await sse(response, chunk({ content: text }), signal);
100
+ }
101
+ }
102
+ else if (event.type === 'tool_calls') {
103
+ complete = true;
104
+ if (parsed.stream) {
105
+ await sse(response, chunk({ tool_calls: event.calls.map((call, index) => ({ index, ...call })) }), signal);
106
+ await sse(response, chunk({}, 'tool_calls'), signal);
107
+ await sse(response, '[DONE]', signal);
108
+ response.end();
109
+ }
110
+ else
111
+ json(response, 200, { ...base, object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: event.calls }, finish_reason: 'tool_calls' }] });
112
+ }
113
+ else {
114
+ complete = true;
115
+ if (parsed.stream) {
116
+ if (!receivedDelta && event.text)
117
+ throw new CliLinkAPIError(502, 'stream_unavailable', 'Codex supplied only buffered final output; real-time streaming was unavailable. Retry with stream=false.');
118
+ const text = redactor.push('', true);
119
+ if (text)
120
+ await sse(response, chunk({ content: text }), signal);
121
+ await sse(response, chunk({}, 'stop'), signal);
122
+ if (parsed.stream_options?.include_usage && event.usage)
123
+ await sse(response, { ...base, object: 'chat.completion.chunk', choices: [], usage: event.usage }, signal);
124
+ await sse(response, '[DONE]', signal);
125
+ response.end();
126
+ }
127
+ else
128
+ json(response, 200, { ...base, object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: redactor.clean(event.text) }, finish_reason: 'stop' }], ...(event.usage ? { usage: event.usage } : {}) });
129
+ }
130
+ }
131
+ if (!complete)
132
+ throw new CliLinkAPIError(502, 'missing_final_response', 'Codex ended without a final response.');
133
+ }
134
+ catch (error) {
135
+ const safe = timedOut ? new CliLinkAPIError(504, 'timeout', 'Request timed out; execution was cancelled. Changes may already have occurred.') : normalizeError(error);
136
+ if (!response.destroyed && !response.writableEnded) {
137
+ if (streaming) {
138
+ await sse(response, errorBody(safe), AbortSignal.timeout(1000)).catch(() => undefined);
139
+ response.end();
140
+ }
141
+ else {
142
+ if (safe.status === 401)
143
+ response.setHeader('www-authenticate', 'Bearer');
144
+ json(response, safe.status, errorBody(safe));
145
+ }
146
+ }
147
+ log({ request_id: id, event: 'request_error', code: safe.code, status: safe.status });
148
+ }
149
+ finally {
150
+ clearTimeout(timer);
151
+ release?.();
152
+ controllers.delete(controller);
153
+ response.off('close', disconnect);
154
+ request.off('aborted', disconnect);
155
+ log({ request_id: id, event: 'request_finished', duration_ms: Date.now() - started, status: response.statusCode });
156
+ }
157
+ });
158
+ server.requestTimeout = config.server.timeoutMs;
159
+ server.headersTimeout = Math.min(config.server.timeoutMs, 10000);
160
+ server.maxHeadersCount = 50;
161
+ return { server, async close() { for (const c of controllers)
162
+ c.abort(); server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())); await provider.close(); } };
163
+ }
164
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,19 @@
1
+ import { CliLinkAPIError } from './errors.js';
2
+ // Deliberately stateless API: each request creates a fresh, ephemeral Codex thread.
3
+ // Clients submit history; raw thread IDs and resumption are never accepted.
4
+ export class ExecutionSlots {
5
+ limit;
6
+ active = new Set();
7
+ constructor(limit) {
8
+ this.limit = limit;
9
+ }
10
+ acquire(workspace) {
11
+ if (this.active.has(workspace))
12
+ throw new CliLinkAPIError(409, 'workspace_busy', 'A request is already executing in this workspace.');
13
+ if (this.active.size >= this.limit)
14
+ throw new CliLinkAPIError(429, 'clilinkapi_busy', 'CliLinkAPI concurrency limit reached.');
15
+ this.active.add(workspace);
16
+ return () => { this.active.delete(workspace); };
17
+ }
18
+ }
19
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1,46 @@
1
+ import os from 'node:os';
2
+ export function startupMessage(config, address) {
3
+ const host = address.address;
4
+ const urlHost = host === '0.0.0.0' ? '127.0.0.1' : host === '::' ? '::1' : host;
5
+ const baseUrl = `http://${urlHost.includes(':') ? `[${urlHost}]` : urlHost}:${address.port}`;
6
+ const row = (label, value) => ` ${label.padEnd(16)} ${value}`;
7
+ return [
8
+ '',
9
+ ' +----------------------------------------------------------+',
10
+ ' | CliLinkAPI |',
11
+ ' | Codex-powered OpenAI-compatible API |',
12
+ ' +----------------------------------------------------------+',
13
+ '',
14
+ ' SERVER INFORMATION',
15
+ row('Status', 'LISTENING'),
16
+ row('Host ID', os.hostname()),
17
+ row('Host / IP', host),
18
+ row('Port', address.port),
19
+ row('Process ID', process.pid),
20
+ row('Base URL', baseUrl),
21
+ row('API base URL', `${baseUrl}/v1`),
22
+ ...(host === '0.0.0.0' || host === '::' ? [row('Bind scope', 'All interfaces (URLs above use loopback)')] : []),
23
+ '',
24
+ ' ENDPOINTS',
25
+ ` GET ${baseUrl}/v1/models`,
26
+ ' List available models and reasoning efforts.',
27
+ ` POST ${baseUrl}/v1/chat/completions`,
28
+ ' Create a chat completion; supports SSE streaming.',
29
+ ...(process.platform === 'win32' ? [config.provider.allowUnqualifiedWindowsExecution
30
+ ? ' WARNING: Unqualified Windows execution enabled; isolation probes skipped.'
31
+ : ' Agent execution is currently disabled on Windows.'] : []),
32
+ '',
33
+ ' REQUEST SETTINGS',
34
+ row('Authentication', 'Authorization: Bearer <API_KEY> (all endpoints)'),
35
+ row('POST content', 'Content-Type: application/json'),
36
+ row('Workspace', config.compatibility.defaultWorkspace
37
+ ? `${config.compatibility.defaultWorkspace} (default; override with X-Workspace-ID)`
38
+ : 'X-Workspace-ID header required for POST'),
39
+ row('Concurrency', config.server.maxConcurrency),
40
+ row('Timeout', `${config.server.timeoutMs / 1000}s`),
41
+ '',
42
+ ' Press Ctrl+C to stop the server.',
43
+ '',
44
+ ].join('\n');
45
+ }
46
+ //# sourceMappingURL=startup.js.map
@@ -0,0 +1,54 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { CliLinkAPIError } from './errors.js';
3
+ const hash = (v) => createHash('sha256').update(JSON.stringify(v, (key, value) => {
4
+ if (key === 'arguments' && typeof value === 'string') {
5
+ try {
6
+ return JSON.parse(value);
7
+ }
8
+ catch {
9
+ return value;
10
+ }
11
+ }
12
+ return value && typeof value === 'object' && !Array.isArray(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) : value;
13
+ })).digest('hex');
14
+ const binding = (i) => hash([i.workspace, i.model, i.effort, i.request?.tools ?? [], i.request?.tool_choice ?? 'auto']);
15
+ const history = (messages) => hash(JSON.parse(JSON.stringify(messages, (key, value) => key === 'content' && (value === null || value === '') ? undefined : value)));
16
+ export class ToolSessions {
17
+ limit;
18
+ ttl;
19
+ close;
20
+ entries = new Map();
21
+ hasWorkspace(workspace) { return [...this.entries.values()].some(e => e.workspace === workspace); }
22
+ constructor(limit, ttl, close) {
23
+ this.limit = limit;
24
+ this.ttl = ttl;
25
+ this.close = close;
26
+ }
27
+ put(input, value, name, args) {
28
+ if (this.entries.size >= this.limit)
29
+ throw new CliLinkAPIError(429, 'pending_tool_limit', 'Too many pending tool continuations.');
30
+ const call = { id: 'call_' + randomUUID().replaceAll('-', ''), type: 'function', function: { name, arguments: JSON.stringify(args) } };
31
+ const timer = setTimeout(() => { this.entries.delete(call.id); void this.close(value).catch(() => undefined); }, this.ttl);
32
+ timer.unref();
33
+ this.entries.set(call.id, { value, workspace: input.workspace.path, binding: binding(input), history: history(input.request.messages), call: structuredClone(call), timer });
34
+ return call;
35
+ }
36
+ take(input) {
37
+ const messages = input.request?.messages;
38
+ const last = messages?.at(-1);
39
+ if (!messages || last?.role !== 'tool')
40
+ return;
41
+ const entry = this.entries.get(last.tool_call_id);
42
+ if (!entry)
43
+ throw new CliLinkAPIError(409, 'tool_session_expired', 'Tool continuation expired, was consumed, or belongs to another server. Restart the agent conversation; do not replay side effects automatically.');
44
+ const assistant = messages.at(-2);
45
+ if (entry.binding !== binding(input) || entry.history !== history(messages.slice(0, -2)) || assistant?.role !== 'assistant' || (assistant.content !== undefined && assistant.content !== null && assistant.content !== '') || hash(assistant.tool_calls) !== hash([entry.call]))
46
+ throw new CliLinkAPIError(409, 'tool_session_mismatch', 'Tool continuation must preserve workspace, model, reasoning, tool definitions and conversation history.');
47
+ clearTimeout(entry.timer);
48
+ this.entries.delete(last.tool_call_id);
49
+ return { value: entry.value, result: last.content };
50
+ }
51
+ async closeAll() { const entries = [...this.entries.values()]; this.entries.clear(); for (const e of entries)
52
+ clearTimeout(e.timer); await Promise.all(entries.map(e => this.close(e.value))); }
53
+ }
54
+ //# sourceMappingURL=tool-sessions.js.map
@@ -0,0 +1,13 @@
1
+ # Architecture and provider integration
2
+
3
+ `server.ts` owns HTTP authentication order, bounded bodies, OpenAI-shaped errors, SSE/backpressure and disconnect/timeout signals. `config.ts` owns strict runtime validation and directory authorization. `sessions.ts` locks workspace execution within one clilinkapi process; new requests are isolated; tool-sessions.ts retains bounded, expiring one-use continuations for external tool results and reserves their workspace. `permissions.ts` owns secret-file permissions. `sandbox.ts` owns native profiles and preflight probes. `requests.ts` owns message and reasoning translation. Providers are registered in `providers/registry.ts` behind `providers/types.ts`.
4
+
5
+ The only implemented provider is Codex. Its adapter owns login verification, catalog pagination/filtering, thread creation, native reasoning configuration, final-answer extraction, cancellation and normalized errors. RPC uses a dedicated stdio app-server per operation. No app-server TCP listener is exposed. Request IDs correlate safe structured logs; prompts, responses, headers, account identity and raw upstream stderr are never logged. Metadata and generation are bounded separately. Slow consumers are subject to the request deadline; RPC event count, line size and final delta accumulation are bounded. No automatic clilinkapi retries occur.
6
+
7
+ The official SDK was inspected first. Version 0.155.0 exposes `Thread.run`, `runStreamed`, AbortSignal and sandbox modes. Its typed thread options do not provide permission-profile selection/model discovery, and its `agent_message` events omit final-versus-commentary phase metadata. Current app-server provides `model/list`, incremental `item/agentMessage/delta`, phase-labelled items, and experimental named permission profiles. Therefore this release uses the official app-server instead of the SDK for execution and metadata. The CLI is used for official login and sandbox diagnostics only. The SDK remains pinned for comparison/migration; there is no fake SDK backend.
8
+
9
+ Protocol fields were checked against `codex app-server generate-ts --experimental` from the pinned installed binary. In particular, its legacy `SandboxPolicy` lacks the `ReadOnlyAccess` field shown in newer online documentation, so the implementation uses `permissions: "clilinkapi"` on threads and `permissionProfile: "clilinkapi"` on standalone probes. It verifies the returned active profile and never passes a legacy `sandbox` override that could supersede the profile. Permission-profile APIs are experimental; reject incompatible versions and re-run platform checks before upgrades.
10
+
11
+ To add a future provider, implement `Provider.models`, `generate`, `close`, and capability flags. `generate` yields actual final-response deltas and one completed result, accepts AbortSignal, and supplies usage only when the backend reports it. Normalize errors without forwarding sensitive backend messages. Own authentication and provider sessions entirely inside the adapter. Add explicit config discrimination and registry selection, validate native reasoning against discovery, and add adapter/protocol and live tests. Do not assume Claude/Gemini authentication, role mapping, sandbox controls or reasoning levels match Codex. Neither provider is implemented in this release.
12
+
13
+ Official references: [Codex SDK](https://learn.chatgpt.com/docs/codex-sdk), [app-server protocol](https://learn.chatgpt.com/docs/app-server), [authentication](https://learn.chatgpt.com/docs/auth), [permission profiles](https://learn.chatgpt.com/docs/permissions), [native sandbox prerequisites](https://learn.chatgpt.com/docs/sandboxing). Local generated schema is authoritative for the pinned executable when online documentation is newer.