@pulsehub/runner 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,21 @@
1
+ name: Pulse Runner
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ smoke:
10
+ name: smoke (${{ matrix.os }})
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ matrix:
14
+ os: [ubuntu-latest, macos-latest, windows-latest]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ - run: npm test
21
+ - run: node src/index.js --help
@@ -0,0 +1,26 @@
1
+ name: Publish Pulse Runner to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ publish:
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: read
15
+ id-token: write
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-node@v4
19
+ with:
20
+ node-version: 24
21
+ registry-url: https://registry.npmjs.org
22
+ - name: Verify release tag matches package version
23
+ run: test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME"
24
+ - run: npm test
25
+ - run: node src/index.js --help
26
+ - run: npm publish --access public
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # Pulse Runner
2
+
3
+ Install with Node 20+: `npm install -g @pulsehub/runner`.
4
+
5
+ To update an existing installation after a new release, run `npm update -g @pulsehub/runner`, then restart the background service with `pulse-runner service stop` followed by `pulse-runner service start`.
6
+
7
+ Releases are published to npm from GitHub Actions when a GitHub release is published. The release tag (for example, `v0.2.0`) must match the version in `package.json` (`0.2.0`).
8
+
9
+ Initialize the local machine with its workspace and agent identifiers plus the release signing public key, then paste the public key printed by `init` into Pulse Settings. The current release key is [`keys/runner-job-v1-public.pem`](keys/runner-job-v1-public.pem). Pulse returns a one-time device credential; `pair` derives and stores the Runner ID and credential with owner-only permissions. Use `diagnose` before `start`.
10
+
11
+ After a successful agent run, the Runner itself pushes every changed `pul/*` branch in the job's signed project worktrees and creates (or detects) its pull request through the local GitHub CLI. This avoids relying on an interactive agent approval for publication; `git` and `gh` must be authenticated locally.
12
+
13
+ The Runner only uses local Git and provider sessions, creates disposable worktrees under its application-data directory, and deletes per-job MCP configuration after each execution.
14
+
15
+ ## QA review jobs
16
+
17
+ 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.
@@ -0,0 +1,3 @@
1
+ -----BEGIN PUBLIC KEY-----
2
+ MCowBQYDK2VwAyEAbEgk63X5Y/uckKtBXu/e2M1R4RCe0669brRGUXjS5oM=
3
+ -----END PUBLIC KEY-----
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@pulsehub/runner",
3
+ "version": "0.1.0",
4
+ "description": "Local, credential-safe Pulse Runner",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/rafaeldavid20/pulse-runner.git"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "type": "module",
13
+ "bin": { "pulse-runner": "./src/index.js" },
14
+ "engines": { "node": ">=20" },
15
+ "scripts": { "test": "node --test", "smoke": "node src/index.js --help" }
16
+ }
package/src/config.js ADDED
@@ -0,0 +1,43 @@
1
+ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { homedir, platform } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export function dataDir() {
6
+ if (process.env.PULSE_RUNNER_HOME) return process.env.PULSE_RUNNER_HOME;
7
+ if (platform() === 'darwin') return join(homedir(), 'Library', 'Application Support', 'pulse-runner');
8
+ if (platform() === 'win32') return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'pulse-runner');
9
+ return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'pulse-runner');
10
+ }
11
+
12
+ export const paths = () => ({ root: dataDir(), config: join(dataDir(), 'config.json'), credential: join(dataDir(), 'device-credential'), repos: join(dataDir(), 'repos'), worktrees: join(dataDir(), 'worktrees'), logs: join(dataDir(), 'logs') });
13
+
14
+ export async function ensureDataDir() {
15
+ const p = paths();
16
+ await Promise.all(Object.values(p).filter((value) => value !== p.config && value !== p.credential).map((value) => mkdir(value, { recursive: true, mode: 0o700 })));
17
+ return p;
18
+ }
19
+
20
+ export async function readConfig() {
21
+ const p = paths();
22
+ try { return JSON.parse(await readFile(p.config, 'utf8')); }
23
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
24
+ }
25
+
26
+ export async function writeConfig(config) {
27
+ const p = await ensureDataDir();
28
+ await writeFile(p.config, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
29
+ await chmod(p.config, 0o600);
30
+ }
31
+
32
+ export async function readCredential() {
33
+ try { return (await readFile(paths().credential, 'utf8')).trim(); }
34
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
35
+ }
36
+
37
+ export async function writeCredential(value) {
38
+ const p = await ensureDataDir();
39
+ await writeFile(p.credential, `${value}\n`, { mode: 0o600 });
40
+ await chmod(p.credential, 0o600);
41
+ }
42
+
43
+ export async function removeTemporary(path) { await rm(path, { force: true, recursive: true }); }
package/src/index.js ADDED
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ import { generateKeyPairSync } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { platform } from 'node:os';
7
+ import { ensureDataDir, paths, readConfig, readCredential, removeTemporary, writeConfig, writeCredential } from './config.js';
8
+ import { validateJob, sanitize } from './job.js';
9
+ import { command, executable, runAgent } from './process.js';
10
+
11
+ const DEFAULT_ENDPOINT = 'https://us-east4-pulse-app-93.cloudfunctions.net';
12
+ const args = process.argv.slice(2);
13
+ const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
14
+ 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
+
17
+ async function request(config, path, body) {
18
+ const credential = await readCredential();
19
+ if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
20
+ const response = await fetch(`${config.endpoint}/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
21
+ if (!response.ok) throw new Error(`Pulse endpoint rejected request (${response.status}).`);
22
+ return response.json();
23
+ }
24
+
25
+ function repoPath(repo) { return repo.replace('/', '__'); }
26
+ async function checkoutRepo(repo, worktree) {
27
+ const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
28
+ const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
29
+ if (existing.code !== 0) {
30
+ const cloned = await command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror]);
31
+ if (cloned.code) throw new Error(sanitize(cloned.output));
32
+ }
33
+ const fetchResult = await command('git', ['--git-dir', mirror, 'fetch', '--prune', 'origin']); if (fetchResult.code) throw new Error(sanitize(fetchResult.output));
34
+ const added = await command('git', ['--git-dir', mirror, 'worktree', 'add', '--detach', worktree, 'HEAD']); if (added.code) throw new Error(sanitize(added.output));
35
+ const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD']);
36
+ if (base.code) throw new Error(sanitize(base.output));
37
+ return { repo, mirror, worktree, baseCommit: base.output.trim() };
38
+ }
39
+ async function checkout(job) {
40
+ const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
41
+ await mkdir(root, { recursive: true, mode: 0o700 });
42
+ try {
43
+ for (const repo of repos) {
44
+ const checkout = await checkoutRepo(repo, join(root, repoPath(repo)));
45
+ await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
46
+ checkouts.push(checkout);
47
+ }
48
+ const primary = checkouts.find((checkout) => checkout.repo === job.repoFullName);
49
+ if (!primary) throw new Error('Job context does not include its target repository.');
50
+ return { primary, checkouts };
51
+ } catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
52
+ }
53
+ async function cleanup(checkouts) {
54
+ await Promise.all(checkouts.map(async ({ worktree, mirror }) => {
55
+ await command('git', ['--git-dir', mirror, 'worktree', 'remove', '--force', worktree]).catch(() => {});
56
+ await removeTemporary(worktree);
57
+ }));
58
+ }
59
+ async function publishCheckout(checkout) {
60
+ const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current']);
61
+ const branch = branchResult.output.trim();
62
+ if (!branch || !branch.startsWith('pul/')) return '';
63
+ const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain']);
64
+ const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
65
+ if (uncommitted.length > 0) throw new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`);
66
+ const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD']);
67
+ if (head.code || head.output.trim() === checkout.baseCommit) return '';
68
+ // Sólo se publica una rama de trabajo Pulse de un worktree firmado como
69
+ // contexto de este job. El Runner no elige repos ni destinos externos.
70
+ const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`]);
71
+ if (pushed.code) throw new Error(sanitize(pushed.output));
72
+ const existing = await command('gh', ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url']);
73
+ if (existing.code === 0) return `Published ${checkout.repo}:${branch}; existing PR detected.`;
74
+ const created = await command('gh', ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill']);
75
+ if (created.code) throw new Error(sanitize(created.output));
76
+ return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
77
+ }
78
+ async function publish(checkouts) {
79
+ const publications = [];
80
+ for (const checkout of checkouts) {
81
+ const published = await publishCheckout(checkout);
82
+ if (published) publications.push(published);
83
+ }
84
+ return publications.join('\n');
85
+ }
86
+ async function runOnce(config) {
87
+ await request(config, 'pulseRunnerHeartbeat', { status: 'online' });
88
+ const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
89
+ const job = delivery.job;
90
+ try {
91
+ const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
92
+ validateJob(job, config, agent); await request(config, 'pulseRunnerHeartbeat', { status: 'busy' });
93
+ const { primary, checkouts } = await checkout(job); const temp = join(paths().root, 'tmp', job.id);
94
+ try {
95
+ const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((checkout) => checkout !== primary), config, delivery.mcpCredential, temp);
96
+ const publication = result.code === 0 && job.mode !== 'review' ? await publish(checkouts) : '';
97
+ await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: result.code === 0 ? 'completed' : 'failed', result: sanitize([result.output, publication].filter(Boolean).join('\n')) });
98
+ } finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
99
+ } catch (error) { await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: 'failed', result: sanitize(error.message) }).catch(() => {}); throw error; }
100
+ finally { await request(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {}); }
101
+ return true;
102
+ }
103
+
104
+ async function init() {
105
+ const publicKeyPath = value('--signing-public-key'); if (!publicKeyPath || !value('--workspace') || !value('--agent')) throw new Error('init requires --workspace, --agent and --signing-public-key.');
106
+ if (!has('--allow-autonomous')) throw new Error('Autonomous agent execution requires explicit --allow-autonomous confirmation.');
107
+ const { publicKey } = generateKeyPairSync('ed25519'); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
108
+ const signingPublicKey = await readFile(publicKeyPath, 'utf8');
109
+ 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 });
110
+ console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
111
+ }
112
+ async function syncRepos(config) { await request(config, 'pulseRunnerConfigure', { connectedRepos: config.repos }); }
113
+ 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); }
114
+ 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; }
115
+ async function service(config) {
116
+ const action = args[1]; const p = paths(); const definition = join(p.root, 'service-install.json'); const pidFile = join(p.root, 'service.pid');
117
+ 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}.`); }
118
+ if (action === 'status') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 0); return console.log(`Pulse Runner is running (pid ${pid}).`); } catch { return console.log('Pulse Runner is stopped.'); } }
119
+ if (action === 'start') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 0); return console.log(`Pulse Runner is already running (pid ${pid}).`); } catch {} const child = spawn(process.execPath, [new URL('./index.js', import.meta.url).pathname, 'start'], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env }); child.unref(); await writeFile(pidFile, JSON.stringify({ pid: child.pid }), { mode: 0o600 }); return console.log(`Pulse Runner started (pid ${child.pid}).`); }
120
+ if (action === 'stop') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 'SIGTERM'); } catch {} await rm(pidFile, { force: true }); return console.log('Pulse Runner stopped.'); }
121
+ throw new Error('Supported service commands: install, start, stop, status.');
122
+ }
123
+
124
+ try {
125
+ if (!args.length || has('--help')) usage();
126
+ else if (args[0] === 'init') await init();
127
+ 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.`); }
128
+ 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) { console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); } await new Promise((resolve) => setTimeout(resolve, backoff)); } } else usage(); }
129
+ } catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
package/src/job.js ADDED
@@ -0,0 +1,29 @@
1
+ import { verify } from 'node:crypto';
2
+
3
+ const b64 = (value) => Buffer.from(value, 'base64url');
4
+ export function jobPayload(job) {
5
+ const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])].sort().join(',');
6
+ return [job.id, job.workspaceId, job.issueId, job.agentId, job.runnerId, job.repoFullName, contextRepos, job.mode, job.issuedAt, job.expiresAt, job.signatureAlgorithm, job.signingKeyId].join('.');
7
+ }
8
+
9
+ /** Reject an envelope before it can cause filesystem or process activity. */
10
+ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.now()) {
11
+ const agent = typeof agentOrNow === 'object' && agentOrNow !== null ? agentOrNow : null;
12
+ const now = typeof agentOrNow === 'number' ? agentOrNow : suppliedNow;
13
+ if (!job || job.signatureAlgorithm !== 'ed25519' || job.signingKeyId !== config.signingKeyId) throw new Error('Job signing key is not trusted.');
14
+ if (!config.signingPublicKey || !verify(null, Buffer.from(jobPayload(job)), config.signingPublicKey, b64(job.signature))) throw new Error('Job signature is invalid.');
15
+ if (!job.id?.startsWith('rjob-') || new Date(job.expiresAt).getTime() <= now) throw new Error('Job is expired or malformed.');
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
+ }
22
+ const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
23
+ 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
+ return true;
25
+ }
26
+
27
+ export function sanitize(value) {
28
+ return String(value || '').slice(-500).replace(/\b(?:sk-ant-|sk-proj-|sk-|pulse_sk_|ghp_|github_pat_)[A-Za-z0-9_-]+/gi, '[redacted]').replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]');
29
+ }
package/src/process.js ADDED
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, writeFile, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+
6
+ export function command(command, args, options = {}) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
9
+ 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 }));
12
+ });
13
+ }
14
+
15
+ export function resolveExecutable(name) {
16
+ const override = process.env[`PULSE_RUNNER_${name.toUpperCase()}_BIN`];
17
+ if (override) return override;
18
+ // ChatGPT for macOS bundles Codex instead of placing it on PATH. The
19
+ // fallback keeps a launchd/background Runner usable without shell startup
20
+ // files while remaining overridable for custom installations.
21
+ if (name === 'codex' && existsSync('/Applications/ChatGPT.app/Contents/Resources/codex')) return '/Applications/ChatGPT.app/Contents/Resources/codex';
22
+ return name;
23
+ }
24
+
25
+ export async function executable(name) { try { return (await command(resolveExecutable(name), ['--version'])).code === 0; } catch { return false; } }
26
+
27
+ export function agentPermissionArgs(kind, mode) {
28
+ if (mode === 'review') {
29
+ if (kind === 'codex') return ['--sandbox', 'read-only', '--ask-for-approval', 'never'];
30
+ if (kind === 'claude') return ['--permission-mode', 'plan'];
31
+ }
32
+ if (kind === 'codex') return ['--approve-for-me'];
33
+ if (kind === 'claude') return ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions'];
34
+ return [];
35
+ }
36
+
37
+ async function mcpConfig(dir, credential, endpoint) {
38
+ const path = join(dir, 'pulse-mcp.json');
39
+ 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 });
41
+ return path;
42
+ }
43
+
44
+ export async function runAgent(kind, job, worktree, additionalWorktrees, config, credential, tempDir) {
45
+ const context = additionalWorktrees.length
46
+ ? job.mode === 'review'
47
+ ? ` Additional review worktrees for inspection: ${additionalWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}.`
48
+ : ` Additional writable project worktrees: ${additionalWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}. They are authorized for this same job: do not request a repository handoff. For every repository you change, create a pul/* branch in that worktree and commit the changes; the Runner will publish a PR for each changed branch.`
49
+ : '';
50
+ const prompt = job.mode === 'review'
51
+ ? `You are Pulse Runner performing a QA review for issue ${job.issueId}. First call pulse_next_review with identifier "${job.issueId}"; if found is false, stop without reviewing. Then call pulse_get_review_context with the same identifier. Review every pull request and its diff returned by that tool, along with the issue acceptance criteria and project Definition of Done. Use the provided local worktrees only to inspect files when a diff is too large or more context is needed. Do not modify files, commit, push, or create pull requests. Submit the evidence-based verdict with pulse_submit_review. If you cannot submit a verdict for any reason, call pulse_report_review_incomplete before ending. Do not print, persist, or transmit credentials.${context}`
52
+ : `You are Pulse Runner executing ${job.mode} for issue ${job.issueId}. Use the Pulse MCP server for issue context and reporting. Work only in the provided project worktrees. Do not print, persist, or transmit credentials.${context}`;
53
+ const mcp = await mcpConfig(tempDir, credential, config.endpoint);
54
+ const additionalDirs = additionalWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
55
+ try {
56
+ // `--approve-for-me` selects Codex's workspace-write sandbox itself.
57
+ // Supplying both flags is rejected by current Codex releases.
58
+ 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 });
60
+ }
61
+ if (kind === 'claude') {
62
+ return await command(resolveExecutable('claude'), ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, job.mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt], { cwd: worktree });
63
+ }
64
+ throw new Error(`Unsupported agent kind '${kind}'.`);
65
+ } finally { await rm(mcp, { force: true }); }
66
+ }
@@ -0,0 +1,15 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generateKeyPairSync, sign } from 'node:crypto';
4
+ import { jobPayload, sanitize, validateJob } from '../src/job.js';
5
+
6
+ test('accepts a signed, bound job and rejects changed identity', () => {
7
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
8
+ const job = { id: 'rjob-123', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repoFullName: 'owner/repo', contextRepos: ['owner/repo', 'owner/app'], mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'runner-job-v1' };
9
+ job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url');
10
+ const config = { workspaceId: 'ws-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repos: ['owner/repo', 'owner/app'], signingKeyId: 'runner-job-v1', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
11
+ assert.equal(validateJob(job, config), true);
12
+ assert.throws(() => validateJob({ ...job, repoFullName: 'owner/other' }, config));
13
+ assert.throws(() => validateJob({ ...job, contextRepos: ['owner/repo'] }, config));
14
+ });
15
+ test('sanitizes provider tokens', () => assert.equal(sanitize('Bearer secret.token sk-ant-secret'), 'Bearer [redacted] [redacted]'));
@@ -0,0 +1,13 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { agentPermissionArgs } from '../src/process.js';
4
+
5
+ test('QA Runner sessions use read-only filesystem permissions', () => {
6
+ assert.deepEqual(agentPermissionArgs('codex', 'review'), ['--sandbox', 'read-only', '--ask-for-approval', 'never']);
7
+ assert.deepEqual(agentPermissionArgs('claude', 'review'), ['--permission-mode', 'plan']);
8
+ });
9
+
10
+ test('development jobs retain their configured write permissions', () => {
11
+ assert.deepEqual(agentPermissionArgs('codex', 'task'), ['--approve-for-me']);
12
+ assert.deepEqual(agentPermissionArgs('claude', 'task'), ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions']);
13
+ });