@pulsehub/runner 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Local, credential-safe Pulse Runner",
5
5
  "repository": {
6
6
  "type": "git",
package/src/github-app.js CHANGED
@@ -17,6 +17,13 @@ export function bindingFor(config, projectId, repo) {
17
17
  if (bindings.length !== 1) throw new Error(`Configure one local GitHub App for project ${projectId}, repo ${repo}.`);
18
18
  return validateBinding(bindings[0]);
19
19
  }
20
+ export function parseAppKeyOutput(output, allowHex = false) {
21
+ let key = output.trim();
22
+ // macOS security renders passwords containing newlines as hexadecimal.
23
+ if (allowHex && /^(?:[0-9a-fA-F]{2})+$/.test(key)) key = Buffer.from(key, 'hex').toString('utf8').trim();
24
+ if (!/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(key)) throw new Error('Local GitHub App key is unavailable in the OS credential store.');
25
+ return key;
26
+ }
20
27
  export async function readAppKey(binding) {
21
28
  const service = `pulse-runner.github-app.${binding.appId}.${binding.keyId}`;
22
29
  try {
@@ -27,8 +34,8 @@ export async function readAppKey(binding) {
27
34
  : platform() === 'linux'
28
35
  ? await exec('secret-tool', ['lookup', 'service', service], { timeout: 10_000, maxBuffer: 32_768 })
29
36
  : null;
30
- if (!result || !/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(result.stdout)) throw new Error();
31
- return result.stdout.trim();
37
+ if (!result) throw new Error();
38
+ return parseAppKeyOutput(result.stdout, platform() === 'darwin');
32
39
  } catch { throw new Error('Local GitHub App key is unavailable in the OS credential store.'); }
33
40
  }
34
41
  export function appJwt(appId, privateKey, now = Date.now()) {
package/src/runtime.js CHANGED
@@ -139,11 +139,12 @@ export async function runOnce(config, options = {}) {
139
139
  let job;
140
140
  let jobPhase = 'poll';
141
141
  let completionReported = false;
142
+ let agentActive = false;
142
143
  const journal = join(paths().root, 'active-job.json');
143
144
  const checkActive = async () => {
144
145
  controller.signal.throwIfAborted();
145
146
  if (Date.parse(job.expiresAt) <= Date.now()) controller.abort();
146
- const response = await remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id });
147
+ const response = await remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id, agentActive });
147
148
  if (response.cancelRequested) controller.abort();
148
149
  controller.signal.throwIfAborted();
149
150
  };
@@ -204,7 +205,15 @@ export async function runOnce(config, options = {}) {
204
205
  controller.signal.throwIfAborted();
205
206
  jobPhase = 'run-agent';
206
207
  await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
207
- 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 }) });
208
+ agentActive = true;
209
+ let result;
210
+ try {
211
+ await checkActive();
212
+ 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 }) });
213
+ } finally {
214
+ agentActive = false;
215
+ await remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id, agentActive: false }).catch(() => {});
216
+ }
208
217
  controller.signal.throwIfAborted();
209
218
  await checkActive();
210
219
  await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
@@ -1,11 +1,25 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { generateKeyPairSync, verify } from 'node:crypto';
4
- import { appJwt, authorizeRepo, appGit, bindingFor, githubReadiness, githubApi } from '../src/github-app.js';
4
+ import { appJwt, authorizeRepo, appGit, bindingFor, githubReadiness, githubApi, parseAppKeyOutput } from '../src/github-app.js';
5
5
  const {privateKey, publicKey}=generateKeyPairSync('rsa',{modulusLength:2048});
6
6
  const pem=privateKey.export({type:'pkcs8',format:'pem'}).toString();
7
7
  const binding={projectId:'project-1',repo:'owner/repo',appId:'1',installationId:'2',slug:'local-app',keyId:'local-key'};
8
8
  const config={githubApps:[binding]};
9
+ test('macOS Keychain hexadecimal multiline output produces a verifiable RSA signature',()=>{
10
+ for (const type of ['pkcs1', 'pkcs8']) {
11
+ const original=privateKey.export({type,format:'pem'}).toString();
12
+ assert.equal(parseAppKeyOutput(original,true),original.trim());
13
+ const decoded=parseAppKeyOutput(`${Buffer.from(original).toString('hex')}\n`,true);
14
+ assert.equal(decoded,original.trim());
15
+ const [header,payload,signature]=appJwt('1',decoded).split('.');
16
+ assert(verify('RSA-SHA256',Buffer.from(`${header}.${payload}`),publicKey,Buffer.from(signature,'base64url')));
17
+ assert.throws(()=>parseAppKeyOutput(Buffer.from(original).toString('hex')));
18
+ }
19
+ for (const invalid of ['sentinel-secret','abc','00ff',Buffer.from('sentinel-secret').toString('hex')]) {
20
+ assert.throws(()=>parseAppKeyOutput(invalid,true),error=>!error.message.includes('sentinel'));
21
+ }
22
+ });
9
23
  function apiFixture(overrides={}) {
10
24
  const calls=[];
11
25
  const api=async(path,token,options)=>{
@@ -138,6 +138,10 @@ for (const kind of ['claude','codex']) {
138
138
  assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
139
139
  const completion = f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
140
140
  assert.equal(completion.outcome,'completed');
141
+ const heartbeats = f.calls.filter(c => c.path === '/pulseRunnerHeartbeat' && c.body.jobId);
142
+ assert.equal(heartbeats[0].body.agentActive, false);
143
+ assert(heartbeats.some(c => c.body.agentActive === true));
144
+ assert.equal(heartbeats.at(-1).body.agentActive, false);
141
145
  assert.ok(!JSON.stringify(f.calls).includes('pulse_sk_fixture'));
142
146
  }));
143
147
  test(`integration: ${kind} missing native session rejects before creating worktrees`, () => fixture(kind, async f => {
@@ -388,6 +392,7 @@ test('integration: publication failure retains commits and retry does not run th
388
392
  f.job.publicationTargets=[...f.job.publicationTargets.map(t=>({...t,sha:entry.sha}))];f.signJob();
389
393
  await f.run();
390
394
  assert.equal(await readFile(f.trace,'utf8'),previousTrace);
395
+ assert(f.calls.filter(c => c.path === '/pulseRunnerHeartbeat' && c.body.jobId).every(c => c.body.agentActive === false));
391
396
  const retry=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
392
397
  assert.equal(retry.outcome,'completed');assert.equal(retry.publication.repositories[0].sha,entry.sha);
393
398
  assert.equal(retry.publication.repositories[0].stage,'linked');assert.equal(publishCalls,1);