@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 +1 -1
- package/src/github-app.js +9 -2
- package/src/runtime.js +11 -2
- package/test/github-app.test.js +15 -1
- package/test/integration.test.js +5 -0
package/package.json
CHANGED
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
|
|
31
|
-
return result.stdout
|
|
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
|
-
|
|
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) }) });
|
package/test/github-app.test.js
CHANGED
|
@@ -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)=>{
|
package/test/integration.test.js
CHANGED
|
@@ -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);
|