@pulsehub/runner 0.1.10 → 0.1.12

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 CHANGED
@@ -105,3 +105,22 @@ Los ZIPs tienen un límite de 30 MiB comprimidos/256 MiB expandidos por repo;
105
105
  enlaces, archivos especiales y traversal se rechazan con diagnóstico visible.
106
106
  La limpieza y recuperación eliminan también snapshots interrumpidos de sólo
107
107
  lectura. Este protocolo requiere desplegar la versión compatible del backend.
108
+
109
+ ### Diagnóstico de QA Codex
110
+
111
+ QA Codex usa `exec --json`. `runner.jsonl` registra la versión del CLI, los
112
+ permisos efectivos, la presencia de la credencial (boolean), el número de
113
+ snapshots y el ciclo de llamadas MCP a Pulse, correlacionados por `jobId`.
114
+ Los eventos `codex.mcp.call` se originan en eventos del CLI, no en menciones
115
+ al nombre de una herramienta dentro del texto del modelo. `hasResult` indica
116
+ que llegó un resultado; no demuestra que la revisión se haya reclamado ni
117
+ que el veredicto haya sido aceptado. Eso se verifica en el estado de Pulse.
118
+
119
+ `codex.diagnostics.summary` cuenta llamadas iniciadas/completadas/fallidas,
120
+ líneas malformadas y truncamiento de eventos. Cero llamadas observadas permite
121
+ distinguir un proceso que termina sin usar MCP. Los mensajes de startup conocidos
122
+ se convierten a estados fijos; stderr restante se cuenta en bytes. Argumentos,
123
+ resultados, errores libres, prompts y transcripts nunca se guardan ni se envían
124
+ en el reporte QA. Los nombres de herramientas desconocidas se registran como
125
+ `other`. Las líneas se limitan a 256 KiB y se emiten como máximo 500 eventos
126
+ más el resumen. Un fallo al escribir el diagnóstico no interrumpe el job.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Local, credential-safe Pulse Runner",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,73 @@
1
+ // Provider output is untrusted and can contain credentials. Only emit constants,
2
+ // counts and explicitly approved Pulse tool names; never forward payload fields.
3
+ const tools = new Set(['pulse_next_review', 'pulse_get_review_context', 'pulse_submit_review', 'pulse_report_review_incomplete', 'pulse_whoami', 'pulse_get_issue', 'pulse_get_project', 'pulse_list_comments']);
4
+ export function codexDiagnostic(value) {
5
+ if (!value || typeof value !== 'object') return null;
6
+ if (['thread.started', 'turn.started', 'turn.completed', 'turn.failed', 'error'].includes(value.type)) {
7
+ return { event: `codex.${value.type}` };
8
+ }
9
+ if (!['item.started', 'item.updated', 'item.completed'].includes(value.type)) return null;
10
+ const item = value.item;
11
+ if (item?.type !== 'mcp_tool_call' || item.server !== 'pulse') return null;
12
+ if (!['in_progress', 'completed', 'failed'].includes(item.status)) return null;
13
+ return { event: 'codex.mcp.call', lifecycle: value.type, server: 'pulse', tool: tools.has(item.tool) ? item.tool : 'other', status: item.status, hasResult: item.result != null, hasError: item.error != null };
14
+ }
15
+
16
+ export function codexDiagnostics(emit) {
17
+ let pending = '';
18
+ let dropping = false;
19
+ let lines = 0;
20
+ let malformed = 0;
21
+ let observed = 0;
22
+ let mcpStarted = 0;
23
+ let mcpCompleted = 0;
24
+ let mcpFailed = 0;
25
+ let emitted = 0;
26
+ const line = (raw) => {
27
+ lines++;
28
+ let value;
29
+ try { value = JSON.parse(raw); } catch { malformed++; return; }
30
+ const safe = codexDiagnostic(value);
31
+ if (!safe) return;
32
+ observed++;
33
+ if (safe.event === 'codex.mcp.call') {
34
+ if (safe.lifecycle === 'item.started') mcpStarted++;
35
+ if (safe.lifecycle === 'item.completed') {
36
+ if (safe.status === 'completed') mcpCompleted++;
37
+ else if (safe.status === 'failed') mcpFailed++;
38
+ }
39
+ }
40
+ if (emitted++ < 500) emit(safe);
41
+ };
42
+ return {
43
+ // Split by newline with a bounded buffer, even for huge or malformed lines.
44
+ push(chunk) {
45
+ for (const part of String(chunk).split(/(?<=\n)/)) {
46
+ const end = part.endsWith('\n');
47
+ if (!dropping) {
48
+ if (pending.length + part.length > 256 * 1024) { pending = ''; dropping = true; }
49
+ else pending += part;
50
+ }
51
+ if (end) {
52
+ if (dropping) { lines++; malformed++; } else line(pending);
53
+ pending = ''; dropping = false;
54
+ }
55
+ }
56
+ },
57
+ finish() {
58
+ if (dropping) { lines++; malformed++; } else if (pending) line(pending);
59
+ pending = ''; dropping = false;
60
+ const summary = { event: 'codex.diagnostics.summary', lines, malformed, observed, mcpStarted, mcpCompleted, mcpFailed, truncated: emitted > 500 };
61
+ emit(summary);
62
+ return summary;
63
+ },
64
+ };
65
+ }
66
+
67
+ // Classify startup stderr using constants only, without retaining messages.
68
+ export function codexStartupDiagnostic(line) {
69
+ if (/^mcp: pulse ready\s*$/.test(line.trim())) return { event: 'codex.mcp.startup', server: 'pulse', status: 'ready' };
70
+ if (/^mcp: pulse failed:/.test(line.trim())) return { event: 'codex.mcp.startup', server: 'pulse', status: 'failed' };
71
+ if (/^error: unexpected argument /.test(line.trim())) return { event: 'codex.startup.error', category: 'cli_arguments' };
72
+ return null;
73
+ }
package/src/process.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { mkdir, writeFile, rm } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
+ import { codexDiagnostics, codexStartupDiagnostic } from './diagnostics.js';
5
6
 
6
7
  export function command(executable, args, options = {}) {
7
8
  return new Promise((resolve, reject) => {
@@ -29,8 +30,10 @@ export function command(executable, args, options = {}) {
29
30
  options.signal?.addEventListener('abort', abort, { once: true });
30
31
  const timer = options.timeout ? setTimeout(abort, options.timeout) : null;
31
32
  let output = '';
32
- const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
33
- child.stdout.on('data', append); child.stderr.on('data', append);
33
+ const append = (chunk) => { if (options.captureOutput !== false) output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
34
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
35
+ child.stdout.on('data', (chunk) => { append(chunk); options.onStdout?.(chunk); });
36
+ child.stderr.on('data', (chunk) => { append(chunk); options.onStderr?.(chunk); });
34
37
  const cleanup = () => {
35
38
  if (timer) clearTimeout(timer);
36
39
  // Kill any descendants before releasing the process group on cancellation.
@@ -83,10 +86,15 @@ async function mcpConfig(dir, credential, endpoint) {
83
86
  }
84
87
 
85
88
  export function providerInvocation(kind, worktree, additionalDirs, config, mcp, prompt, mode = 'task') {
86
- if (kind === 'codex') return {
87
- executable: resolveExecutable('codex'),
88
- args: ['exec', '--ephemeral', ...agentPermissionArgs(kind, mode), ...(mode === 'review' ? ['--skip-git-repo-check'] : []), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url=${JSON.stringify(mcpEndpoint(config.endpoint))}`, '-c', 'mcp_servers.pulse.env_http_headers={Authorization="PULSE_RUNNER_MCP_CREDENTIAL"}', prompt],
89
- };
89
+ if (kind === 'codex') {
90
+ const reviewArgs = mode === 'review'
91
+ ? ['--json', '--skip-git-repo-check', '-c', 'mcp_servers.pulse.default_tools_approval_mode="approve"']
92
+ : [];
93
+ return {
94
+ executable: resolveExecutable('codex'),
95
+ args: ['exec', '--ephemeral', ...agentPermissionArgs(kind, mode), ...reviewArgs, '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url=${JSON.stringify(mcpEndpoint(config.endpoint))}`, '-c', 'mcp_servers.pulse.env_http_headers={Authorization="PULSE_RUNNER_MCP_CREDENTIAL"}', prompt],
96
+ };
97
+ }
90
98
  if (kind === 'claude') return {
91
99
  executable: resolveExecutable('claude'),
92
100
  args: ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt],
@@ -112,6 +120,44 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
112
120
  const invocation = providerInvocation(kind, agentDirectory, additionalDirs, config, mcp, prompt, job.mode);
113
121
  if (options.profile?.model) invocation.args.splice(kind === 'codex' ? 1 : 0, 0, '--model', options.profile.model);
114
122
  const env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
115
- return await command(invocation.executable, invocation.args, { cwd: agentDirectory, env, signal: options.signal });
123
+ const diagnosticMode = kind === 'codex' && job.mode === 'review';
124
+ let writes = Promise.resolve();
125
+ let stderrBytes = 0;
126
+ let stderrLine = '';
127
+ const stderrEvents = new Set();
128
+ const emit = (details) => {
129
+ // Keep order and drain writes before completion; sink failures are nonfatal.
130
+ writes = writes.then(() => options.onDiagnostic?.(details)).catch(() => {});
131
+ };
132
+ const diagnostics = diagnosticMode ? codexDiagnostics(emit) : null;
133
+ if (diagnosticMode) {
134
+ const version = await command(invocation.executable, ['--version'], { signal: options.signal, timeout: 10_000, maxOutput: 1000 }).catch(() => ({ output: '' }));
135
+ const cliVersion = /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(version.output.trim())?.[1] || 'unknown';
136
+ emit({ event: 'codex.startup', cliVersion, sandbox: 'read-only', ephemeral: true, json: true, mcpApproval: 'approve', credentialPresent: Boolean(credential), snapshotCount: reviewWorktrees.length });
137
+ }
138
+ try {
139
+ const result = await command(invocation.executable, invocation.args, { cwd: agentDirectory, env, signal: options.signal, captureOutput: !diagnosticMode, onStdout: diagnostics ? (chunk) => diagnostics.push(chunk) : undefined, onStderr: diagnosticMode ? (chunk) => {
140
+ stderrBytes += Buffer.byteLength(chunk);
141
+ for (const part of String(chunk).split(/(?<=\n)/)) {
142
+ stderrLine = (stderrLine + part).slice(-4096);
143
+ if (!part.endsWith('\n')) continue;
144
+ const safe = codexStartupDiagnostic(stderrLine);
145
+ stderrLine = '';
146
+ if (safe && !stderrEvents.has(JSON.stringify(safe))) { stderrEvents.add(JSON.stringify(safe)); emit(safe); }
147
+ }
148
+ } : undefined });
149
+ if (diagnostics) {
150
+ emit({ event: 'codex.process.exited', exitCode: result.code, stderrBytes });
151
+ const summary = diagnostics.finish();
152
+ result.output = `Codex QA: events=${summary.observed}, MCP started=${summary.mcpStarted}, completed=${summary.mcpCompleted}, failed=${summary.mcpFailed}, malformed=${summary.malformed}.`;
153
+ }
154
+ return result;
155
+ } catch (error) {
156
+ // Do not forward a spawn error that can include the executable's path.
157
+ if (!diagnosticMode) throw error;
158
+ diagnostics.finish();
159
+ emit({ event: 'codex.spawn.failed' });
160
+ throw new Error('Codex QA process could not start.');
161
+ } finally { await writes; }
116
162
  } finally { await rm(mcp, { force: true }); }
117
163
  }
package/src/runtime.js CHANGED
@@ -235,7 +235,7 @@ export async function runOnce(config, options = {}) {
235
235
  controller.signal.throwIfAborted();
236
236
  jobPhase = 'run-agent';
237
237
  await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
238
- const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile });
238
+ const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile, onDiagnostic: ({ event, ...details }) => logEvent(event, { jobId: job.id, ...details }) });
239
239
  controller.signal.throwIfAborted();
240
240
  await checkActive();
241
241
  await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { codexDiagnostic, codexDiagnostics, codexStartupDiagnostic } from '../src/diagnostics.js';
4
+ import { command, runAgent } from '../src/process.js';
5
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { tmpdir } from 'node:os';
8
+ const secret = 'opaque-session-secret';
9
+ const item = { id: secret, type: 'mcp_tool_call', server: 'pulse', tool: 'pulse_next_review', status: 'completed', arguments: { token: secret }, result: { content: [{ text: secret }] }, error: { message: secret } };
10
+
11
+ test('only allow-listed metadata survives untrusted MCP payloads', () => {
12
+ assert.deepEqual(codexDiagnostic({ type: 'item.completed', item }), { event: 'codex.mcp.call', lifecycle: 'item.completed', server: 'pulse', tool: 'pulse_next_review', status: 'completed', hasResult: true, hasError: true });
13
+ for (const changes of [{ server: secret }, { status: secret }, { type: 'agent_message', text: secret }]) assert.equal(codexDiagnostic({ type: 'item.completed', item: { ...item, ...changes } }), null);
14
+ assert.equal(codexDiagnostic({ type: 'item.completed', item: { ...item, tool: secret } }).tool, 'other');
15
+ assert.deepEqual(codexDiagnostic({ type: 'turn.failed', error: { message: secret } }), { event: 'codex.turn.failed' });
16
+ });
17
+ test('split, malformed, oversized and final lines remain bounded and safe', () => {
18
+ const events = [], stream = codexDiagnostics(e => events.push(e));
19
+ const started = JSON.stringify({ type: 'item.started', item: { ...item, status: 'in_progress' } });
20
+ stream.push(started.slice(0,35)); stream.push(started.slice(35)+'\n');
21
+ stream.push(secret+'\n'); stream.push('x'.repeat(300_000)); stream.push('\n');
22
+ stream.push(JSON.stringify({ type: 'item.completed', item }));
23
+ const summary = stream.finish();
24
+ assert.equal(summary.mcpStarted,1); assert.equal(summary.mcpCompleted,1); assert.equal(summary.malformed,2);
25
+ assert(!JSON.stringify(events).includes(secret));
26
+ });
27
+ test('zero MCP calls and bounded emission are explicit', () => {
28
+ const events = [], stream = codexDiagnostics(e => events.push(e));
29
+ for (let i=0;i<600;i++) stream.push('{"type":"turn.started"}\n');
30
+ assert.equal(stream.finish().mcpStarted,0); assert.equal(events.length,501); assert.equal(events.at(-1).truncated,true);
31
+ });
32
+ test('QA child JSONL is observed and secrets in stdout/stderr are discarded on failure', async () => {
33
+ const root = await mkdtemp(join(tmpdir(),'pulse-diagnostics-')), previous = process.env.PULSE_RUNNER_CODEX_BIN;
34
+ try {
35
+ const fake = join(root,'codex.mjs');
36
+ await writeFile(fake, `if (process.argv.includes('--version')) console.log('codex-cli 0.159.2'); else { console.log(${JSON.stringify(JSON.stringify({ type:'item.started', item:{...item,status:'in_progress'} }))}); console.log(${JSON.stringify(JSON.stringify({ type:'item.completed',item:{...item,status:'failed'} }))}); console.error(${JSON.stringify(secret)}); process.exitCode=1; }`);
37
+ process.env.PULSE_RUNNER_CODEX_BIN=fake;
38
+ const events=[];
39
+ const result=await runAgent('codex',{mode:'review',issueId:'issue-fixture',repoFullName:'owner/repo'},root,[],{endpoint:'https://example.invalid'},secret,join(root,'temp'),{onDiagnostic:async e=>events.push(e)});
40
+ assert.equal(result.code,1); assert.equal(events[0].cliVersion,'0.159.2'); assert.equal(events.at(-1).mcpFailed,1);
41
+ assert(!JSON.stringify({result,events}).includes(secret));
42
+ assert.equal(events.find(e=>e.event==='codex.process.exited').stderrBytes,Buffer.byteLength(secret+'\n'));
43
+ assert.equal((await command(fake,[],{captureOutput:false})).output,'');
44
+ } finally {
45
+ if(previous===undefined) delete process.env.PULSE_RUNNER_CODEX_BIN; else process.env.PULSE_RUNNER_CODEX_BIN=previous;
46
+ await rm(root,{recursive:true,force:true});
47
+ }
48
+ });
49
+
50
+ test('startup stderr recognizes only fixed Pulse states and CLI incompatibility', () => {
51
+ assert.deepEqual(codexStartupDiagnostic('mcp: pulse ready'), {event:'codex.mcp.startup', server:'pulse', status:'ready'});
52
+ assert.deepEqual(codexStartupDiagnostic('mcp: pulse failed: '+secret), {event:'codex.mcp.startup', server:'pulse', status:'failed'});
53
+ assert.deepEqual(codexStartupDiagnostic('error: unexpected argument '+secret), {event:'codex.startup.error', category:'cli_arguments'});
54
+ assert.equal(codexStartupDiagnostic(secret),null);
55
+ });
@@ -255,6 +255,15 @@ for (const kind of ['codex','claude']) test(`integration: ${kind} QA reads all p
255
255
  assert.equal(f.calls.filter(c=>c.path==='/pulseQaSource' && c.body.repo).length,3);
256
256
  assert.equal(published,false);
257
257
  assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
258
+ if (kind === 'codex') {
259
+ const raw = await readFile(join(paths().logs, 'runner.jsonl'), 'utf8');
260
+ const events = raw.trim().split('\n').map(JSON.parse);
261
+ assert.equal(events.find(e => e.event === 'codex.startup').snapshotCount, 3);
262
+ assert.equal(events.find(e => e.event === 'codex.diagnostics.summary').jobId, f.job.id);
263
+ assert.equal(events.find(e => e.event === 'codex.diagnostics.summary').mcpStarted, 0);
264
+ assert(!raw.includes('fixture-secret'));
265
+ assert(!JSON.stringify(f.calls.find(c=>c.path==='/pulseRunnerComplete').body).includes('fixture-secret'));
266
+ }
258
267
  }));
259
268
 
260
269
  test('integration: signed multi-repo worktrees are passed to the adapter and all cleaned',()=>fixture('codex',async f=>{
@@ -11,9 +11,15 @@ test('Codex QA starts outside the snapshots without trusting the repository or w
11
11
  const invocation = providerInvocation('codex', '/tmp/pulse-review', ['/tmp/snapshot'], { endpoint: 'https://example.cloudfunctions.net/pulseRunnerPoll' }, '/tmp/pulse-mcp.json', 'Review', 'review');
12
12
  assert(invocation.args.includes('--skip-git-repo-check'));
13
13
  assert.deepEqual(agentPermissionArgs('codex', 'review'), ['--sandbox', 'read-only']);
14
+ assert(invocation.args.includes('mcp_servers.pulse.default_tools_approval_mode="approve"'));
14
15
  assert(!invocation.args.includes('--ask-for-approval'));
15
16
  });
16
17
 
18
+ test('Codex development jobs do not auto-approve MCP tools', () => {
19
+ const invocation = providerInvocation('codex', '/tmp/pulse-task', [], { endpoint: 'https://example.cloudfunctions.net' }, '/tmp/pulse-mcp.json', 'Task', 'task');
20
+ assert(!invocation.args.some((arg) => arg.includes('default_tools_approval_mode')));
21
+ });
22
+
17
23
  test('development jobs retain their configured write permissions', () => {
18
24
  assert.deepEqual(agentPermissionArgs('codex', 'task'), ['--approve-for-me']);
19
25
  assert.deepEqual(agentPermissionArgs('claude', 'task'), ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions']);