@pulsehub/runner 0.1.3 → 0.1.4

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
@@ -16,4 +16,26 @@ The Runner only uses local Git and provider sessions, creates disposable worktre
16
16
 
17
17
  ## QA review jobs
18
18
 
19
- An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key, connect every repository that has a PR in the review, pair its one-time device credential, and select it on the QA agent. Review jobs call `pulse_next_review` and `pulse_get_review_context` through Pulse MCP, inspect every PR, and submit their verdict with `pulse_submit_review`. They do not publish source changes or create PRs.
19
+ An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize each QA instance with `--agent-role qa`, and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key, connect every repository that has a PR in the review, pair its one-time device credential, and select it on the QA agent. Review jobs call `pulse_next_review` and `pulse_get_review_context` through Pulse MCP, inspect every PR, and submit their verdict with `pulse_submit_review`. They do not publish source changes or create PRs.
20
+
21
+ ## Preparation and preflight (TES-304)
22
+
23
+ In Pulse Settings, select a Runner on the agent, then choose **Verificar preparación**. The expected local identity is shown there. On the Runner machine, under the same OS user as the service, register it explicitly:
24
+
25
+ ```sh
26
+ pulse-runner identity add --agent AGENT_ID --agent-kind codex --agent-role dev
27
+ # For a QA agent (Claude is also supported):
28
+ pulse-runner identity add --agent QA_AGENT_ID --agent-kind claude --agent-role qa
29
+ pulse-runner identity list
30
+ pulse-runner diagnose
31
+ ```
32
+
33
+ `identity remove --agent ID` removes an identity. Each job must match an explicitly configured agent, provider and role, including QA jobs. Use separate `PULSE_RUNNER_HOME` directories for independent local instances. The service reloads identity configuration on each polling cycle.
34
+
35
+ Sign in locally with `codex login` or `claude auth login`. Diagnostics use the providers' native status commands and Git `ls-remote`; Pulse receives only agent/provider/role identifiers and CLI/session/repository availability booleans. Account information, command output and provider credentials are discarded locally. A status check confirms locally available authentication, not that a later provider request cannot fail. The daemon refreshes readiness at least once per minute and runs a fresh local check before executing a delivered job. Probes time out without opening interactive login prompts.
36
+
37
+ Backend dispatch requires a recent heartbeat and readiness report, a matching identity/provider/role, repository authorization and access, and capacity. It checks these centrally for tasks, rework, handoff, retry and QA; delivery checks again before issuing a job MCP credential. Failed jobs carry a phase/category and the job ID used in `logs/runner.jsonl`.
38
+
39
+ Rollout: upgrade the Runner and configure all Dev/QA identities before enabling the new backend preflight. Older Runners without readiness reports will be blocked with an actionable message. The frontend and backend changes are required together for the preparation panel. Manual UI and live provider verification remain part of TES-308.
40
+
41
+ Authentication command references: [Codex CLI](https://developers.openai.com/codex/cli/reference), [Claude CLI](https://code.claude.com/docs/en/cli-reference).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local, credential-safe Pulse Runner",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
2
3
  import { generateKeyPairSync } from 'node:crypto';
3
4
  import { spawn } from 'node:child_process';
4
5
  import { appendFile, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
@@ -6,13 +7,13 @@ import { join } from 'node:path';
6
7
  import { platform } from 'node:os';
7
8
  import { ensureDataDir, paths, readConfig, readCredential, removeTemporary, writeConfig, writeCredential } from './config.js';
8
9
  import { validateJob, sanitize } from './job.js';
9
- import { command, executable, runAgent } from './process.js';
10
+ import { command, runAgent } from './process.js';
10
11
 
11
12
  const DEFAULT_ENDPOINT = 'https://us-east4-pulse-app-93.cloudfunctions.net';
12
13
  const args = process.argv.slice(2);
13
14
  const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
14
15
  const has = (flag) => args.includes(flag);
15
- const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|repo|service>\n\ninit --workspace WS --agent AGENT --signing-public-key FILE --allow-autonomous [--endpoint URL] [--repo owner/repo]\npair --credential runner.secret\nstart | run --once | diagnose | repo add|remove|list | service install|start|stop|status`);
16
+ const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|identity|repo|service>\n\ninit --workspace WS --agent AGENT [--agent-kind codex|claude] [--agent-role dev|qa] --signing-public-key FILE --allow-autonomous [--endpoint URL] [--repo owner/repo]\npair --credential runner.secret\nidentity add|remove --agent ID --agent-kind codex|claude --agent-role dev|qa | identity list\nstart | run --once | diagnose | repo add|remove|list | service install|start|stop|status`);
16
17
 
17
18
  async function logEvent(event, details = {}) {
18
19
  try {
@@ -141,8 +142,15 @@ async function publish(checkouts, jobId) {
141
142
  }
142
143
  return publications;
143
144
  }
145
+ let readinessCache;
146
+ async function readinessFor(config, force = false) {
147
+ const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos]);
148
+ if (force || !readinessCache || readinessCache.key !== key || Date.now() - readinessCache.at > 60_000) readinessCache = { key, at: Date.now(), value: await inspectReadiness(config) };
149
+ return readinessCache.value;
150
+ }
144
151
  async function runOnce(config) {
145
- await request(config, 'pulseRunnerHeartbeat', { status: 'online' });
152
+ const readiness = await readinessFor(config);
153
+ await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
146
154
  const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
147
155
  const job = delivery.job;
148
156
  await logEvent('job.received', { jobId: job.id, issueId: job.issueId, agentId: job.agentId, mode: job.mode, repo: job.repoFullName });
@@ -150,6 +158,8 @@ async function runOnce(config) {
150
158
  try {
151
159
  const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
152
160
  validateJob(job, config, agent);
161
+ jobPhase = 'local-preflight';
162
+ assertLocalReadiness(job, config, agent, await readinessFor(config, true));
153
163
  await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
154
164
  jobPhase = 'prepare-worktrees';
155
165
  await request(config, 'pulseRunnerHeartbeat', { status: 'busy' });
@@ -167,15 +177,16 @@ async function runOnce(config) {
167
177
  const publication = shouldPublish ? await publish(checkouts, job.id) : { succeeded: [], failed: [] };
168
178
  const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
169
179
  const summary = completionSummary(result, publication);
180
+ const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
170
181
  jobPhase = 'report-completion';
171
- await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome, result: sanitize(summary) });
182
+ await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome, result: sanitize(summary), failure });
172
183
  await logEvent('job.completed', { jobId: job.id, outcome, publicationFailures: publication.failed.length });
173
184
  } finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
174
185
  } catch (error) {
175
186
  const phase = error.phase || jobPhase;
176
187
  const message = error?.message || String(error);
177
188
  await logEvent('job.failed', { jobId: job.id, issueId: job.issueId, repo: job.repoFullName, phase, error: message });
178
- await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: 'failed', result: sanitize(`${phase}: ${message}`) }).catch((completionError) => {
189
+ await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: 'failed', result: sanitize(`${phase}: ${message}`), failure: { phase, category: phase === 'validate-job' ? 'configuration' : ['local-preflight', 'prepare-worktrees'].includes(phase) ? 'local_preparation' : 'execution', correlationId: job.id } }).catch((completionError) => {
179
190
  logEvent('job.completion-report-failed', { jobId: job.id, error: completionError?.message || String(completionError) });
180
191
  });
181
192
  throw error;
@@ -187,14 +198,34 @@ async function runOnce(config) {
187
198
  async function init() {
188
199
  const publicKeyPath = value('--signing-public-key'); if (!publicKeyPath || !value('--workspace') || !value('--agent')) throw new Error('init requires --workspace, --agent and --signing-public-key.');
189
200
  if (!has('--allow-autonomous')) throw new Error('Autonomous agent execution requires explicit --allow-autonomous confirmation.');
201
+ if (!['codex', 'claude'].includes(value('--agent-kind') || 'codex') || !['dev', 'qa'].includes(value('--agent-role') || 'dev')) throw new Error('init requires a valid --agent-kind codex|claude and --agent-role dev|qa.');
190
202
  const { publicKey } = generateKeyPairSync('ed25519'); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
191
203
  const signingPublicKey = await readFile(publicKeyPath, 'utf8');
192
- await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos: args.filter((item, index) => args[index - 1] === '--repo'), autonomousConfirmed: true });
204
+ await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', agentRole: value('--agent-role') || 'dev', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos: args.filter((item, index) => args[index - 1] === '--repo'), autonomousConfirmed: true });
193
205
  console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
194
206
  }
195
207
  async function syncRepos(config) { await request(config, 'pulseRunnerConfigure', { connectedRepos: config.repos }); }
196
208
  async function repo(config) { const op = args[1]; const name = args[2]; if (op === 'list') return console.log(config.repos.join('\n')); if (!name || !/^[^/\s]+\/[^/\s]+$/.test(name)) throw new Error('Repository must be owner/repo.'); const repos = new Set(config.repos); op === 'add' ? repos.add(name) : repos.delete(name); const updated = { ...config, repos: [...repos].sort() }; await writeConfig(updated); await syncRepos(updated); }
197
- async function diagnose(config) { const checks = { paired: !!(await readCredential()), git: await executable('git'), githubCli: await executable('gh'), codex: await executable('codex'), claude: await executable('claude'), configuredRepos: config.repos.length, platform: platform() }; console.log(JSON.stringify(checks, null, 2)); if (!checks.git || !checks.githubCli) process.exitCode = 1; }
209
+ async function diagnose(config) {
210
+ const readiness = await readinessFor(config, true);
211
+ console.log(JSON.stringify(readiness, null, 2));
212
+ if (await readCredential()) await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
213
+ const providers = localIdentities(config).map((identity) => readiness.providers[identity.kind]);
214
+ if (providers.some((provider) => !provider?.cli || !provider?.session) || readiness.repositories.some((repo) => !repo.accessible)) process.exitCode = 1;
215
+ }
216
+ async function identity(config) {
217
+ if (args[1] === 'list') return console.log(JSON.stringify(localIdentities(config), null, 2));
218
+ const agentId = value('--agent');
219
+ const kind = value('--agent-kind');
220
+ const role = value('--agent-role');
221
+ if (!agentId || !/^[-A-Za-z0-9_]{1,100}$/.test(agentId) || !['add', 'remove'].includes(args[1])) throw new Error('Use identity add|remove --agent ID [--agent-kind codex|claude --agent-role dev|qa].');
222
+ if (args[1] === 'add' && (!['codex', 'claude'].includes(kind) || !['dev', 'qa'].includes(role))) throw new Error('identity add requires a valid provider and role.');
223
+ const identities = localIdentities(config).filter((entry) => entry.agentId !== agentId);
224
+ if (args[1] === 'add') identities.push({ agentId, kind, role });
225
+ const updated = { ...config, identities };
226
+ await writeConfig(updated);
227
+ await diagnose(updated);
228
+ }
198
229
  async function service(config) {
199
230
  const action = args[1]; const p = paths(); const definition = join(p.root, 'service-install.json'); const pidFile = join(p.root, 'service.pid');
200
231
  if (action === 'install') { await mkdir(p.logs, { recursive: true, mode: 0o700 }); await writeFile(definition, JSON.stringify({ command: process.execPath, args: [new URL('./index.js', import.meta.url).pathname, 'start'], platform: platform() }, null, 2), { mode: 0o600 }); return console.log(`Service definition prepared at ${definition}.`); }
@@ -208,5 +239,5 @@ try {
208
239
  if (!args.length || has('--help')) usage();
209
240
  else if (args[0] === 'init') await init();
210
241
  else if (args[0] === 'pair') { const credential = value('--credential'); const match = credential?.match(/^(runner-[A-Za-z0-9_-]{12})\.[A-Za-z0-9_-]{32,}$/); if (!match) throw new Error('Invalid Runner credential.'); const config = await readConfig(); if (!config) throw new Error('Run pulse-runner init before pairing.'); const paired = { ...config, runnerId: match[1] }; await writeCredential(credential); await writeConfig(paired); await syncRepos(paired); console.log(`Runner ${paired.runnerId} paired.`); }
211
- else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === 'repo') await repo(config); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(config); else if (args[0] === 'run') await runOnce(config); else if (args[0] === 'start') { let backoff = 5_000; for (;;) { try { const ranJob = await runOnce(config); backoff = ranJob ? 1_000 : 5_000; } catch (error) { await logEvent('service.loop-failed', { error: error.message }); console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); } await new Promise((resolve) => setTimeout(resolve, backoff)); } } else usage(); }
242
+ else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === 'identity') await identity(config); else if (args[0] === 'repo') await repo(config); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(config); else if (args[0] === 'run') await runOnce(config); else if (args[0] === 'start') { let backoff = 5_000; for (;;) { try { const ranJob = await runOnce((await readConfig()) || config); backoff = ranJob ? 1_000 : 5_000; } catch (error) { await logEvent('service.loop-failed', { error: error.message }); console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); } await new Promise((resolve) => setTimeout(resolve, backoff)); } } else usage(); }
212
243
  } catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
package/src/job.js CHANGED
@@ -14,11 +14,9 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
14
14
  if (!config.signingPublicKey || !verify(null, Buffer.from(jobPayload(job)), config.signingPublicKey, b64(job.signature))) throw new Error('Job signature is invalid.');
15
15
  if (!job.id?.startsWith('rjob-') || new Date(job.expiresAt).getTime() <= now) throw new Error('Job is expired or malformed.');
16
16
  if (job.workspaceId !== config.workspaceId || job.runnerId !== config.runnerId) throw new Error('Job identity does not match this Runner.');
17
- if (job.mode === 'review') {
18
- if (!agent || agent.role !== 'qa' || agent.kind !== config.agentKind) throw new Error('QA review identity does not match this Runner provider.');
19
- } else if (job.agentId !== config.agentId || (agent && agent.kind !== config.agentKind)) {
20
- throw new Error('Job identity does not match this Runner.');
21
- }
17
+ const identity = (config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }]).find((entry) => entry.agentId === job.agentId);
18
+ const role = job.mode === 'review' ? 'qa' : 'dev';
19
+ if (!identity || identity.role !== role || (agent && (agent.kind !== identity.kind || agent.role !== role))) throw new Error('Job identity does not match this Runner provider/role.');
22
20
  const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
23
21
  if (!contextRepos.includes(job.repoFullName) || contextRepos.some((repo) => !config.repos.includes(repo))) throw new Error('Job repository context is not allowed by this Runner.');
24
22
  return true;
package/src/process.js CHANGED
@@ -6,9 +6,11 @@ import { join } from 'node:path';
6
6
  export function command(command, args, options = {}) {
7
7
  return new Promise((resolve, reject) => {
8
8
  const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
9
+ const timer = options.timeout ? setTimeout(() => child.kill('SIGKILL'), options.timeout) : null;
9
10
  let output = '';
10
- child.stdout.on('data', (chunk) => { output += chunk; }); child.stderr.on('data', (chunk) => { output += chunk; });
11
- child.on('error', reject); child.on('close', (code) => resolve({ code, output }));
11
+ const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
12
+ child.stdout.on('data', append); child.stderr.on('data', append);
13
+ child.on('error', (error) => { if (timer) clearTimeout(timer); reject(error); }); child.on('close', (code) => { if (timer) clearTimeout(timer); resolve({ code, output }); });
12
14
  });
13
15
  }
14
16
 
@@ -34,10 +36,14 @@ export function agentPermissionArgs(kind, mode) {
34
36
  return [];
35
37
  }
36
38
 
39
+ export function mcpEndpoint(endpoint) {
40
+ return endpoint.replace(/\/(?:pulseRunner\w*|pulseMcp)\/?$/, '').replace(/\/$/, '') + '/pulseMcp';
41
+ }
42
+
37
43
  async function mcpConfig(dir, credential, endpoint) {
38
44
  const path = join(dir, 'pulse-mcp.json');
39
45
  await mkdir(dir, { recursive: true, mode: 0o700 });
40
- await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: endpoint.replace(/\/pulseRunner(?:\w+)?$/, '/pulseMcp'), headers: { Authorization: `Bearer ${credential}` } } } }), { mode: 0o600 });
46
+ await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: mcpEndpoint(endpoint), headers: { Authorization: `Bearer ${credential}` } } } }), { mode: 0o600 });
41
47
  return path;
42
48
  }
43
49
 
@@ -56,7 +62,7 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
56
62
  // `--approve-for-me` selects Codex's workspace-write sandbox itself.
57
63
  // Supplying both flags is rejected by current Codex releases.
58
64
  if (kind === 'codex') {
59
- return await command(resolveExecutable('codex'), ['exec', '--ephemeral', ...agentPermissionArgs(kind, job.mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url="${config.endpoint.replace(/\"/g, '\\\"')}/pulseMcp"`, '-c', `mcp_servers.pulse.http_headers={Authorization="Bearer ${credential}"}`, prompt], { cwd: worktree });
65
+ return await command(resolveExecutable('codex'), ['exec', '--ephemeral', ...agentPermissionArgs(kind, job.mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url="${mcpEndpoint(config.endpoint).replace(/\"/g, '\\\"')}"`, '-c', `mcp_servers.pulse.http_headers={Authorization="Bearer ${credential}"}`, prompt], { cwd: worktree });
60
66
  }
61
67
  if (kind === 'claude') {
62
68
  return await command(resolveExecutable('claude'), ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, job.mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt], { cwd: worktree });
@@ -0,0 +1,35 @@
1
+ import { command, resolveExecutable } from './process.js';
2
+
3
+ export function localIdentities(config) {
4
+ return config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }];
5
+ }
6
+
7
+ export async function inspectReadiness(config, run = command) {
8
+ const providers = {};
9
+ for (const kind of ['codex', 'claude']) {
10
+ const cli = await run(resolveExecutable(kind), ['--version'], { timeout: 10_000, maxOutput: 4096 }).then((result) => result.code === 0).catch(() => false);
11
+ const session = cli && await run(resolveExecutable(kind), kind === 'codex' ? ['login', 'status'] : ['auth', 'status'], { timeout: 10_000, maxOutput: 4096 }).then((result) => {
12
+ if (result.code !== 0) return false;
13
+ // Claude can exit successfully while reporting loggedIn:false. Never
14
+ // return its raw JSON (which may include account identifiers).
15
+ if (kind === 'claude') { try { return JSON.parse(result.output).loggedIn === true; } catch { return false; } }
16
+ return true;
17
+ }).catch(() => false);
18
+ providers[kind] = { cli, session: !!session };
19
+ }
20
+ const repositories = [];
21
+ for (const repo of config.repos) {
22
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error('Invalid repository.');
23
+ const accessible = await run('git', ['ls-remote', '--exit-code', `https://github.com/${repo}.git`, 'HEAD'], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 10_000, maxOutput: 4096 }).then((result) => result.code === 0).catch(() => false);
24
+ repositories.push({ repo, accessible });
25
+ }
26
+ return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories };
27
+ }
28
+
29
+ export function assertLocalReadiness(job, config, agent, readiness) {
30
+ const identity = localIdentities(config).find((entry) => entry.agentId === job.agentId);
31
+ const role = job.mode === 'review' ? 'qa' : 'dev';
32
+ if (!identity || identity.kind !== agent.kind || identity.role !== role || agent.role !== role) throw new Error('Local agent identity/provider/role mismatch. Run pulse-runner identity add with this agent configuration.');
33
+ if (!readiness.providers[agent.kind]?.cli || !readiness.providers[agent.kind]?.session) throw new Error(`Local ${agent.kind} CLI/session is unavailable. Sign in locally and run pulse-runner diagnose.`);
34
+ if ((job.contextRepos || [job.repoFullName]).some((repo) => !readiness.repositories.some((entry) => entry.repo === repo && entry.accessible))) throw new Error('Local repository access is unavailable. Check Git authentication and run pulse-runner diagnose.');
35
+ }
package/test/job.test.js CHANGED
@@ -13,3 +13,14 @@ test('accepts a signed, bound job and rejects changed identity', () => {
13
13
  assert.throws(() => validateJob({ ...job, contextRepos: ['owner/repo'] }, config));
14
14
  });
15
15
  test('sanitizes provider tokens', () => assert.equal(sanitize('Bearer secret.token sk-ant-secret'), 'Bearer [redacted] [redacted]'));
16
+
17
+ test('signed QA jobs require explicit agent identity and provider/role compatibility', () => {
18
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
19
+ const job = { id: 'rjob-qa', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'qa-1', runnerId: 'runner-1', repoFullName: 'owner/repo', mode: 'review', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'key' };
20
+ job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url');
21
+ const config = { workspaceId: 'ws-1', runnerId: 'runner-1', agentId: 'dev-1', agentKind: 'codex', repos: ['owner/repo'], signingKeyId: 'key', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
22
+ assert.throws(() => validateJob(job, config, { kind: 'codex', role: 'qa' }), /identity/);
23
+ config.identities = [{ agentId: 'qa-1', kind: 'codex', role: 'qa' }];
24
+ assert.equal(validateJob(job, config, { kind: 'codex', role: 'qa' }), true);
25
+ assert.throws(() => validateJob(job, config, { kind: 'claude', role: 'qa' }), /provider/);
26
+ });
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { agentPermissionArgs } from '../src/process.js';
3
+ import { agentPermissionArgs, mcpEndpoint } from '../src/process.js';
4
4
 
5
5
  test('QA Runner sessions use read-only filesystem permissions', () => {
6
6
  assert.deepEqual(agentPermissionArgs('codex', 'review'), ['--sandbox', 'read-only', '--ask-for-approval', 'never']);
@@ -11,3 +11,9 @@ test('development jobs retain their configured write permissions', () => {
11
11
  assert.deepEqual(agentPermissionArgs('codex', 'task'), ['--approve-for-me']);
12
12
  assert.deepEqual(agentPermissionArgs('claude', 'task'), ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions']);
13
13
  });
14
+
15
+ test('both providers use the same MCP URL for cloud and local emulator endpoints', () => {
16
+ assert.equal(mcpEndpoint('https://example.cloudfunctions.net'), 'https://example.cloudfunctions.net/pulseMcp');
17
+ assert.equal(mcpEndpoint('http://127.0.0.1:5001/demo-pulse-local/us-east4'), 'http://127.0.0.1:5001/demo-pulse-local/us-east4/pulseMcp');
18
+ assert.equal(mcpEndpoint('http://localhost:5001/demo-pulse-local/us-east4/pulseRunnerPoll'), 'http://localhost:5001/demo-pulse-local/us-east4/pulseMcp');
19
+ });
@@ -0,0 +1,34 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { inspectReadiness, assertLocalReadiness } from '../src/readiness.js';
4
+ const config = { workspaceId: 'ws-1', agentId: 'agent-1', agentKind: 'codex', repos: ['owner/repo'] };
5
+ const job = { agentId: 'agent-1', repoFullName: 'owner/repo', mode: 'task' };
6
+ const agent = { kind: 'codex', role: 'dev' };
7
+ test('readiness uses native status commands and reports only booleans', async () => {
8
+ const calls = [];
9
+ const report = await inspectReadiness(config, async (cmd, args) => {
10
+ calls.push(args);
11
+ return { code: 0, output: args[0] === 'auth' ? JSON.stringify({ loggedIn: true, email: 'private', token: 'private' }) : 'private' };
12
+ });
13
+ assert.ok(calls.some((args) => args.join(' ') === 'login status'));
14
+ assert.ok(calls.some((args) => args.join(' ') === 'auth status'));
15
+ assert.equal(JSON.stringify(report).includes('private'), false);
16
+ assert.doesNotThrow(() => assertLocalReadiness(job, config, agent, report));
17
+ assert.throws(() => assertLocalReadiness({ ...job, agentId: 'other' }, config, agent, report), /identity/);
18
+ });
19
+ test('CLI missing, session false and Git denial remain unavailable', async () => {
20
+ const report = await inspectReadiness(config, async (cmd, args) => {
21
+ if (cmd.includes('codex')) throw new Error('missing');
22
+ return { code: args[0] === 'ls-remote' ? 1 : 0, output: '{"loggedIn":false}' };
23
+ });
24
+ assert.equal(report.providers.codex.cli, false);
25
+ assert.equal(report.providers.claude.session, false);
26
+ assert.equal(report.repositories[0].accessible, false);
27
+ assert.throws(() => assertLocalReadiness(job, config, agent, report), /CLI/);
28
+ });
29
+ test('QA identity is explicitly allowed and cannot execute development jobs', async () => {
30
+ const qaConfig = { ...config, identities: [{ agentId: 'qa-1', kind: 'claude', role: 'qa' }] };
31
+ const report = await inspectReadiness(qaConfig, async () => ({ code: 0, output: '{"loggedIn":true}' }));
32
+ assert.doesNotThrow(() => assertLocalReadiness({ ...job, agentId: 'qa-1', mode: 'review' }, qaConfig, { kind: 'claude', role: 'qa' }, report));
33
+ assert.throws(() => assertLocalReadiness({ ...job, agentId: 'qa-1' }, qaConfig, { kind: 'claude', role: 'qa' }, report), /role/);
34
+ });