@openagents-org/agent-launcher 0.2.142 → 0.2.145

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.
@@ -19,6 +19,7 @@ const { execSync, spawn } = require('child_process');
19
19
  const http = require('http');
20
20
  const https = require('https');
21
21
 
22
+ const { whereBinary } = require('../paths');
22
23
  const BaseAdapter = require('./base');
23
24
  const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
24
25
 
@@ -49,7 +50,8 @@ class CodexAdapter extends BaseAdapter {
49
50
  this._loadSessions();
50
51
 
51
52
  // Determine mode:
52
- // - CLI mode: only works with OpenAI's native Responses API (api.openai.com)
53
+ // - CLI mode: works with OpenAI's native Responses API (api.openai.com)
54
+ // OR with subscription-based auth via 'codex login'
53
55
  // - Direct API mode: works with any OpenAI-compatible chat completions endpoint
54
56
  this._codexBin = this._findCodexBinary();
55
57
  this._directMode = false;
@@ -59,9 +61,10 @@ class CodexAdapter extends BaseAdapter {
59
61
  const isOpenAiNative = !this._directBaseUrl ||
60
62
  this._directBaseUrl.includes('api.openai.com');
61
63
 
62
- if (this._codexBin && isOpenAiNative) {
64
+ if (this._codexBin && (isOpenAiNative || !this._directApiKey)) {
65
+ // CLI mode: either OpenAI native API or subscription auth (no API key)
63
66
  this._useCliMode = true;
64
- this._log(`CLI mode: ${this._codexBin}`);
67
+ this._log(`CLI mode: ${this._codexBin}${!this._directApiKey ? ' (subscription auth)' : ''}`);
65
68
  } else if (this._directApiKey && this._directBaseUrl) {
66
69
  this._directMode = true;
67
70
  if (this._codexBin) {
@@ -70,7 +73,7 @@ class CodexAdapter extends BaseAdapter {
70
73
  this._log(`Direct LLM mode: ${this._directBaseUrl} model=${this._directModel || 'gpt-4o'}`);
71
74
  }
72
75
  } else if (this._codexBin) {
73
- // CLI binary found, no custom base URL — assume OpenAI
76
+ // CLI binary found, no custom base URL — assume OpenAI or subscription auth
74
77
  this._useCliMode = true;
75
78
  this._log(`CLI mode: ${this._codexBin}`);
76
79
  } else {
@@ -123,17 +126,12 @@ class CodexAdapter extends BaseAdapter {
123
126
  const portableCandidate = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `codex${ext}`);
124
127
  if (fs.existsSync(portableCandidate)) return portableCandidate;
125
128
 
126
- // Tier 1: PATH search
127
- try {
128
- if (IS_WINDOWS) {
129
- const r = execSync('where codex.cmd 2>nul || where codex.exe 2>nul || where codex 2>nul', {
130
- encoding: 'utf-8', timeout: 5000,
131
- });
132
- return r.split(/\r?\n/)[0].trim();
133
- } else {
134
- return execSync('which codex', { encoding: 'utf-8', timeout: 5000 }).trim();
135
- }
136
- } catch {}
129
+ // Tier 1: PATH search via a codepage-safe lookup (whereBinary forces UTF-8
130
+ // output + verifies existence so a non-ASCII/Chinese username isn't mangled
131
+ // into an ENOENT; it also no longer returns an empty string on a miss, which
132
+ // used to short-circuit the Node-derived fallback tiers below).
133
+ const viaWhere = whereBinary('codex');
134
+ if (viaWhere) return viaWhere;
137
135
 
138
136
  // Tier 2: Next to current Node.js interpreter (npm global)
139
137
  const nodeBinDir = path.dirname(process.execPath);
@@ -270,7 +268,7 @@ class CodexAdapter extends BaseAdapter {
270
268
  } else if (this._directMode) {
271
269
  await this._handleViaDirectApi(content, msgChannel);
272
270
  } else {
273
- await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex');
271
+ await this.sendError(msgChannel, 'codex CLI not found. Install with: npm install -g @openai/codex\n\nOr configure OPENAI_API_KEY + OPENAI_BASE_URL for direct API mode.');
274
272
  }
275
273
  }
276
274
 
@@ -0,0 +1,380 @@
1
+ /**
2
+ * GitHub Copilot CLI — JSONL stream parser (pure, no I/O).
3
+ *
4
+ * This module turns the raw byte stream produced by
5
+ * `copilot -p "<prompt>" --output-format=json`
6
+ * into a sequence of *normalized* events the CopilotAdapter can map onto
7
+ * OpenAgents workspace messages. It is deliberately split into two layers:
8
+ *
9
+ * 1. FRAMING (schema-agnostic) — `frameChunk()` / `CopilotStreamParser`
10
+ * Reassembles complete text lines from arbitrary chunk boundaries. This
11
+ * is the part that is fully specified regardless of the CLI version:
12
+ * one JSON object per line (JSONL), lines may be split across chunks,
13
+ * a single chunk may carry many lines, line endings may be `\n` or
14
+ * `\r\n`, blank lines and non-JSON noise must be tolerated. This layer
15
+ * is exhaustively unit-tested and does not depend on any field names.
16
+ *
17
+ * 2. CLASSIFICATION (schema-aware) — `classifyEvent()`
18
+ * Maps one parsed JSON object onto a normalized event `{ kind, ... }`.
19
+ * The concrete field/type names live in ONE table (`EVENT_KIND_BY_TYPE`
20
+ * + the field extractors) so that, once the JSONL schema of the locally
21
+ * installed `@github/copilot` build is captured, only this table needs
22
+ * to change — the adapter and the framing layer stay untouched.
23
+ *
24
+ * ── Schema status ────────────────────────────────────────────────────────
25
+ * The GitHub Copilot CLI (`@github/copilot`, executable `copilot`) ships its
26
+ * structured output as JSONL via `--output-format=json`. The exact `type`
27
+ * strings and payload shapes evolve between releases and MUST be confirmed
28
+ * against the actually-installed build (see docs/agents/github-copilot-cli.md
29
+ * and the delivery report). Until then the mapping below recognizes the
30
+ * documented/observed families and ALWAYS degrades unknown objects to a
31
+ * redacted `unknown` event instead of throwing — so a schema drift downgrades
32
+ * fidelity, never crashes a task. Never infer an event's meaning from a field
33
+ * name alone; verify against real output or the official source.
34
+ *
35
+ * Normalized event kinds (the adapter's stable contract):
36
+ * { kind: 'session', sessionId } real CLI session id/name
37
+ * { kind: 'text_delta', text } streaming assistant text
38
+ * { kind: 'text', text } final/complete assistant text
39
+ * { kind: 'reasoning', text } thinking / status narration
40
+ * { kind: 'tool_start', tool, input } a tool/command is starting
41
+ * { kind: 'tool_result', tool, result, isError } a tool/command finished
42
+ * { kind: 'file_change', path, action } a file was read/written
43
+ * { kind: 'shell', command, exitCode, output } a shell command ran
44
+ * { kind: 'permission', detail, granted } a permission decision
45
+ * { kind: 'ask_user', question } CLI wants interactive input
46
+ * { kind: 'usage', model, usage } model / token accounting
47
+ * { kind: 'done', status } turn completed (terminal)
48
+ * { kind: 'error', message, code } failure (terminal)
49
+ * { kind: 'unknown', raw } unrecognized (redacted)
50
+ */
51
+
52
+ 'use strict';
53
+
54
+ // ────────────────────────────────────────────────────────────────────────
55
+ // Redaction (shared by the adapter for logs + unknown-event diagnostics)
56
+ // ────────────────────────────────────────────────────────────────────────
57
+
58
+ // GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ (classic + fine-grained github_pat_),
59
+ // plus generic bearer/secret shapes. Kept conservative to avoid mangling code.
60
+ const TOKEN_PATTERNS = [
61
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
62
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
63
+ /\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g,
64
+ /\bBearer\s+[A-Za-z0-9._-]{16,}\b/gi,
65
+ /\b[A-Fa-f0-9]{40,}\b/g, // long hex blobs (OAuth/refresh tokens)
66
+ ];
67
+
68
+ // Env-style assignments of well-known secret-bearing variables — redact the
69
+ // value, keep the key so logs still say *which* var was involved.
70
+ const SECRET_ENV_KEYS = [
71
+ 'COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_COPILOT_TOKEN',
72
+ 'GITHUB_COPILOT_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
73
+ 'AZURE_API_KEY', 'GOOGLE_API_KEY', 'OA_WORKSPACE_TOKEN',
74
+ ];
75
+ const SECRET_ENV_RE = new RegExp(
76
+ `\\b(${SECRET_ENV_KEYS.join('|')})(\\s*[=:]\\s*)("?)([^\\s"']+)`,
77
+ 'gi',
78
+ );
79
+ const AUTH_HEADER_RE = /\b(authorization|x-api-key|x-github-token)(\s*:\s*)(\S+)/gi;
80
+
81
+ /**
82
+ * Redact secrets from an arbitrary string for safe logging / diagnostics.
83
+ * Never throws; non-strings are coerced. Pure.
84
+ */
85
+ function redactSensitive(value) {
86
+ let s;
87
+ if (typeof value === 'string') s = value;
88
+ else { try { s = JSON.stringify(value); } catch { s = String(value); } }
89
+ if (!s) return s;
90
+ s = s.replace(SECRET_ENV_RE, (_m, k, sep, q) => `${k}${sep}${q}***`);
91
+ s = s.replace(AUTH_HEADER_RE, (_m, k, sep) => `${k}${sep}***`);
92
+ for (const re of TOKEN_PATTERNS) s = s.replace(re, '***');
93
+ return s;
94
+ }
95
+
96
+ /**
97
+ * Produce a short, redacted, single-line diagnostic for an unknown event so a
98
+ * schema drift is observable in logs without dumping a full transcript or any
99
+ * secret. Pure.
100
+ */
101
+ function diagnosticForUnknown(obj, maxLen = 300) {
102
+ let s;
103
+ try { s = JSON.stringify(obj); } catch { s = String(obj); }
104
+ s = redactSensitive(s).replace(/\s+/g, ' ').trim();
105
+ return s.length > maxLen ? s.slice(0, maxLen) + '…' : s;
106
+ }
107
+
108
+ // ────────────────────────────────────────────────────────────────────────
109
+ // Framing (schema-agnostic) — pure
110
+ // ────────────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Append `chunk` to `buffer` and split off every complete line.
114
+ * Returns `{ lines, rest }` where `lines` are complete lines (no trailing
115
+ * newline, `\r` stripped) and `rest` is the leftover partial line to carry
116
+ * into the next call. Handles `\n` and `\r\n`. Pure — no state retained.
117
+ *
118
+ * @param {string} buffer carry-over from the previous call
119
+ * @param {string|Buffer} chunk new bytes
120
+ * @returns {{ lines: string[], rest: string }}
121
+ */
122
+ function frameChunk(buffer, chunk) {
123
+ const text = (buffer || '') + (chunk == null ? '' : chunk.toString('utf-8'));
124
+ const parts = text.split('\n');
125
+ const rest = parts.pop(); // last element is the incomplete tail (maybe '')
126
+ const lines = parts.map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l));
127
+ return { lines, rest };
128
+ }
129
+
130
+ /**
131
+ * Parse one JSONL line into an object, or null when the line is blank or not
132
+ * valid JSON (CLIs occasionally interleave human-readable banners/noise). A
133
+ * single corrupt line must never abort the surrounding task. Pure.
134
+ *
135
+ * @param {string} line
136
+ * @returns {object|null}
137
+ */
138
+ function parseLine(line) {
139
+ if (line == null) return null;
140
+ const trimmed = String(line).trim();
141
+ if (!trimmed) return null;
142
+ if (trimmed[0] !== '{' && trimmed[0] !== '[') return null; // fast reject noise
143
+ try {
144
+ const v = JSON.parse(trimmed);
145
+ return (v && typeof v === 'object') ? v : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ // ────────────────────────────────────────────────────────────────────────
152
+ // Classification (schema-aware) — pure. SINGLE source of field/type mapping.
153
+ // ────────────────────────────────────────────────────────────────────────
154
+
155
+ /**
156
+ * Maps a Copilot CLI event `type` (lowercased) to a normalized kind.
157
+ *
158
+ * ── Verification status (IMPORTANT) ──────────────────────────────────────
159
+ * VERIFIED against real CLI v1.0.63: `--output-format json` is "JSONL, one JSON
160
+ * object per line"; auth/session/network failures print to STDERR with EMPTY
161
+ * stdout (no JSONL error event) — so those are NOT parsed here, the adapter
162
+ * classifies them from stderr. The success-path stdout event names below
163
+ * (text/tool/file/done/etc.) could NOT be captured (no Copilot subscription in
164
+ * CI), so they are BEST-EFFORT, intentionally focused rather than broad, and
165
+ * must be confirmed against a real authenticated run. The deliberately small
166
+ * alias set avoids misclassifying unrelated objects; anything unrecognized
167
+ * degrades to a redacted `unknown` event (never a crash, never a false `done`).
168
+ * When real samples are available, extend THIS table only.
169
+ */
170
+ const EVENT_KIND_BY_TYPE = {
171
+ // session lifecycle / identity (kind only mapped when an id is also present)
172
+ session: 'session', 'session.created': 'session', 'session.started': 'session',
173
+ session_started: 'session', thread: 'session', 'thread.started': 'session',
174
+ // streaming assistant text (qualified names only — no bare 'delta'/'token')
175
+ 'text.delta': 'text_delta', text_delta: 'text_delta',
176
+ 'message.delta': 'text_delta', 'assistant.delta': 'text_delta',
177
+ content_block_delta: 'text_delta',
178
+ // final assistant text (qualified/explicit names only — no bare 'message')
179
+ text: 'text', 'message.completed': 'text', 'assistant.message': 'text',
180
+ assistant: 'text', completion: 'text', agent_message: 'text',
181
+ // reasoning / status narration
182
+ reasoning: 'reasoning', thinking: 'reasoning', thought: 'reasoning',
183
+ status: 'reasoning', progress: 'reasoning',
184
+ // tool calls
185
+ tool_call: 'tool_start', 'tool.call': 'tool_start', tool_use: 'tool_start',
186
+ 'tool.started': 'tool_start', tool_start: 'tool_start', function_call: 'tool_start',
187
+ tool_result: 'tool_result', 'tool.result': 'tool_result',
188
+ 'tool.completed': 'tool_result', tool_output: 'tool_result',
189
+ function_result: 'tool_result',
190
+ // file + shell specializations (may also arrive as generic tool events)
191
+ file_change: 'file_change', file_edit: 'file_change', 'file.changed': 'file_change',
192
+ file_write: 'file_change', file_read: 'file_change',
193
+ shell: 'shell', command_execution: 'shell', 'shell.exec': 'shell',
194
+ // permissions + interactive prompts
195
+ permission: 'permission', 'permission.request': 'permission',
196
+ permission_request: 'permission', permission_denied: 'permission',
197
+ approval: 'permission',
198
+ ask_user: 'ask_user', 'user.prompt': 'ask_user', input_request: 'ask_user',
199
+ // accounting (explicit only — bare 'model'/'tokens' are too generic)
200
+ usage: 'usage',
201
+ // terminal (no bare 'end'/'result' — 'result' collides with tool results)
202
+ done: 'done', completed: 'done', 'turn.completed': 'done', finish: 'done',
203
+ error: 'error', failure: 'error', 'turn.failed': 'error', failed: 'error',
204
+ fatal: 'error', aborted: 'error',
205
+ };
206
+
207
+ function _firstString(obj, keys) {
208
+ for (const k of keys) {
209
+ const v = obj[k];
210
+ if (typeof v === 'string' && v.length) return v;
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ function _coerceText(v) {
216
+ if (typeof v === 'string') return v;
217
+ if (v && typeof v === 'object') {
218
+ if (typeof v.text === 'string') return v.text;
219
+ if (Array.isArray(v.content)) {
220
+ return v.content
221
+ .map((b) => (typeof b === 'string' ? b : (b && typeof b.text === 'string' ? b.text : '')))
222
+ .join('');
223
+ }
224
+ if (typeof v.content === 'string') return v.content;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /**
230
+ * Classify a parsed JSON object into a normalized event. Never throws; an
231
+ * unrecognized shape becomes `{ kind: 'unknown', raw }` with a redacted
232
+ * diagnostic. Pure — the single place that knows Copilot field names.
233
+ *
234
+ * @param {object} obj
235
+ * @returns {{kind: string} & object}
236
+ */
237
+ function classifyEvent(obj) {
238
+ if (!obj || typeof obj !== 'object') {
239
+ return { kind: 'unknown', raw: diagnosticForUnknown(obj) };
240
+ }
241
+
242
+ const rawType = _firstString(obj, ['type', 'event', 'event_type', 'kind', 'name']);
243
+ let kind = rawType ? EVENT_KIND_BY_TYPE[rawType.toLowerCase()] : undefined;
244
+
245
+ // Nested item types: some CLIs wrap the real type in `item.type`
246
+ // (e.g. { type: 'item.completed', item: { type: 'command_execution' } }).
247
+ const item = (obj.item && typeof obj.item === 'object') ? obj.item : null;
248
+ if (item) {
249
+ const itemType = _firstString(item, ['type', 'kind']);
250
+ const itemKind = itemType ? EVENT_KIND_BY_TYPE[itemType.toLowerCase()] : undefined;
251
+ if (itemKind) kind = itemKind;
252
+ }
253
+ const src = item || obj;
254
+
255
+ switch (kind) {
256
+ case 'session': {
257
+ const sessionId = _firstString(src, [
258
+ 'session_id', 'sessionId', 'session', 'thread_id', 'threadId', 'id', 'name',
259
+ ]);
260
+ if (!sessionId) break; // not actually a session event → fall to unknown
261
+ return { kind: 'session', sessionId };
262
+ }
263
+ case 'text_delta': {
264
+ const text = _coerceText(src.delta) ?? _coerceText(src.text) ?? _coerceText(src.content) ?? _coerceText(src);
265
+ return { kind: 'text_delta', text: text || '' };
266
+ }
267
+ case 'text': {
268
+ const text = _coerceText(src.text) ?? _coerceText(src.message) ?? _coerceText(src.content) ?? _coerceText(src);
269
+ return { kind: 'text', text: text || '' };
270
+ }
271
+ case 'reasoning': {
272
+ const text = _coerceText(src.text) ?? _coerceText(src.reasoning) ?? _coerceText(src.message)
273
+ ?? _coerceText(src.content) ?? _firstString(src, ['status', 'label', 'title']) ?? '';
274
+ return { kind: 'reasoning', text };
275
+ }
276
+ case 'tool_start': {
277
+ const tool = _firstString(src, ['tool', 'tool_name', 'name', 'function']) || 'tool';
278
+ const input = src.input ?? src.arguments ?? src.args ?? src.parameters ?? src.params;
279
+ return { kind: 'tool_start', tool, input };
280
+ }
281
+ case 'tool_result': {
282
+ const tool = _firstString(src, ['tool', 'tool_name', 'name', 'function']) || 'tool';
283
+ const result = src.result ?? src.output ?? src.content ?? src.stdout;
284
+ const isError = !!(src.is_error ?? src.isError ?? src.error ?? (src.exit_code != null && src.exit_code !== 0));
285
+ return { kind: 'tool_result', tool, result, isError };
286
+ }
287
+ case 'file_change': {
288
+ const filePath = _firstString(src, ['path', 'file', 'file_path', 'filename', 'filePath']);
289
+ const action = _firstString(src, ['action', 'operation', 'change', 'mode'])
290
+ || (rawType && /read/i.test(rawType) ? 'read' : 'edit');
291
+ return { kind: 'file_change', path: filePath || '', action };
292
+ }
293
+ case 'shell': {
294
+ const command = _firstString(src, ['command', 'cmd', 'shell', 'script']) || '';
295
+ const exitCode = src.exit_code ?? src.exitCode ?? src.code ?? null;
296
+ const output = _coerceText(src.output) ?? _coerceText(src.stdout) ?? '';
297
+ return { kind: 'shell', command, exitCode, output };
298
+ }
299
+ case 'permission': {
300
+ const detail = _firstString(src, ['detail', 'message', 'tool', 'resource', 'reason'])
301
+ || diagnosticForUnknown(src, 120);
302
+ const granted = !!(src.granted ?? src.allowed ?? src.approved);
303
+ return { kind: 'permission', detail, granted };
304
+ }
305
+ case 'ask_user': {
306
+ const question = _firstString(src, ['question', 'prompt', 'message', 'text']) || '';
307
+ return { kind: 'ask_user', question };
308
+ }
309
+ case 'usage': {
310
+ const model = _firstString(src, ['model', 'model_id', 'modelName']);
311
+ const usage = src.usage ?? src.tokens ?? src.cost ?? null;
312
+ return { kind: 'usage', model, usage };
313
+ }
314
+ case 'done': {
315
+ const status = _firstString(src, ['status', 'reason', 'result']) || 'completed';
316
+ return { kind: 'done', status };
317
+ }
318
+ case 'error': {
319
+ const err = (src.error && typeof src.error === 'object') ? src.error : src;
320
+ const message = _firstString(err, ['message', 'error', 'detail', 'reason'])
321
+ || (typeof src.error === 'string' ? src.error : '')
322
+ || 'Copilot CLI reported an error';
323
+ const code = _firstString(err, ['code', 'type', 'status']) || null;
324
+ return { kind: 'error', message: redactSensitive(message), code };
325
+ }
326
+ default:
327
+ break;
328
+ }
329
+ return { kind: 'unknown', raw: diagnosticForUnknown(obj) };
330
+ }
331
+
332
+ // ────────────────────────────────────────────────────────────────────────
333
+ // Stateful incremental parser — thin wrapper over the pure functions
334
+ // ────────────────────────────────────────────────────────────────────────
335
+
336
+ class CopilotStreamParser {
337
+ constructor() {
338
+ this._buffer = '';
339
+ }
340
+
341
+ /**
342
+ * Feed a stdout chunk; returns the normalized events newly completed by it.
343
+ * Partial trailing data is retained until the next push()/flush().
344
+ * @param {string|Buffer} chunk
345
+ * @returns {Array<object>}
346
+ */
347
+ push(chunk) {
348
+ const { lines, rest } = frameChunk(this._buffer, chunk);
349
+ this._buffer = rest;
350
+ const events = [];
351
+ for (const line of lines) {
352
+ const obj = parseLine(line);
353
+ if (obj === null) continue; // blank or non-JSON noise — skip, never throw
354
+ events.push(classifyEvent(obj));
355
+ }
356
+ return events;
357
+ }
358
+
359
+ /**
360
+ * Flush any buffered trailing line (e.g. last line without a newline at
361
+ * process exit). Returns the normalized events it yields. Idempotent.
362
+ * @returns {Array<object>}
363
+ */
364
+ flush() {
365
+ const tail = this._buffer;
366
+ this._buffer = '';
367
+ const obj = parseLine(tail);
368
+ return obj === null ? [] : [classifyEvent(obj)];
369
+ }
370
+ }
371
+
372
+ module.exports = {
373
+ CopilotStreamParser,
374
+ frameChunk,
375
+ parseLine,
376
+ classifyEvent,
377
+ redactSensitive,
378
+ diagnosticForUnknown,
379
+ EVENT_KIND_BY_TYPE,
380
+ };