@pulsehub/runner 0.1.3 → 0.1.5

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,269 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generateKeyPairSync, sign } from 'node:crypto';
4
+ import { createServer } from 'node:http';
5
+ import { mkdtemp, mkdir, readFile, writeFile, rm, access } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { paths, ensureDataDir, writeCredential } from '../src/config.js';
9
+ import { jobPayload } from '../src/job.js';
10
+ import { command } from '../src/process.js';
11
+ import { inspectReadiness } from '../src/readiness.js';
12
+ import { runOnce, recoverInterruptedWork, withRunnerLock } from '../src/runtime.js';
13
+
14
+ const exists = (file) => access(file).then(() => true).catch(() => false);
15
+ async function git(args) { const result = await command('git', args); assert.equal(result.code, 0, result.output); return result.output.trim(); }
16
+
17
+ async function fixture(kind, callback) {
18
+ const root = await mkdtemp(join(tmpdir(), 'pulse-runtime-'));
19
+ const saved = { ...process.env };
20
+ process.env.PULSE_RUNNER_HOME = join(root, 'runner');
21
+ const source = join(root, 'source');
22
+ const trace = join(root, 'trace.json');
23
+ const fake = join(root, 'provider.mjs');
24
+ await writeFile(fake, `
25
+ import { writeFileSync, readFileSync } from 'node:fs';
26
+ import { spawn, spawnSync } from 'node:child_process';
27
+ const args = process.argv.slice(2);
28
+ const settings = JSON.parse(readFileSync(${JSON.stringify(join(root, 'settings.json'))}, 'utf8'));
29
+ if (args.includes('--version')) console.log('fixture-cli');
30
+ else if (args[0] === 'login') process.exit(settings.session ? 0 : 1);
31
+ else if (args[0] === 'auth') console.log(JSON.stringify({loggedIn: settings.session}));
32
+ else {
33
+ const file = args[args.indexOf('--mcp-config') + 1];
34
+ const received = args.includes('exec') ? !!process.env.PULSE_RUNNER_MCP_CREDENTIAL?.startsWith('Bearer ') : !!JSON.parse(readFileSync(file)).mcpServers.pulse.headers.Authorization;
35
+ const child = settings.slow ? spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'ignore'}) : null;
36
+ writeFileSync(${JSON.stringify(trace)}, JSON.stringify({args, received, pid: process.pid, childPid: child?.pid}));
37
+ writeFileSync('job-output.txt', 'isolated output');
38
+ if (settings.publish) {
39
+ for (const argv of [['switch','-c','pul/fixture'],['add','job-output.txt'],['-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture job']]) {
40
+ const result=spawnSync('git',argv,{encoding:'utf8'});
41
+ if(result.status!==0) throw new Error(result.stderr);
42
+ }
43
+ }
44
+ if (settings.slow) setInterval(() => {}, 1000);
45
+ else { console.log('Bearer fixture-secret pulse_sk_fixture'); process.exitCode = settings.fail ? 1 : 0; }
46
+ }
47
+ `);
48
+ const fakeGh = join(root,'gh.mjs');
49
+ await writeFile(fakeGh, `
50
+ import { appendFileSync } from 'node:fs';
51
+ appendFileSync(${JSON.stringify(join(root,'gh-calls.jsonl'))},JSON.stringify(process.argv.slice(2))+'\\n');
52
+ if (process.argv.includes('view')) {console.error('no pull requests found');process.exitCode=1;}
53
+ else console.log('https://github.com/owner/repo/pull/123');
54
+ `);
55
+ process.env.PULSE_RUNNER_GH_BIN = fakeGh;
56
+ process.env.PULSE_RUNNER_CODEX_BIN = fake;
57
+ process.env.PULSE_RUNNER_CLAUDE_BIN = fake;
58
+ const settings = { session: true, slow: false, fail: false };
59
+ const updateSettings = () => writeFile(join(root, 'settings.json'), JSON.stringify(settings));
60
+ await updateSettings();
61
+ const p = await ensureDataDir();
62
+ await writeCredential('runner-fixture.synthetic-device');
63
+ await git(['init', source]);
64
+ await writeFile(join(source, 'source.txt'), 'original');
65
+ await git(['-C', source, 'add', 'source.txt']);
66
+ await git(['-C', source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'initial']);
67
+ await git(['clone', '--mirror', source, join(p.repos, 'owner__repo.git')]);
68
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
69
+ const config = { workspaceId: 'ws-test', runnerId: 'runner-fixture', agentId: 'agent-test', agentKind: kind, agentRole: 'dev', repos: ['owner/repo'], signingKeyId: 'fixture', signingPublicKey: publicKey.export({type:'spki',format:'pem'}).toString() };
70
+ const job = { id: 'rjob-test', workspaceId: config.workspaceId, runnerId: config.runnerId, agentId: config.agentId, issueId: 'issue-fixture', repoFullName: 'owner/repo', mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now()+60_000).toISOString(), signatureAlgorithm:'ed25519', signingKeyId: config.signingKeyId };
71
+ const signJob = () => { job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url'); };
72
+ signJob();
73
+ const calls = [];
74
+ const control = { cancel: false, cancelWhenRunning: false, disconnectWhenRunning: false };
75
+ const server = createServer(async (req, res) => {
76
+ let raw = ''; for await (const chunk of req) raw += chunk;
77
+ const body = JSON.parse(raw); calls.push({path:req.url, body});
78
+ res.setHeader('Content-Type', 'application/json');
79
+ if (req.url === '/pulseRunnerPoll') res.end(JSON.stringify({job, agent:{kind,role: job.mode === 'review' ? 'qa' : 'dev'}, mcpCredential:'pulse_sk_fixture'}));
80
+ else if (body.jobId && req.url === '/pulseRunnerHeartbeat') {
81
+ const running = await exists(trace);
82
+ if (control.disconnectWhenRunning && running) {res.statusCode=401;res.end('{}');}
83
+ else res.end(JSON.stringify({cancelRequested: control.cancel || (control.cancelWhenRunning && running)}));
84
+ } else res.end('{}');
85
+ });
86
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
87
+ config.endpoint = `http://127.0.0.1:${server.address().port}`;
88
+ const readinessFor = () => inspectReadiness(config, (exe, args, options) => command(exe, exe === 'git' ? ['ls-remote', '--exit-code', source, 'HEAD'] : args, options));
89
+ const run = (extra = {}) => runOnce(config, { readinessFor, heartbeatIntervalMs: 20, ...extra });
90
+ try { await callback({root, source, trace, config, job, signJob, settings, updateSettings, calls, control, run}); }
91
+ finally {
92
+ server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
93
+ for (const key of ['PULSE_RUNNER_HOME', 'PULSE_RUNNER_CODEX_BIN', 'PULSE_RUNNER_CLAUDE_BIN', 'PULSE_RUNNER_GH_BIN']) {
94
+ if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key];
95
+ }
96
+ await rm(root, {recursive:true,force:true});
97
+ }
98
+ }
99
+
100
+ for (const kind of ['claude','codex']) {
101
+ test(`integration: ${kind} executes a real fixture CLI, isolates work and cleans MCP configuration`, () => fixture(kind, async f => {
102
+ await f.run();
103
+ const trace = JSON.parse(await readFile(f.trace,'utf8'));
104
+ assert.equal(trace.received, true);
105
+ assert.ok(!trace.args.join(' ').includes('pulse_sk_fixture'));
106
+ assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
107
+ assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
108
+ assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
109
+ const completion = f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
110
+ assert.equal(completion.outcome,'completed');
111
+ assert.ok(!JSON.stringify(f.calls).includes('pulse_sk_fixture'));
112
+ }));
113
+ test(`integration: ${kind} missing native session rejects before creating worktrees`, () => fixture(kind, async f => {
114
+ f.settings.session = false; await f.updateSettings();
115
+ let prepared = false;
116
+ await assert.rejects(f.run({checkout:async()=>{prepared=true;}}),/session is unavailable/);
117
+ assert.equal(prepared,false);
118
+ assert.equal(await exists(f.trace),false);
119
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'failed');
120
+ }));
121
+ }
122
+
123
+ test('integration: invalid signed jobs never reach checkout', () => fixture('codex', async f => {
124
+ const original = {...f.job};
125
+ const cases = [
126
+ {signature:'invalid'}, {workspaceId:'other'}, {runnerId:'other'}, {agentId:'other'},
127
+ {repoFullName:'other/repo'}, {expiresAt:new Date(Date.now()-1000).toISOString()},
128
+ {expiresAt:'invalid'}, {issuedAt:'invalid'}, {id:'rjob-../../outside'}, {mode:'other'},
129
+ ];
130
+ for (const mutation of cases) {
131
+ Object.assign(f.job,original,mutation);
132
+ if (!mutation.signature) f.signJob();
133
+ let prepared=false;
134
+ await assert.rejects(f.run({checkout:async()=>{prepared=true;}}));
135
+ assert.equal(prepared,false);
136
+ assert.equal(await exists(f.trace),false);
137
+ }
138
+ }));
139
+
140
+ for (const cause of ['remote', 'signal', 'revoked']) {
141
+ test(`integration: ${cause} cancellation stops the CLI and descendants, skips publication and cleans worktrees`, () => fixture('codex', async f => {
142
+ f.settings.slow=true; await f.updateSettings();
143
+ f.control.cancelWhenRunning=cause==='remote';
144
+ f.control.disconnectWhenRunning=cause==='revoked';
145
+ const controller=new AbortController();
146
+ let published=false;
147
+ const running=f.run({signal:controller.signal,publish:async()=>{published=true;}});
148
+ if(cause==='signal') {
149
+ for(let i=0;i<200 && !(await exists(f.trace));i++) await new Promise(r=>setTimeout(r,10));
150
+ assert.equal(await exists(f.trace),true);
151
+ controller.abort();
152
+ }
153
+ await running;
154
+ const trace=JSON.parse(await readFile(f.trace,'utf8'));
155
+ assert.equal(published,false);
156
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
157
+ assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
158
+ assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
159
+ assert.equal(await git(['-C',f.source,'status','--porcelain']),'');
160
+ for(const pid of [trace.pid,trace.childPid]) {
161
+ let alive=true;
162
+ for(let i=0;i<50 && alive;i++) {try{process.kill(pid,0);}catch{alive=false;} if(alive) await new Promise(r=>setTimeout(r,20));}
163
+ assert.equal(alive,false,`process ${pid} must terminate`);
164
+ }
165
+ assert.equal(f.calls.at(-1).body.status,'online');
166
+ }));
167
+ }
168
+
169
+ test('integration: a cancellation before execution never starts the CLI', () => fixture('claude',async f=>{
170
+ f.control.cancel=true;
171
+ await f.run();
172
+ assert.equal(await exists(f.trace),false);
173
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
174
+ }));
175
+
176
+ test('integration: failed provider output is sanitized in reports and local logs',()=>fixture('codex',async f=>{
177
+ f.settings.fail=true; await f.updateSettings();
178
+ await f.run();
179
+ const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
180
+ assert.equal(completion.outcome,'failed');
181
+ assert.match(completion.result,/redacted/);
182
+ assert.ok(!JSON.stringify(f.calls).includes('fixture-secret'));
183
+ const logs=await readFile(join(paths().logs,'runner.jsonl'),'utf8');
184
+ assert.ok(!logs.includes('pulse_sk_fixture'));
185
+ assert.ok(!logs.includes('fixture-secret'));
186
+ }));
187
+
188
+ test('integration: recovery prunes interrupted worktrees and temporary MCP files without touching the source',()=>fixture('codex',async f=>{
189
+ const mirror=join(paths().repos,'owner__repo.git');
190
+ const interrupted=join(paths().worktrees,'rjob-interrupted');
191
+ await git(['--git-dir',mirror,'worktree','add','--detach',interrupted,'HEAD']);
192
+ await writeFile(join(interrupted,'source.txt'),'interrupted edits');
193
+ const temp=join(paths().root,'tmp','rjob-interrupted');
194
+ await mkdir(temp,{recursive:true}); await writeFile(join(temp,'pulse-mcp.json'),'synthetic temporary data');
195
+ await writeFile(join(paths().root, 'active-job.json'), JSON.stringify({jobId:f.job.id}));
196
+ await recoverInterruptedWork(f.config);
197
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
198
+ assert.equal(await exists(join(paths().root,'active-job.json')),false);
199
+ f.calls.length=0;
200
+ assert.equal(await exists(interrupted),false);
201
+ assert.equal(await exists(temp),false);
202
+ assert.ok(!(await git(['--git-dir',mirror,'worktree','list','--porcelain'])).includes('rjob-interrupted'));
203
+ assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
204
+ await f.run();
205
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
206
+ }));
207
+
208
+
209
+ test('integration: one-shot and daemon share a lock; stale locks and thrown jobs release it',()=>fixture('codex',async()=>{
210
+ await withRunnerLock(async()=>{
211
+ await assert.rejects(withRunnerLock(async()=>{}),/already active/);
212
+ });
213
+ assert.equal(await exists(join(paths().root,'runner.lock')),false);
214
+ await writeFile(join(paths().root,'runner.lock'),'2147483647\n');
215
+ await assert.rejects(withRunnerLock(async()=>{throw new Error('fixture failure');}),/fixture failure/);
216
+ assert.equal(await exists(join(paths().root,'runner.lock')),false);
217
+ await withRunnerLock(async()=>{});
218
+ }));
219
+
220
+ test('integration: a job expires during execution and is stopped before publication',()=>fixture('claude',async f=>{
221
+ f.settings.slow=true; await f.updateSettings();
222
+ f.job.expiresAt=new Date(Date.now()+5000).toISOString(); f.signJob();
223
+ let published=false;
224
+ await f.run({publish:async()=>{published=true;}});
225
+ assert.equal(published,false);
226
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
227
+ assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
228
+ }));
229
+
230
+ test('integration: QA execution retains read-only flags and never publishes',()=>fixture('codex',async f=>{
231
+ f.job.mode='review'; f.signJob();
232
+ f.config.agentRole='qa';
233
+ let published=false;
234
+ await f.run({publish:async()=>{published=true;}});
235
+ const trace=JSON.parse(await readFile(f.trace,'utf8'));
236
+ assert.ok(trace.args.includes('read-only'));
237
+ assert.equal(published,false);
238
+ assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
239
+ }));
240
+
241
+ test('integration: signed multi-repo worktrees are passed to the adapter and all cleaned',()=>fixture('codex',async f=>{
242
+ await git(['clone','--mirror',f.source,join(paths().repos,'owner__app.git')]);
243
+ f.config.repos.push('owner/app');
244
+ f.job.contextRepos=['owner/repo','owner/app']; f.signJob();
245
+ await f.run();
246
+ const trace=JSON.parse(await readFile(f.trace,'utf8'));
247
+ assert.ok(trace.args.includes('--add-dir'));
248
+ assert.ok(trace.args.some(arg=>arg.endsWith('owner__app')));
249
+ assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
250
+ for (const repo of ['owner__repo.git','owner__app.git']) {
251
+ assert.ok(!(await git(['--git-dir',join(paths().repos,repo),'worktree','list','--porcelain'])).includes('rjob-test'));
252
+ }
253
+ }));
254
+
255
+
256
+ test('integration: committed job branches push to a local remote and report the fixture PR',()=>fixture('codex',async f=>{
257
+ f.settings.publish=true; await f.updateSettings();
258
+ const original=await git(['-C',f.source,'rev-parse','HEAD']);
259
+ await f.run();
260
+ const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
261
+ assert.equal(completion.outcome,'completed');
262
+ assert.match(completion.result,/owner\/repo:pul\/fixture/);
263
+ assert.match(completion.result,/https:\/\/github.com\/owner\/repo\/pull\/123/);
264
+ assert.equal(await git(['-C',f.source,'rev-parse','HEAD']),original);
265
+ assert.notEqual(await git(['-C',f.source,'rev-parse','refs/heads/pul/fixture']),original);
266
+ const calls=(await readFile(join(f.root,'gh-calls.jsonl'),'utf8')).trim().split('\n').map(JSON.parse);
267
+ assert.equal(calls[0][1],'view'); assert.equal(calls[1][1],'create');
268
+ assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
269
+ }));
package/test/job.test.js CHANGED
@@ -13,3 +13,26 @@ 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
+ });
27
+
28
+ test('rejects signed envelopes with missing or invalid timestamps before execution', () => {
29
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
30
+ const config = { workspaceId: 'ws-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repos: ['owner/repo'], signingKeyId: 'runner-job-v1', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
31
+ const job = { id: 'rjob-456', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repoFullName: 'owner/repo', mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'runner-job-v1' };
32
+ const malformed = { ...job, expiresAt: 'invalid' };
33
+ malformed.signature = sign(null, Buffer.from(jobPayload(malformed)), privateKey).toString('base64url');
34
+ assert.throws(() => validateJob(malformed, config), /expired or malformed/);
35
+ const expired = { ...job, expiresAt: new Date(Date.now() - 1_000).toISOString() };
36
+ expired.signature = sign(null, Buffer.from(jobPayload(expired)), privateKey).toString('base64url');
37
+ assert.throws(() => validateJob(expired, config), /expired or malformed/);
38
+ });
@@ -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
+ });
@@ -0,0 +1,42 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { command, providerInvocation } from '../src/process.js';
7
+ import { addIsolatedWorktree, removeIsolatedWorktree } from '../src/worktree.js';
8
+
9
+ test('cancellation terminates the active child process', async () => {
10
+ const controller = new AbortController();
11
+ const running = command(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { signal: controller.signal });
12
+ setTimeout(() => controller.abort(), 100);
13
+ const result = await running;
14
+ assert.notEqual(result.code, 0);
15
+ });
16
+
17
+ test('Claude and Codex adapters receive only ephemeral MCP configuration references', () => {
18
+ const config = { endpoint: 'https://pulse.example.test' };
19
+ const codex = providerInvocation('codex', '/tmp/job', [], config, '/tmp/pulse-mcp.json', 'run task');
20
+ const claude = providerInvocation('claude', '/tmp/job', [], config, '/tmp/pulse-mcp.json', 'run task');
21
+ assert.match(codex.args.join(' '), /env_http_headers/);
22
+ assert.doesNotMatch(codex.args.join(' '), /secret|credential-value/);
23
+ assert.ok(claude.args.includes('/tmp/pulse-mcp.json'));
24
+ assert.throws(() => providerInvocation('unknown', '/tmp/job', [], config, '', 'run task'), /Unsupported agent kind/);
25
+ });
26
+
27
+ test('an isolated worktree can change and clean up without changing its source checkout', async () => {
28
+ const root = await mkdtemp(join(tmpdir(), 'pulse-runner-worktree-'));
29
+ const repo = join(root, 'repo');
30
+ const worktree = join(root, 'job');
31
+ try {
32
+ await command('git', ['init', repo]);
33
+ await writeFile(join(repo, 'source.txt'), 'original\n');
34
+ await command('git', ['-C', repo, 'add', 'source.txt']);
35
+ await command('git', ['-C', repo, '-c', 'user.name=Pulse Test', '-c', 'user.email=pulse@example.invalid', 'commit', '-m', 'initial']);
36
+ const checkout = await addIsolatedWorktree('owner/repo', join(repo, '.git'), worktree);
37
+ assert.ok(checkout.baseCommit);
38
+ await writeFile(join(worktree, 'source.txt'), 'changed in job\n');
39
+ assert.equal(await readFile(join(repo, 'source.txt'), 'utf8'), 'original\n');
40
+ await removeIsolatedWorktree(checkout);
41
+ } finally { await rm(root, { recursive: true, force: true }); }
42
+ });
@@ -0,0 +1,49 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { service } from '../src/service.js';
7
+
8
+ for (const platform of ['darwin','linux','win32']) {
9
+ test(`service: ${platform} retains instance home and supports install, stop, start and uninstall`,async()=>{
10
+ const root=await mkdtemp(join(tmpdir(),'pulse-service-'));
11
+ const saved=process.env.PULSE_RUNNER_HOME;
12
+ process.env.PULSE_RUNNER_HOME=join(root,'instance home');
13
+ const calls=[];
14
+ const options={platform,userHome:join(root,'os-user'),print:()=>{},command:async(exe,args)=>{calls.push({exe,args});return {code:0,output:''};}};
15
+ try {
16
+ await service('install',options);
17
+ if(platform==='darwin') {
18
+ const folder=join(options.userHome,'Library','LaunchAgents');
19
+ const [file]=await readdir(folder);const plist=await readFile(join(folder,file),'utf8');
20
+ assert.match(plist,/<string>--home<\/string>/);
21
+ assert.ok(plist.includes(process.env.PULSE_RUNNER_HOME));
22
+ assert.match(file,/com\.pulse\.runner\.[0-9a-f]{12}\.plist/);
23
+ } else if(platform==='linux') {
24
+ const folder=join(options.userHome,'.config','systemd','user');
25
+ const [file]=await readdir(folder);const unit=await readFile(join(folder,file),'utf8');
26
+ const homeArgument=unit.match(/--home ("(?:\\.|[^"])*")/)?.[1];
27
+ assert.equal(JSON.parse(homeArgument),process.env.PULSE_RUNNER_HOME);
28
+ assert.match(file,/pulse-runner-[0-9a-f]{12}\.service/);
29
+ } else {
30
+ const install=calls.find(c=>c.args.includes('/Create'));
31
+ assert.ok(install.args.some(arg=>arg.includes(`--home "${process.env.PULSE_RUNNER_HOME}"`)));
32
+ assert.ok(install.args.some(arg=>/^PulseRunner-[0-9a-f]{12}$/.test(arg)));
33
+ }
34
+ await service('stop',options);
35
+ const afterStop=calls.length;
36
+ await service('start',options);
37
+ if(platform==='darwin') assert.deepEqual(calls.slice(afterStop).map(c=>c.args[0]),['bootstrap','kickstart']);
38
+ await service('uninstall',options);
39
+ const firstName=calls.find(c=>c.args.some(arg=>/pulse\.runner\.|pulse-runner-|PulseRunner-/.test(arg))).args.join(' ');
40
+ process.env.PULSE_RUNNER_HOME=join(root,'second instance');
41
+ calls.length=0; await service('install',options);
42
+ const secondName=calls.find(c=>c.args.some(arg=>/pulse\.runner\.|pulse-runner-|PulseRunner-/.test(arg))).args.join(' ');
43
+ assert.notEqual(firstName,secondName);
44
+ } finally {
45
+ if(saved===undefined) delete process.env.PULSE_RUNNER_HOME;else process.env.PULSE_RUNNER_HOME=saved;
46
+ await rm(root,{recursive:true,force:true});
47
+ }
48
+ });
49
+ }