@pulsehub/runner 0.1.12 → 0.1.13
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 +67 -0
- package/package.json +1 -1
- package/src/github-app.js +101 -0
- package/src/index.js +4 -3
- package/src/job.js +14 -4
- package/src/process.js +4 -2
- package/src/publication.js +114 -0
- package/src/readiness.js +7 -9
- package/src/runtime.js +74 -96
- package/src/worktree.js +1 -1
- package/test/github-app.test.js +52 -0
- package/test/integration.test.js +59 -18
- package/test/publication.test.js +45 -0
- package/test/readiness.test.js +1 -1
package/README.md
CHANGED
|
@@ -124,3 +124,70 @@ resultados, errores libres, prompts y transcripts nunca se guardan ni se envían
|
|
|
124
124
|
en el reporte QA. Los nombres de herramientas desconocidas se registran como
|
|
125
125
|
`other`. Las líneas se limitan a 256 KiB y se emiten como máximo 500 eventos
|
|
126
126
|
más el resumen. Un fallo al escribir el diagnóstico no interrumpe el job.
|
|
127
|
+
|
|
128
|
+
## Local GitHub App identity (TES-305)
|
|
129
|
+
|
|
130
|
+
Development jobs now require protocol 3 from Pulse. QA snapshots continue using
|
|
131
|
+
protocol 2. Upgrade the backend and Runner together; configure every project/repo
|
|
132
|
+
before starting development. No fallback to the active `gh` account, inherited Git
|
|
133
|
+
credentials, or a PAT is performed. GitHub Actions agents are outside this flow.
|
|
134
|
+
|
|
135
|
+
Use a GitHub App administered by the owner of this Runner/project. **Do not copy
|
|
136
|
+
Pulse's shared service App private key to an end user's computer.** Install your
|
|
137
|
+
local App only on the needed repositories with Metadata read, Contents write and
|
|
138
|
+
Pull requests write. The bot identity is displayed in readiness. Changes to
|
|
139
|
+
`.github/workflows` are blocked by this initial implementation; it does not
|
|
140
|
+
silently grant Workflows permission. Configure an additional permission path
|
|
141
|
+
before attempting such work. Repository rules and organization policy may still
|
|
142
|
+
reject a publication after preflight.
|
|
143
|
+
|
|
144
|
+
Store the App PEM private key in the OS credential store, using the service name
|
|
145
|
+
`pulse-runner.github-app.APP_ID.KEY_ID`:
|
|
146
|
+
|
|
147
|
+
- macOS: create a generic password in **Keychain Access**, with that service name
|
|
148
|
+
and the PEM as its password. Run Runner under the same OS user and permit access
|
|
149
|
+
when Keychain asks. Never place the PEM in command arguments or Runner config.
|
|
150
|
+
- Linux: use a Secret Service compatible credential manager and store a secret
|
|
151
|
+
with attribute `service` equal to that name. `secret-tool` must be installed and
|
|
152
|
+
the user keyring unlocked for the Runner service.
|
|
153
|
+
- Windows: local App key access is not supported yet; configuration fails closed.
|
|
154
|
+
QA remains supported. No plaintext fallback is provided.
|
|
155
|
+
|
|
156
|
+
Associate public metadata explicitly; KEY_ID is a local reference, not a secret:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
pulse-runner github-app set --project PROJECT_ID --repo owner/repo \
|
|
160
|
+
--app-id APP_ID --installation-id INSTALLATION_ID \
|
|
161
|
+
--slug app-slug --key-id KEY_ID
|
|
162
|
+
pulse-runner github-app list
|
|
163
|
+
pulse-runner diagnose
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Repeat for all repositories, including additional worktrees. An installation must
|
|
167
|
+
belong to the repository owner. Setup checks the App identity, installation and
|
|
168
|
+
actual token grant before saving the binding. Token grants are requested for one
|
|
169
|
+
repository with only Contents/PR write and Metadata read; the key and tokens never
|
|
170
|
+
reach Pulse/MCP, config, URLs, CLI arguments, logs, or the agent environment.
|
|
171
|
+
Removing a binding with `github-app remove --project PROJECT_ID --repo owner/repo`
|
|
172
|
+
does not delete the key or revoke its installation. Revoke those in the OS store
|
|
173
|
+
and GitHub as needed, and send a fresh diagnose report.
|
|
174
|
+
|
|
175
|
+
Pulse signs the repository, bot identity, installation, base and branch of each
|
|
176
|
+
job. Runner prepares those exact branches and commits remain local until its
|
|
177
|
+
publication step. Public manifests under `publications/` and Git refs under
|
|
178
|
+
`refs/pulse-recovery/` preserve commits independently of temporary worktrees.
|
|
179
|
+
Interrupted work that has not been checkpointed is retained with a
|
|
180
|
+
`.publication-pending` marker, while temporary provider MCP files are removed.
|
|
181
|
+
|
|
182
|
+
When the agent finishes but GitHub publication fails, the job reports both
|
|
183
|
+
outcomes and the failed stage. **Reintentar publicación** in Pulse creates a new
|
|
184
|
+
signed authorization for the same repos/refs/commits on the same Runner. It does
|
|
185
|
+
not run the provider CLI. Existing branches and PRs are reconciled; no force push
|
|
186
|
+
is used. All PRs are explicitly linked to the issue. Revoked access, a changed
|
|
187
|
+
project/App identity, a missing recovery ref, or divergent remote work blocks the
|
|
188
|
+
retry with a safe diagnosis. Recovery refs/manifests are retained after success;
|
|
189
|
+
remove them only after verifying the work is safely published.
|
|
190
|
+
|
|
191
|
+
The agent still runs as the local OS user. This feature isolates the Runner's
|
|
192
|
+
publication transport; it is not an OS sandbox preventing arbitrary agent code
|
|
193
|
+
from reading other user credentials.
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createSign } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { platform } from 'node:os';
|
|
5
|
+
import { command } from './process.js';
|
|
6
|
+
import { writeConfig } from './config.js';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const repoPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
10
|
+
const identifier = /^[-A-Za-z0-9_]{1,100}$/;
|
|
11
|
+
export function validateBinding(entry) {
|
|
12
|
+
if (!entry || !identifier.test(entry.projectId || '') || !repoPattern.test(entry.repo || '') || !/^\d+$/.test(String(entry.appId || '')) || !/^\d+$/.test(String(entry.installationId || '')) || !identifier.test(entry.slug || '') || !identifier.test(entry.keyId || '')) throw new Error('Invalid local GitHub App binding.');
|
|
13
|
+
return { projectId: entry.projectId, repo: entry.repo, appId: String(entry.appId), installationId: String(entry.installationId), slug: entry.slug, keyId: entry.keyId };
|
|
14
|
+
}
|
|
15
|
+
export function bindingFor(config, projectId, repo) {
|
|
16
|
+
const bindings = (config.githubApps || []).filter((e) => e.projectId === projectId && e.repo === repo);
|
|
17
|
+
if (bindings.length !== 1) throw new Error(`Configure one local GitHub App for project ${projectId}, repo ${repo}.`);
|
|
18
|
+
return validateBinding(bindings[0]);
|
|
19
|
+
}
|
|
20
|
+
export async function readAppKey(binding) {
|
|
21
|
+
const service = `pulse-runner.github-app.${binding.appId}.${binding.keyId}`;
|
|
22
|
+
try {
|
|
23
|
+
// Read only. Setup is performed in the OS credential manager, never through
|
|
24
|
+
// secret CLI arguments. Do not forward stdout/stderr or exec errors.
|
|
25
|
+
const result = platform() === 'darwin'
|
|
26
|
+
? await exec('/usr/bin/security', ['find-generic-password', '-s', service, '-w'], { timeout: 10_000, maxBuffer: 32_768 })
|
|
27
|
+
: platform() === 'linux'
|
|
28
|
+
? await exec('secret-tool', ['lookup', 'service', service], { timeout: 10_000, maxBuffer: 32_768 })
|
|
29
|
+
: null;
|
|
30
|
+
if (!result || !/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(result.stdout)) throw new Error();
|
|
31
|
+
return result.stdout.trim();
|
|
32
|
+
} catch { throw new Error('Local GitHub App key is unavailable in the OS credential store.'); }
|
|
33
|
+
}
|
|
34
|
+
export function appJwt(appId, privateKey, now = Date.now()) {
|
|
35
|
+
const seconds = Math.floor(now / 1000);
|
|
36
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
37
|
+
const message = `${encode({ alg: 'RS256', typ: 'JWT' })}.${encode({ iat: seconds - 60, exp: seconds + 540, iss: String(appId) })}`;
|
|
38
|
+
return `${message}.${createSign('RSA-SHA256').update(message).sign(privateKey).toString('base64url')}`;
|
|
39
|
+
}
|
|
40
|
+
export async function githubApi(path, token, options = {}, fetcher = fetch) {
|
|
41
|
+
if (!path.startsWith('/') || path.includes('://')) throw new Error('Invalid GitHub API path.');
|
|
42
|
+
const response = await fetcher(`https://api.github.com${path}`, {
|
|
43
|
+
method: options.method || 'GET', redirect: 'error', signal: options.signal || AbortSignal.timeout(30_000),
|
|
44
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(options.body ? { 'Content-Type': 'application/json' } : {}) },
|
|
45
|
+
...(options.body ? { body: JSON.stringify(options.body) } : {}),
|
|
46
|
+
});
|
|
47
|
+
// Response bodies can contain reflected credentials; only report status/stage.
|
|
48
|
+
if (!response.ok) throw Object.assign(new Error(`GitHub rejected ${options.method || 'GET'} request (HTTP ${response.status}).`), { status: response.status });
|
|
49
|
+
return response.status === 204 ? null : response.json();
|
|
50
|
+
}
|
|
51
|
+
export async function authorizeRepo(config, projectId, repo, options = {}) {
|
|
52
|
+
const binding = bindingFor(config, projectId, repo);
|
|
53
|
+
const api = options.api || githubApi;
|
|
54
|
+
let jwt;
|
|
55
|
+
try { jwt = appJwt(binding.appId, await (options.readKey || readAppKey)(binding)); }
|
|
56
|
+
catch { throw new Error('Local GitHub App key cannot sign requests.'); }
|
|
57
|
+
const app = await api('/app', jwt);
|
|
58
|
+
if (String(app.id) !== binding.appId || app.slug !== binding.slug) throw new Error('GitHub App identity differs from the selected local binding.');
|
|
59
|
+
const installation = await api(`/app/installations/${binding.installationId}`, jwt);
|
|
60
|
+
if (installation.suspended_at || installation.account?.login?.toLowerCase() !== repo.split('/')[0].toLowerCase()) throw new Error('GitHub App installation is suspended or belongs to another repository owner.');
|
|
61
|
+
if (installation.permissions?.contents !== 'write' || installation.permissions?.pull_requests !== 'write') throw new Error('GitHub App requires Contents write and Pull requests write.');
|
|
62
|
+
const granted = await api(`/app/installations/${binding.installationId}/access_tokens`, jwt, { method: 'POST', body: { repositories: [repo.split('/')[1]], permissions: { contents: 'write', pull_requests: 'write', metadata: 'read' } } });
|
|
63
|
+
if (!granted.token || !Number.isFinite(Date.parse(granted.expires_at)) || Date.parse(granted.expires_at) <= Date.now() || granted.permissions?.contents !== 'write' || granted.permissions?.pull_requests !== 'write' || !Array.isArray(granted.repositories) || granted.repositories.length !== 1 || granted.repositories[0].full_name.toLowerCase() !== repo.toLowerCase()) throw new Error('GitHub did not grant the exact repository and publication permissions requested.');
|
|
64
|
+
const metadata = await api(`/repos/${repo}`, granted.token);
|
|
65
|
+
if (metadata.archived || metadata.disabled || metadata.full_name.toLowerCase() !== repo.toLowerCase() || !metadata.default_branch) throw new Error('Selected repository is unavailable for publication.');
|
|
66
|
+
return { token: granted.token, expiresAt: granted.expires_at, base: metadata.default_branch, identity: { projectId, repo, appId: binding.appId, installationId: binding.installationId, slug: binding.slug }, api: (path, options) => api(path, granted.token, options) };
|
|
67
|
+
}
|
|
68
|
+
export async function githubReadiness(config, options = {}) {
|
|
69
|
+
const entries = [];
|
|
70
|
+
for (const entry of config.githubApps || []) {
|
|
71
|
+
const binding = validateBinding(entry);
|
|
72
|
+
const identity = { projectId: binding.projectId, repo: binding.repo, appId: binding.appId, installationId: binding.installationId, slug: binding.slug };
|
|
73
|
+
try { const grant = await authorizeRepo(config, binding.projectId, binding.repo, options); entries.push({ ...identity, ready: true, base: grant.base }); }
|
|
74
|
+
catch { entries.push({ ...identity, ready: false }); }
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
export async function appGit(grant, repo, args, options = {}, run = command) {
|
|
79
|
+
if (!repoPattern.test(repo) || grant.identity.repo !== repo || Date.parse(grant.expiresAt) <= Date.now()) throw new Error('GitHub repository grant is invalid or expired.');
|
|
80
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_|^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN)$/.test(key)));
|
|
81
|
+
// Credentials remain in the transport environment, never in argv/config/URL.
|
|
82
|
+
Object.assign(env, { GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'http.https://github.com/.extraHeader', GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`x-access-token:${grant.token}`).toString('base64')}` });
|
|
83
|
+
const result = await run('git', ['-c', 'credential.helper=', '-c', 'http.extraHeader=', '-c', 'http.followRedirects=false', '-c', 'http.sslVerify=true', '-c', 'http.proxy=', '-c', 'core.hooksPath=/dev/null', ...args], { ...options, env });
|
|
84
|
+
if (result.code !== 0) throw new Error('GitHub Git transport failed; check repository permissions, branch rules or connectivity.');
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
export async function githubAppCommand(config, args) {
|
|
88
|
+
if (args[1] === 'list') return console.log(JSON.stringify(config.githubApps || [], null, 2));
|
|
89
|
+
const value = (flag) => args[args.indexOf(flag) + 1];
|
|
90
|
+
const projectId = value('--project'); const repo = value('--repo');
|
|
91
|
+
if (!args.includes('--project') || !args.includes('--repo') || !['set', 'remove'].includes(args[1])) throw new Error('Use github-app set|remove --project ID --repo owner/repo [--app-id ID --installation-id ID --slug SLUG --key-id ID].');
|
|
92
|
+
const entries = (config.githubApps || []).filter((e) => e.projectId !== projectId || e.repo !== repo);
|
|
93
|
+
if (args[1] === 'set') {
|
|
94
|
+
if (['--app-id','--installation-id','--slug','--key-id'].some(flag => !args.includes(flag))) throw new Error('All public GitHub App identity flags are required.');
|
|
95
|
+
const binding = validateBinding({ projectId, repo, appId: value('--app-id'), installationId: value('--installation-id'), slug: value('--slug'), keyId: value('--key-id') });
|
|
96
|
+
await authorizeRepo({ ...config, githubApps: [...entries, binding] }, projectId, repo);
|
|
97
|
+
entries.push(binding);
|
|
98
|
+
}
|
|
99
|
+
await writeConfig({ ...config, githubApps: entries });
|
|
100
|
+
console.log('Local GitHub App binding updated. Run pulse-runner diagnose.');
|
|
101
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { githubAppCommand } from './github-app.js';
|
|
2
3
|
import { inspectReadiness, localIdentities } from './readiness.js';
|
|
3
4
|
import { generateKeyPairSync } from 'node:crypto';
|
|
4
5
|
import { readFile } from 'node:fs/promises';
|
|
@@ -21,7 +22,7 @@ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
|
21
22
|
});
|
|
22
23
|
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
23
24
|
const has = (flag) => args.includes(flag);
|
|
24
|
-
const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|identity|repo|profile|service>\n\ninit --workspace WS --agent AGENT [--agent-kind codex|claude] [--agent-role dev|qa] --signing-public-key FILE --allow-autonomous [--endpoint URL] \npair --credential runner.secret\nidentity add|remove --agent ID --agent-kind codex|claude --agent-role dev|qa | identity list\nprofile list|set|remove [--model MODEL] [--repo owner/repo --directory PATH]\nstart | run --once | diagnose | service install|start|stop|status|uninstall`);
|
|
25
|
+
const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|identity|repo|profile|github-app|service>\n\ninit --workspace WS --agent AGENT [--agent-kind codex|claude] [--agent-role dev|qa] --signing-public-key FILE --allow-autonomous [--endpoint URL] \npair --credential runner.secret\nidentity add|remove --agent ID --agent-kind codex|claude --agent-role dev|qa | identity list\nprofile list|set|remove [--model MODEL] [--repo owner/repo --directory PATH]\nstart | run --once | diagnose | service install|start|stop|status|uninstall`);
|
|
25
26
|
|
|
26
27
|
async function start(config) {
|
|
27
28
|
return withRunnerLock(async () => {
|
|
@@ -51,7 +52,7 @@ async function diagnose(config) {
|
|
|
51
52
|
console.log(JSON.stringify(readiness, null, 2));
|
|
52
53
|
if (await readCredential()) await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
53
54
|
const providers = localIdentities(config).map((identity) => readiness.providers[identity.kind]);
|
|
54
|
-
if (providers.some((provider) => !provider?.cli || !provider?.session) || readiness.repositories.some((repo) => !repo.accessible)) process.exitCode = 1;
|
|
55
|
+
if (providers.some((provider) => !provider?.cli || !provider?.session) || readiness.repositories.some((repo) => !repo.accessible) || readiness.githubApps.some((entry) => !entry.ready)) process.exitCode = 1;
|
|
55
56
|
}
|
|
56
57
|
async function identity(config) {
|
|
57
58
|
if (args[1] === 'list') return console.log(JSON.stringify(localIdentities(config), null, 2));
|
|
@@ -71,5 +72,5 @@ try {
|
|
|
71
72
|
if (!args.length || has('--help')) usage();
|
|
72
73
|
else if (args[0] === 'init') await init();
|
|
73
74
|
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); console.log(`Runner ${paired.runnerId} paired.`); }
|
|
74
|
-
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(); else if (args[0] === 'profile') await profileCommand(config, args); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(args[1]); else if (args[0] === 'run') { activeJobController = new AbortController(); try { await withRunnerLock(async () => { await recoverInterruptedWork(config); await runOnce(config, { signal: activeJobController.signal }); }); } finally { activeJobController = undefined; } } else if (args[0] === 'start') await start(config); else usage(); }
|
|
75
|
+
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] === 'github-app') await githubAppCommand(config, args); else if (args[0] === 'repo') await repo(); else if (args[0] === 'profile') await profileCommand(config, args); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(args[1]); else if (args[0] === 'run') { activeJobController = new AbortController(); try { await withRunnerLock(async () => { await recoverInterruptedWork(config); await runOnce(config, { signal: activeJobController.signal }); }); } finally { activeJobController = undefined; } } else if (args[0] === 'start') await start(config); else usage(); }
|
|
75
76
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { publicationPayload, validBranch } from './publication.js';
|
|
1
2
|
import { verify } from 'node:crypto';
|
|
2
3
|
|
|
3
4
|
const b64 = (value) => Buffer.from(value, 'base64url');
|
|
4
5
|
export function jobPayload(job) {
|
|
5
6
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])].sort().join(',');
|
|
6
7
|
const fields = [job.id, job.workspaceId, job.issueId, job.agentId, job.runnerId, job.repoFullName, contextRepos, job.mode, job.issuedAt, job.expiresAt, job.signatureAlgorithm, job.signingKeyId];
|
|
7
|
-
return (job.protocolVersion ===
|
|
8
|
+
return (job.protocolVersion === 3 ? [...fields, 3, job.projectId, publicationPayload(job.publicationTargets), job.recoveryOf || ''] : job.protocolVersion === 2 ? [...fields, 2, job.projectId] : fields).join('.');
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/** Reject an envelope before it can cause filesystem or process activity. */
|
|
@@ -12,8 +13,8 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
12
13
|
const agent = typeof agentOrNow === 'object' && agentOrNow !== null ? agentOrNow : null;
|
|
13
14
|
const now = typeof agentOrNow === 'number' ? agentOrNow : suppliedNow;
|
|
14
15
|
if (!job || typeof job.signature !== 'string' || job.signatureAlgorithm !== 'ed25519' || job.signingKeyId !== config.signingKeyId) throw new Error('Job signing key is not trusted.');
|
|
15
|
-
if (job.protocolVersion !== undefined && job.protocolVersion
|
|
16
|
-
if (job.protocolVersion
|
|
16
|
+
if (job.protocolVersion !== undefined && ![2, 3].includes(job.protocolVersion)) throw new Error('Job protocol is not supported.');
|
|
17
|
+
if ([2, 3].includes(job.protocolVersion) && (typeof job.projectId !== 'string' || !/^[-A-Za-z0-9_]+$/.test(job.projectId))) throw new Error('Job project is invalid.');
|
|
17
18
|
if (!config.signingPublicKey || !verify(null, Buffer.from(jobPayload(job)), config.signingPublicKey, b64(job.signature))) throw new Error('Job signature is invalid.');
|
|
18
19
|
const issuedAt = Date.parse(job.issuedAt);
|
|
19
20
|
const expiresAt = Date.parse(job.expiresAt);
|
|
@@ -23,7 +24,16 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
23
24
|
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
24
25
|
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.');
|
|
25
26
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
|
|
26
|
-
if (!contextRepos.includes(job.repoFullName) || contextRepos.some((repo) => typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || (job.protocolVersion
|
|
27
|
+
if (!contextRepos.includes(job.repoFullName) || contextRepos.some((repo) => typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo) || (![2, 3].includes(job.protocolVersion) && !(config.repos || []).includes(repo)))) throw new Error('Job repository context is not allowed by this Runner.');
|
|
28
|
+
if (job.protocolVersion === 3) {
|
|
29
|
+
if (!Array.isArray(job.publicationTargets) || job.publicationTargets.length > 100 || !job.publicationTargets.length || job.mode === 'review') throw new Error('Invalid signed publication scope.');
|
|
30
|
+
const repos = new Set();
|
|
31
|
+
for (const t of job.publicationTargets) {
|
|
32
|
+
if (!contextRepos.includes(t.repo) || repos.has(t.repo) || !validBranch(t.branch) || !t.branch.startsWith('pul/') || !validBranch(t.base) || !/^\d+$/.test(t.appId) || !/^\d+$/.test(t.installationId) || !/^[-A-Za-z0-9_]{1,100}$/.test(t.slug) || (job.recoveryOf && !/^[a-f0-9]{40}$/.test(t.sha))) throw new Error('Invalid signed publication target.');
|
|
33
|
+
repos.add(t.repo);
|
|
34
|
+
}
|
|
35
|
+
if ((!job.recoveryOf && contextRepos.some(repo => !repos.has(repo))) || (job.recoveryOf && !/^rjob-[A-Za-z0-9_-]+$/.test(job.recoveryOf))) throw new Error('Invalid publication recovery scope.');
|
|
36
|
+
}
|
|
27
37
|
return true;
|
|
28
38
|
}
|
|
29
39
|
|
package/src/process.js
CHANGED
|
@@ -109,9 +109,10 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
109
109
|
? ` Snapshot manifest: ${join(worktree, '..', 'qa-sources.json')}. Additional review worktrees for inspection: ${reviewWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}.`
|
|
110
110
|
: ` Additional writable project worktrees: ${reviewWorktrees.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.`
|
|
111
111
|
: '';
|
|
112
|
+
const publicationInstructions = job.publicationTargets ? ` Signed publication branches: ${job.publicationTargets.map(t => `${t.repo}: ${t.branch}`).join('; ')}. Use exactly these branches for new work; for rework keep the prepared detached HEAD. The Runner handles push and PR creation; commit locally and do not publish or use GitHub credentials.` : '';
|
|
112
113
|
const prompt = job.mode === 'review'
|
|
113
114
|
? `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. Snapshots are read-only and have no Git identity. Do not install dependencies or run build/tests/scripts from the reviewed code: provider sessions are present. 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}`
|
|
114
|
-
: `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}`;
|
|
115
|
+
: `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}${publicationInstructions}`;
|
|
115
116
|
const mcp = await mcpConfig(tempDir, credential, config.endpoint);
|
|
116
117
|
const additionalDirs = reviewWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
|
|
117
118
|
try {
|
|
@@ -119,7 +120,8 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
119
120
|
const agentDirectory = job.mode === 'review' ? tempDir : worktree;
|
|
120
121
|
const invocation = providerInvocation(kind, agentDirectory, additionalDirs, config, mcp, prompt, job.mode);
|
|
121
122
|
if (options.profile?.model) invocation.args.splice(kind === 'codex' ? 1 : 0, 0, '--model', options.profile.model);
|
|
122
|
-
const
|
|
123
|
+
const providerEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(GH_TOKEN|GITHUB_TOKEN|GH_ENTERPRISE_TOKEN|GITHUB_ENTERPRISE_TOKEN|GIT_CONFIG_VALUE_\d+)$/.test(key)));
|
|
124
|
+
const env = kind === 'codex' ? { ...providerEnv, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : providerEnv;
|
|
123
125
|
const diagnosticMode = kind === 'codex' && job.mode === 'review';
|
|
124
126
|
let writes = Promise.resolve();
|
|
125
127
|
let stderrBytes = 0;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile, chmod } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { paths } from './config.js';
|
|
4
|
+
import { command } from './process.js';
|
|
5
|
+
import { authorizeRepo, appGit } from './github-app.js';
|
|
6
|
+
import { sanitize } from './job.js';
|
|
7
|
+
|
|
8
|
+
const jobIdPattern = /^rjob-[A-Za-z0-9_-]+$/;
|
|
9
|
+
export function publicationPayload(targets = []) {
|
|
10
|
+
return JSON.stringify([...targets].sort((a, b) => a.repo.localeCompare(b.repo)).map(t => [t.repo, t.branch, t.base, t.appId, t.installationId, t.slug, t.sha || '']));
|
|
11
|
+
}
|
|
12
|
+
export function validBranch(value) {
|
|
13
|
+
return typeof value === 'string' && value.length <= 200 && !/\.\.|@\{|[\s~^:?*\[\\]|\/\/|\.$|\.lock(?:\/|$)|^\/|\/$/.test(value) && value.split('/').every(p => p && !p.startsWith('.'));
|
|
14
|
+
}
|
|
15
|
+
function manifestPath(jobId) {
|
|
16
|
+
if (!jobIdPattern.test(jobId)) throw new Error('Invalid publication job.');
|
|
17
|
+
return join(paths().root, 'publications', `${jobId}.json`);
|
|
18
|
+
}
|
|
19
|
+
export async function savePublication(manifest) {
|
|
20
|
+
const file = manifestPath(manifest.jobId);
|
|
21
|
+
await mkdir(join(paths().root, 'publications'), { recursive: true, mode: 0o700 });
|
|
22
|
+
await writeFile(`${file}.tmp`, `${JSON.stringify(manifest)}\n`, {mode: 0o600});
|
|
23
|
+
await chmod(`${file}.tmp`, 0o600); await rename(`${file}.tmp`, file);
|
|
24
|
+
}
|
|
25
|
+
export async function loadPublication(jobId) { return JSON.parse(await readFile(manifestPath(jobId), 'utf8')); }
|
|
26
|
+
export function publicationReport(manifest) {
|
|
27
|
+
return {execution: manifest.execution, repositories: manifest.repositories.map(({repo, branch, sha, stage, prNumber, prUrl}) => ({repo, branch, sha, stage, ...(prNumber ? {prNumber, prUrl} : {})}))};
|
|
28
|
+
}
|
|
29
|
+
async function git(args, signal) {
|
|
30
|
+
const result = await command('git', ['-c', 'core.hooksPath=/dev/null', ...args], {signal});
|
|
31
|
+
if (result.code !== 0) throw new Error('Local publication commit inspection failed.');
|
|
32
|
+
return result.output.trim();
|
|
33
|
+
}
|
|
34
|
+
export async function preparePublication(job, checkouts, execution = 'completed', signal) {
|
|
35
|
+
if (job.protocolVersion !== 3 || !job.publicationTargets) throw new Error('Publication requires a signed local GitHub App job. Upgrade Pulse and configure github-app.');
|
|
36
|
+
const manifest = {version: 1, jobId: job.id, workspaceId: job.workspaceId, runnerId: job.runnerId, projectId: job.projectId, issueId: job.issueId, execution, repositories: []};
|
|
37
|
+
// Record each recovery ref before cleanup, including commits from local source repos.
|
|
38
|
+
for (const checkout of checkouts) {
|
|
39
|
+
const target = job.publicationTargets.find(t => t.repo === checkout.repo);
|
|
40
|
+
if (!target) throw new Error('Repository is outside signed publication scope.');
|
|
41
|
+
const sha = await git(['-C', checkout.worktree, 'rev-parse', 'HEAD'], signal);
|
|
42
|
+
|
|
43
|
+
const branch = await git(['-C', checkout.worktree, 'branch', '--show-current'], signal);
|
|
44
|
+
if (branch !== target.branch && !(job.mode === 'rework' && branch === '')) throw new Error('Agent commit branch differs from the signed job branch.');
|
|
45
|
+
const status = await git(['-C', checkout.worktree, 'status', '--porcelain'], signal);
|
|
46
|
+
if (status.split('\n').some(line => line && !line.endsWith('.pulse-runner-job'))) throw new Error('Commit all changes before publication.');
|
|
47
|
+
if (sha === checkout.baseCommit) continue;
|
|
48
|
+
const storage = checkout.sourceDirectory || checkout.mirror;
|
|
49
|
+
const recoveryRef = `refs/pulse-recovery/${job.id}/${checkout.repo.replace('/', '__')}`;
|
|
50
|
+
await git([...(checkout.sourceDirectory ? ['-C', storage] : ['--git-dir', storage]), 'update-ref', recoveryRef, sha], signal);
|
|
51
|
+
const title = sanitize(await git(['-C', checkout.worktree, 'log', '-1', '--format=%s'], signal)).replace(/\s+/g, ' ').slice(0, 120) || 'Pulse Runner update';
|
|
52
|
+
manifest.repositories.push({...target, baseCommit: checkout.baseCommit, sha, title, storage, sourceDirectory: !!checkout.sourceDirectory, recoveryRef, stage: 'pending'});
|
|
53
|
+
await savePublication(manifest);
|
|
54
|
+
}
|
|
55
|
+
await savePublication(manifest);
|
|
56
|
+
return manifest;
|
|
57
|
+
}
|
|
58
|
+
export async function resumePublication(job) {
|
|
59
|
+
const original = await loadPublication(job.recoveryOf);
|
|
60
|
+
if (original.workspaceId !== job.workspaceId || original.runnerId !== job.runnerId || original.projectId !== job.projectId || original.issueId !== job.issueId || original.execution !== 'completed') throw new Error('Publication recovery identity mismatch.');
|
|
61
|
+
const repositories = job.publicationTargets.map(target => {
|
|
62
|
+
const entry = original.repositories.find(e => e.repo === target.repo);
|
|
63
|
+
if (!entry || entry.sha !== target.sha || ['branch','base','appId','installationId','slug'].some(key => entry[key] !== target[key])) throw new Error('Publication recovery scope mismatch.');
|
|
64
|
+
return {...entry};
|
|
65
|
+
});
|
|
66
|
+
if (repositories.length !== original.repositories.length) throw new Error('Publication recovery omitted repositories.');
|
|
67
|
+
const manifest = {...original, jobId: job.id, repositories};
|
|
68
|
+
await savePublication(manifest);
|
|
69
|
+
return manifest;
|
|
70
|
+
}
|
|
71
|
+
export async function publishManifest(config, job, manifest, checkActive, remote, signal, deps = {}) {
|
|
72
|
+
const succeeded = []; const failed = [];
|
|
73
|
+
for (const entry of manifest.repositories) {
|
|
74
|
+
let phase = 'github-preflight';
|
|
75
|
+
try {
|
|
76
|
+
await checkActive();
|
|
77
|
+
const grant = await (deps.authorizeRepo || authorizeRepo)(config, job.projectId, entry.repo);
|
|
78
|
+
if (['appId', 'installationId', 'slug'].some(key => grant.identity[key] !== entry[key])) throw new Error('Selected local App identity differs from signed job.');
|
|
79
|
+
if (grant.base !== entry.base) throw new Error('Repository base changed since the job was authorized.');
|
|
80
|
+
const prefix = entry.sourceDirectory ? ['-C', entry.storage] : ['--git-dir', entry.storage];
|
|
81
|
+
const recovered = await git([...prefix, 'rev-parse', entry.recoveryRef], signal);
|
|
82
|
+
if (recovered !== entry.sha) throw new Error('Recovered commit differs from signed publication.');
|
|
83
|
+
const workflows = await git([...prefix, 'diff', '--name-only', entry.baseCommit, entry.sha, '--', '.github/workflows'], signal);
|
|
84
|
+
if (workflows) throw new Error('Workflow changes require separately authorized GitHub Workflows permission.');
|
|
85
|
+
// Reconcile the exact branch even when the previous attempt recorded a PR.
|
|
86
|
+
// A timeout can leave a push/PR complete but not recorded in the manifest.
|
|
87
|
+
phase = 'push';
|
|
88
|
+
await checkActive();
|
|
89
|
+
let remoteRef;
|
|
90
|
+
try { remoteRef = await grant.api(`/repos/${entry.repo}/git/ref/heads/${entry.branch.split('/').map(encodeURIComponent).join('/')}`); } catch (error) { if (error.status !== 404) throw error; }
|
|
91
|
+
if (remoteRef && remoteRef.object?.sha !== entry.sha && remoteRef.object?.sha !== entry.baseCommit) throw new Error('Remote branch diverged from the recovered publication commit.');
|
|
92
|
+
if (remoteRef?.object?.sha !== entry.sha) await (deps.appGit || appGit)(grant, entry.repo, [...prefix, 'push', `https://github.com/${entry.repo}.git`, `${entry.sha}:refs/heads/${entry.branch}`], {signal, timeout: 120_000});
|
|
93
|
+
entry.stage = 'pushed'; await savePublication(manifest);
|
|
94
|
+
phase = 'find-pull-request';
|
|
95
|
+
const prs = await grant.api(`/repos/${entry.repo}/pulls?state=all&head=${encodeURIComponent(`${entry.repo.split('/')[0]}:${entry.branch}`)}&base=${encodeURIComponent(entry.base)}&per_page=100`);
|
|
96
|
+
let pr = prs.find(p => p.head?.ref === entry.branch && p.head?.repo?.full_name === entry.repo && p.base?.ref === entry.base && p.state === 'open');
|
|
97
|
+
if (!pr && prs.length) throw new Error('A previous PR is closed or its publication scope changed.');
|
|
98
|
+
if (!pr) {
|
|
99
|
+
phase = 'create-pull-request'; await checkActive();
|
|
100
|
+
pr = await grant.api(`/repos/${entry.repo}/pulls`, {method: 'POST', body: {title: entry.title, head: entry.branch, base: entry.base, body: `Pulse Runner work for issue ${job.issueId}.`, draft: true}});
|
|
101
|
+
}
|
|
102
|
+
if (pr.head?.sha !== entry.sha || pr.head?.ref !== entry.branch || pr.head?.repo?.full_name !== entry.repo || pr.base?.repo?.full_name !== entry.repo || pr.base?.ref !== entry.base || !Number.isSafeInteger(pr.number) || pr.html_url !== `https://github.com/${entry.repo}/pull/${pr.number}` || pr.state !== 'open') throw new Error('GitHub PR does not match the exact publication commit and repository.');
|
|
103
|
+
entry.prNumber = pr.number; entry.prUrl = pr.html_url; entry.stage = 'pr_created'; await savePublication(manifest);
|
|
104
|
+
phase = 'link-pull-request'; await checkActive();
|
|
105
|
+
await remote(config, 'pulseRunnerLinkPr', {jobId: job.id, repo: entry.repo, branch: entry.branch, sha: entry.sha, prNumber: pr.number, prUrl: pr.html_url, prState: pr.draft ? 'draft' : 'open'});
|
|
106
|
+
entry.stage = 'linked'; await savePublication(manifest);
|
|
107
|
+
succeeded.push(entry.prUrl);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (signal?.aborted) throw error;
|
|
110
|
+
failed.push({repo: entry.repo, phase, error: sanitize(error.message)});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {succeeded, failed};
|
|
114
|
+
}
|
package/src/readiness.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { githubReadiness } from './github-app.js';
|
|
1
2
|
import { command, resolveExecutable } from './process.js';
|
|
2
3
|
|
|
3
4
|
export function localIdentities(config) {
|
|
@@ -17,19 +18,16 @@ export async function inspectReadiness(config, run = command) {
|
|
|
17
18
|
}).catch(() => false);
|
|
18
19
|
providers[kind] = { cli, session: !!session };
|
|
19
20
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
repositories.push({ repo, accessible });
|
|
25
|
-
}
|
|
26
|
-
return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories, jobProtocolVersion: 2, qaSourceProtocolVersion: 1 };
|
|
21
|
+
const githubApps = await githubReadiness(config);
|
|
22
|
+
const repositories = [...new Set([...(config.repos || []), ...githubApps.map(e => e.repo)])].map(repo => ({repo, accessible: githubApps.some(e => e.repo === repo && e.ready)}));
|
|
23
|
+
|
|
24
|
+
return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories, githubApps, jobProtocolVersion: 3, qaSourceProtocolVersion: 1 };
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
export function assertLocalReadiness(job, config, agent, readiness) {
|
|
30
28
|
const identity = localIdentities(config).find((entry) => entry.agentId === job.agentId);
|
|
31
29
|
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
32
30
|
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.mode !== 'review' && (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.');
|
|
31
|
+
if (!job.recoveryOf && (!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.`);
|
|
32
|
+
if (job.mode !== 'review' && job.protocolVersion !== 3 && (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
33
|
}
|
package/src/runtime.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { authorizeRepo, appGit } from './github-app.js';
|
|
2
|
+
import { preparePublication, resumePublication, publishManifest, publicationReport, loadPublication } from './publication.js';
|
|
1
3
|
import { appendFile, chmod, mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
4
|
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { join } from 'node:path';
|
|
4
6
|
import { ensureDataDir, paths, readCredential, readProfiles, removeTemporary } from './config.js';
|
|
5
7
|
import { validateJob, sanitize } from './job.js';
|
|
6
|
-
import { command,
|
|
8
|
+
import { command, runAgent } from './process.js';
|
|
7
9
|
import { addIsolatedWorktree, removeIsolatedWorktree } from './worktree.js';
|
|
8
10
|
import { resolveProfile, validateLocalRepo } from './profile.js';
|
|
9
11
|
import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
|
|
@@ -50,16 +52,20 @@ function completionSummary(result, publication) {
|
|
|
50
52
|
const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
|
|
51
53
|
return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
|
|
52
54
|
}
|
|
53
|
-
async function checkoutRepo(repo, worktree, signal, sourceDirectory) {
|
|
54
|
-
if (sourceDirectory)
|
|
55
|
+
async function checkoutRepo(repo, worktree, signal, sourceDirectory, grant, target, transport = appGit) {
|
|
56
|
+
if (sourceDirectory) {
|
|
57
|
+
const ref = target?.branch && target.rework ? target.branch : target.base;
|
|
58
|
+
await transport(grant, repo, ['-C', sourceDirectory, 'fetch', `https://github.com/${repo}.git`, `refs/heads/${ref}`], {signal, timeout: 120_000});
|
|
59
|
+
return addIsolatedWorktree(repo, sourceDirectory, worktree, {signal, sourceDirectory, ref: 'FETCH_HEAD'});
|
|
60
|
+
}
|
|
55
61
|
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
56
62
|
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
57
63
|
if (existing.code !== 0) {
|
|
58
|
-
const cloned = await
|
|
64
|
+
const cloned = await transport(grant, repo, ['clone', '--mirror', `https://github.com/${repo}.git`, mirror], { signal, timeout: 120_000 });
|
|
59
65
|
if (cloned.code !== 0) throw new Error(sanitize(cloned.output));
|
|
60
66
|
}
|
|
61
|
-
const fetchResult = await
|
|
62
|
-
return addIsolatedWorktree(repo, mirror, worktree, { signal });
|
|
67
|
+
const fetchResult = await transport(grant, repo, ['--git-dir', mirror, 'fetch', `https://github.com/${repo}.git`, '+refs/heads/*:refs/heads/*'], { signal, timeout: 120_000 }); if (fetchResult.code !== 0) throw new Error(sanitize(fetchResult.output));
|
|
68
|
+
return addIsolatedWorktree(repo, mirror, worktree, { signal, ref: `refs/heads/${target.rework ? target.branch : target.base}` });
|
|
63
69
|
}
|
|
64
70
|
async function checkout(job, options = {}) {
|
|
65
71
|
if (job.mode === 'review') return checkoutQa(job, options);
|
|
@@ -67,8 +73,13 @@ async function checkout(job, options = {}) {
|
|
|
67
73
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
68
74
|
try {
|
|
69
75
|
for (const repo of repos) {
|
|
70
|
-
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal, repo === job.repoFullName ? options.profile?.directory : undefined);
|
|
76
|
+
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal, repo === job.repoFullName ? options.profile?.directory : undefined, options.grants?.[repo], {...job.publicationTargets?.find(t => t.repo === repo), rework: job.mode === 'rework'}, options.transport);
|
|
71
77
|
await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
|
|
78
|
+
const target = job.publicationTargets?.find(t => t.repo === repo);
|
|
79
|
+
if (target && job.mode !== 'rework') {
|
|
80
|
+
const switched = await command('git', ['-C', checkout.worktree, 'switch', '-c', target.branch], {signal: options.signal});
|
|
81
|
+
if (switched.code !== 0) throw new Error('Could not prepare the signed publication branch.');
|
|
82
|
+
}
|
|
72
83
|
checkouts.push(checkout);
|
|
73
84
|
}
|
|
74
85
|
const primary = checkouts.find((checkout) => checkout.repo === job.repoFullName);
|
|
@@ -104,68 +115,9 @@ async function cleanup(checkouts) {
|
|
|
104
115
|
await removeTemporary(worktree);
|
|
105
116
|
}));
|
|
106
117
|
}
|
|
107
|
-
async function publishCheckout(checkout, jobId, issueId, checkActive, signal) {
|
|
108
|
-
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current'], { signal });
|
|
109
|
-
const branch = branchResult.output.trim();
|
|
110
|
-
if (!branch || !branch.startsWith('pul/')) {
|
|
111
|
-
await logEvent('publication.skipped', { jobId, repo: checkout.repo, reason: 'branch-not-pulse' });
|
|
112
|
-
return '';
|
|
113
|
-
}
|
|
114
|
-
let phase = 'validate-commit';
|
|
115
|
-
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain'], { signal });
|
|
116
|
-
const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
|
|
117
|
-
if (uncommitted.length > 0) throw Object.assign(new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`), { phase });
|
|
118
|
-
phase = 'inspect-commit';
|
|
119
|
-
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD'], { signal });
|
|
120
|
-
if (head.code !== 0) throw Object.assign(new Error(sanitize(head.output)), { phase });
|
|
121
|
-
if (head.output.trim() === checkout.baseCommit) {
|
|
122
|
-
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
123
|
-
return '';
|
|
124
|
-
}
|
|
125
|
-
const subject = await command('git', ['-C', checkout.worktree, 'log', '-1', '--format=%s'], { signal });
|
|
126
|
-
if (subject.code !== 0) throw Object.assign(new Error(sanitize(subject.output)), { phase });
|
|
127
|
-
const pullRequestTitle = sanitize(subject.output.trim().replace(/\s+/g, ' ')).slice(0, 120) || 'Pulse Runner update';
|
|
128
|
-
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
129
|
-
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
130
|
-
phase = 'push';
|
|
131
|
-
await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
|
|
132
|
-
await checkActive();
|
|
133
|
-
const pushed = await command('git', [...(checkout.sourceDirectory ? ['-C', checkout.worktree] : ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror]), 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { signal, timeout: 120_000 });
|
|
134
|
-
if (pushed.code !== 0) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
|
|
135
|
-
await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
|
|
136
|
-
phase = 'find-pull-request';
|
|
137
|
-
const existing = await command(resolveExecutable('gh'), ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url'], { signal, timeout: 30_000 });
|
|
138
|
-
if (existing.code === 0) {
|
|
139
|
-
await logEvent('publication.pull-request-exists', { jobId, repo: checkout.repo, branch });
|
|
140
|
-
return `Published ${checkout.repo}:${branch}; existing PR detected.`;
|
|
141
|
-
}
|
|
142
|
-
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
143
|
-
phase = 'create-pull-request';
|
|
144
|
-
await checkActive();
|
|
145
|
-
const pullRequestBody = `Automated changes from Pulse Runner for issue ${sanitize(issueId)}.`;
|
|
146
|
-
const created = await command(resolveExecutable('gh'), ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--title', pullRequestTitle, '--body', pullRequestBody], { signal, timeout: 30_000 });
|
|
147
|
-
if (created.code !== 0) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
148
|
-
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
149
|
-
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
150
|
-
}
|
|
151
|
-
async function publish(checkouts, jobId, issueId, checkActive, signal) {
|
|
152
|
-
const publications = { succeeded: [], failed: [] };
|
|
153
|
-
for (const checkout of checkouts) {
|
|
154
|
-
try {
|
|
155
|
-
const published = await publishCheckout(checkout, jobId, issueId, checkActive, signal);
|
|
156
|
-
if (published) publications.succeeded.push(published);
|
|
157
|
-
} catch (error) {
|
|
158
|
-
if (error.name === 'AbortError') throw error;
|
|
159
|
-
const failure = { repo: checkout.repo, phase: error.phase || 'prepare', error: sanitize(error.message) };
|
|
160
|
-
publications.failed.push(failure);
|
|
161
|
-
await logEvent('publication.failed', { jobId, ...failure });
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return publications;
|
|
165
|
-
}
|
|
166
118
|
let readinessCache;
|
|
167
119
|
async function readinessFor(config, force = false) {
|
|
168
|
-
const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos]);
|
|
120
|
+
const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos, config.githubApps]);
|
|
169
121
|
if (force || !readinessCache || readinessCache.key !== key || Date.now() - readinessCache.at > 60_000) readinessCache = { key, at: Date.now(), value: await inspectReadiness(config) };
|
|
170
122
|
return readinessCache.value;
|
|
171
123
|
}
|
|
@@ -174,7 +126,8 @@ export async function runOnce(config, options = {}) {
|
|
|
174
126
|
const remote = options.request || request;
|
|
175
127
|
const readinessForJob = options.readinessFor || readinessFor;
|
|
176
128
|
const prepare = options.checkout || checkout;
|
|
177
|
-
const publishWork = options.publish
|
|
129
|
+
const publishWork = options.publish;
|
|
130
|
+
const authorize = options.authorizeRepo || authorizeRepo;
|
|
178
131
|
const cleanWork = options.cleanup || cleanup;
|
|
179
132
|
const controller = new AbortController();
|
|
180
133
|
const cancel = () => controller.abort();
|
|
@@ -205,9 +158,16 @@ export async function runOnce(config, options = {}) {
|
|
|
205
158
|
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
206
159
|
validateJob(job, config, agent);
|
|
207
160
|
jobPhase = 'local-preflight';
|
|
208
|
-
const profile = await resolveProfile({ ...config, agentKind: agent.kind }, job);
|
|
209
|
-
const jobConfig = job.protocolVersion
|
|
161
|
+
const profile = job.recoveryOf ? null : await resolveProfile({ ...config, agentKind: agent.kind }, job);
|
|
162
|
+
const jobConfig = [2, 3].includes(job.protocolVersion) ? { ...config, repos: job.contextRepos || [job.repoFullName] } : config;
|
|
163
|
+
if (job.mode !== 'review' && job.protocolVersion !== 3) throw new Error('Development publication requires a signed GitHub App job (protocol 3). Upgrade Pulse and configure github-app locally.');
|
|
210
164
|
assertLocalReadiness(job, jobConfig, agent, await readinessForJob(jobConfig, true));
|
|
165
|
+
const grants = {};
|
|
166
|
+
if (job.protocolVersion === 3) for (const target of job.publicationTargets) {
|
|
167
|
+
const grant = await authorize(jobConfig, job.projectId, target.repo);
|
|
168
|
+
if (['appId','installationId','slug'].some(key => grant.identity[key] !== target[key]) || grant.base !== target.base) throw new Error('Selected local GitHub App does not match signed publication identity.');
|
|
169
|
+
grants[target.repo] = grant;
|
|
170
|
+
}
|
|
211
171
|
await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
|
|
212
172
|
await checkActive();
|
|
213
173
|
expiryTimer = setTimeout(cancel, Math.max(0, Date.parse(job.expiresAt) - Date.now()));
|
|
@@ -225,31 +185,48 @@ export async function runOnce(config, options = {}) {
|
|
|
225
185
|
try { await checkActive(); } catch { controller.abort(); break; }
|
|
226
186
|
}
|
|
227
187
|
})();
|
|
228
|
-
jobPhase = 'prepare-worktrees';
|
|
229
|
-
await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
|
|
230
|
-
await chmod(journal, 0o600);
|
|
231
|
-
const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile, config, credential: delivery.mcpCredential });
|
|
232
|
-
const temp = join(paths().root, 'tmp', job.id);
|
|
233
188
|
let completion;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
await
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
await
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
189
|
+
if (job.recoveryOf) {
|
|
190
|
+
jobPhase = 'recover-publication';
|
|
191
|
+
const manifest = await resumePublication(job);
|
|
192
|
+
const publication = await publishManifest(config, job, manifest, checkActive, remote, controller.signal, {authorizeRepo: authorize, ...options.publicationDeps});
|
|
193
|
+
completion = {jobId: job.id, outcome: publication.failed.length ? 'failed' : 'completed', result: sanitize(completionSummary({code: 0}, publication)), publication: publicationReport(manifest), failure: publication.failed.length ? {phase: publication.failed[0].phase, category: 'execution', correlationId: job.id} : null};
|
|
194
|
+
} else {
|
|
195
|
+
jobPhase = 'prepare-worktrees';
|
|
196
|
+
await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
|
|
197
|
+
await chmod(journal, 0o600);
|
|
198
|
+
const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile, config, credential: delivery.mcpCredential, grants, transport: options.appGit });
|
|
199
|
+
const temp = join(paths().root, 'tmp', job.id);
|
|
200
|
+
let preserveWork = job.protocolVersion === 3;
|
|
201
|
+
const marker = join(paths().worktrees, job.id, '.publication-pending');
|
|
202
|
+
if (preserveWork) await writeFile(marker, 'Local work awaiting durable publication snapshot.', {mode: 0o600});
|
|
203
|
+
try {
|
|
204
|
+
controller.signal.throwIfAborted();
|
|
205
|
+
jobPhase = 'run-agent';
|
|
206
|
+
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
|
+
controller.signal.throwIfAborted();
|
|
209
|
+
await checkActive();
|
|
210
|
+
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
|
211
|
+
jobPhase = 'publication';
|
|
212
|
+
let manifest;
|
|
213
|
+
if (job.protocolVersion === 3) {
|
|
214
|
+
manifest = await preparePublication(job, checkouts, result.code === 0 ? 'completed' : 'failed', controller.signal);
|
|
215
|
+
if (result.code === 0) await remote(config, 'pulseRunnerPublication', {jobId: job.id, publication: publicationReport(manifest)});
|
|
216
|
+
preserveWork = false; await rm(marker, {force: true});
|
|
217
|
+
}
|
|
218
|
+
const publication = result.code === 0 && job.mode !== 'review'
|
|
219
|
+
? publishWork ? await publishWork(checkouts, job.id, job.issueId, checkActive, controller.signal) : await publishManifest(config, job, manifest, checkActive, remote, controller.signal, {authorizeRepo: authorize, ...options.publicationDeps})
|
|
220
|
+
: {succeeded: [], failed: []};
|
|
221
|
+
await checkActive();
|
|
222
|
+
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
223
|
+
const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
|
|
224
|
+
jobPhase = 'report-completion';
|
|
225
|
+
completion = { jobId: job.id, outcome, result: sanitize(completionSummary(result, publication)), failure, ...(manifest ? {publication: publicationReport(manifest)} : {}) };
|
|
226
|
+
} finally {
|
|
227
|
+
await removeTemporary(temp);
|
|
228
|
+
if (!preserveWork) { await cleanWork(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
|
|
229
|
+
}
|
|
253
230
|
}
|
|
254
231
|
controller.signal.throwIfAborted();
|
|
255
232
|
await remote(config, 'pulseRunnerComplete', completion);
|
|
@@ -261,7 +238,8 @@ export async function runOnce(config, options = {}) {
|
|
|
261
238
|
const phase = error.phase || jobPhase;
|
|
262
239
|
const message = canceled ? 'Job canceled; local execution stopped.' : error?.message || String(error);
|
|
263
240
|
await logEvent(canceled ? 'job.canceled' : 'job.failed', { jobId: job.id, phase, error: message });
|
|
264
|
-
|
|
241
|
+
const savedPublication = await loadPublication(job.id).catch(() => null);
|
|
242
|
+
await remote(config, 'pulseRunnerComplete', { ...(savedPublication ? {publication: publicationReport(savedPublication)} : {}), jobId: job.id, outcome: canceled ? 'canceled' : 'failed', result: sanitize(`${phase}: ${message}`), failure: canceled ? null : { phase, category: phase === 'validate-job' ? 'configuration' : ['local-preflight', 'prepare-worktrees'].includes(phase) ? 'local_preparation' : 'execution', correlationId: job.id } }).then(() => { completionReported = true; }).catch(async (completionError) => {
|
|
265
243
|
await logEvent('job.completion-report-failed', { jobId: job.id, error: completionError.message });
|
|
266
244
|
});
|
|
267
245
|
if (!canceled) throw error;
|
|
@@ -281,7 +259,7 @@ export async function recoverInterruptedWork(config) {
|
|
|
281
259
|
for (const area of [p.worktrees, join(p.root, 'tmp')]) {
|
|
282
260
|
await mkdir(area, { recursive: true, mode: 0o700 });
|
|
283
261
|
const jobs = await readdir(area, { withFileTypes: true });
|
|
284
|
-
await Promise.all(jobs.map(async (entry) => { if (entry.isDirectory()) await makeWritable(join(area, entry.name)); await rm(join(area, entry.name), { recursive: true, force: true }); }));
|
|
262
|
+
await Promise.all(jobs.map(async (entry) => { if (area === p.worktrees && await stat(join(area, entry.name, '.publication-pending')).then(() => true).catch(() => false)) return; if (entry.isDirectory()) await makeWritable(join(area, entry.name)); await rm(join(area, entry.name), { recursive: true, force: true }); }));
|
|
285
263
|
}
|
|
286
264
|
const repositories = await readdir(p.repos, { withFileTypes: true });
|
|
287
265
|
await Promise.all(repositories.filter((entry) => entry.isDirectory()).map((entry) => command('git', ['--git-dir', join(p.repos, entry.name), 'worktree', 'prune']).catch(() => {})));
|
package/src/worktree.js
CHANGED
|
@@ -4,7 +4,7 @@ import { sanitize } from './job.js';
|
|
|
4
4
|
export async function addIsolatedWorktree(repo, gitDir, worktree, options = {}) {
|
|
5
5
|
options.signal?.throwIfAborted();
|
|
6
6
|
const prefix = options.sourceDirectory ? ['-C', options.sourceDirectory] : ['--git-dir', gitDir];
|
|
7
|
-
const added = await command('git', [...prefix, 'worktree', 'add', '--detach', worktree, 'HEAD'], options);
|
|
7
|
+
const added = await command('git', [...prefix, 'worktree', 'add', '--detach', worktree, options.ref || 'HEAD'], options);
|
|
8
8
|
if (added.code !== 0) throw new Error(sanitize(added.output));
|
|
9
9
|
const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD'], options);
|
|
10
10
|
if (base.code !== 0) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { generateKeyPairSync, verify } from 'node:crypto';
|
|
4
|
+
import { appJwt, authorizeRepo, appGit, bindingFor, githubReadiness, githubApi } from '../src/github-app.js';
|
|
5
|
+
const {privateKey, publicKey}=generateKeyPairSync('rsa',{modulusLength:2048});
|
|
6
|
+
const pem=privateKey.export({type:'pkcs8',format:'pem'}).toString();
|
|
7
|
+
const binding={projectId:'project-1',repo:'owner/repo',appId:'1',installationId:'2',slug:'local-app',keyId:'local-key'};
|
|
8
|
+
const config={githubApps:[binding]};
|
|
9
|
+
function apiFixture(overrides={}) {
|
|
10
|
+
const calls=[];
|
|
11
|
+
const api=async(path,token,options)=>{
|
|
12
|
+
calls.push({path,token,options});
|
|
13
|
+
if(path==='/app') return {id:1,slug:'local-app',...overrides.app};
|
|
14
|
+
if(path==='/app/installations/2') return {account:{login:'owner'},permissions:{contents:'write',pull_requests:'write'},...overrides.installation};
|
|
15
|
+
if(path.endsWith('access_tokens')) return {token:'sentinel-github-secret',expires_at:new Date(Date.now()+3600000).toISOString(),permissions:{contents:'write',pull_requests:'write'},repositories:[{full_name:'owner/repo'}],...overrides.grant};
|
|
16
|
+
return {full_name:'owner/repo',default_branch:'main',...overrides.repo};
|
|
17
|
+
};
|
|
18
|
+
return {calls,api};
|
|
19
|
+
}
|
|
20
|
+
test('local App signs a short-lived JWT and never falls back to active gh identity',async()=>{
|
|
21
|
+
const jwt=appJwt('1',pem,1000000);
|
|
22
|
+
const [header,payload,signature]=jwt.split('.');
|
|
23
|
+
assert(verify('RSA-SHA256',Buffer.from(`${header}.${payload}`),publicKey,Buffer.from(signature,'base64url')));
|
|
24
|
+
const data=JSON.parse(Buffer.from(payload,'base64url'));assert.equal(data.iss,'1');assert.equal(data.exp-data.iat,600);
|
|
25
|
+
const f=apiFixture();const grant=await authorizeRepo(config,'project-1','owner/repo',{readKey:async()=>pem,api:f.api});
|
|
26
|
+
assert.equal(grant.identity.slug,'local-app');
|
|
27
|
+
const request=f.calls.find(c=>c.path.endsWith('access_tokens'));
|
|
28
|
+
assert.deepEqual(request.options.body,{repositories:['repo'],permissions:{contents:'write',pull_requests:'write',metadata:'read'}});
|
|
29
|
+
assert.throws(()=>bindingFor(config,'project-2','owner/repo'));
|
|
30
|
+
assert.throws(()=>bindingFor({...config,githubApps:[binding,binding]},'project-1','owner/repo'));
|
|
31
|
+
});
|
|
32
|
+
test('preflight rejects wrong App/owner, suspended installations, broad grants and missing PR permission',async()=>{
|
|
33
|
+
for(const overrides of [{app:{slug:'other'}},{installation:{suspended_at:'now'}},{installation:{account:{login:'other'}}},{installation:{permissions:{contents:'write'}}},{grant:{repositories:[{full_name:'owner/repo'},{full_name:'owner/other'}]}},{grant:{expires_at:'invalid'}},{repo:{archived:true}}]) {
|
|
34
|
+
const f=apiFixture(overrides);await assert.rejects(authorizeRepo(config,'project-1','owner/repo',{readKey:async()=>pem,api:f.api}));
|
|
35
|
+
}
|
|
36
|
+
const readiness=await githubReadiness(config,{readKey:async()=>{throw new Error('sentinel-private-key');}});
|
|
37
|
+
assert.equal(readiness[0].ready,false);assert(!JSON.stringify(readiness).includes('sentinel'));
|
|
38
|
+
});
|
|
39
|
+
test('Git transport passes secrets only to transport environment and ignores global credentials',async()=>{
|
|
40
|
+
const grant={identity:{repo:'owner/repo'},token:'sentinel-github-secret',expiresAt:new Date(Date.now()+60000).toISOString()};
|
|
41
|
+
await appGit(grant,'owner/repo',['push','https://github.com/owner/repo.git','a:refs/heads/pul/job'],{},async(exe,args,opts)=>{
|
|
42
|
+
assert.equal(exe,'git');assert(!args.join(' ').includes(grant.token));assert(!JSON.stringify(args).includes('Authorization'));
|
|
43
|
+
assert.equal(opts.env.GH_TOKEN,undefined);assert.equal(opts.env.GITHUB_TOKEN,undefined);
|
|
44
|
+
assert.equal(opts.env.GIT_CONFIG_NOSYSTEM,'1');assert.equal(opts.env.GIT_CONFIG_GLOBAL,'/dev/null');
|
|
45
|
+
assert(opts.env.GIT_CONFIG_VALUE_0.startsWith('Authorization: Basic '));return {code:0,output:''};
|
|
46
|
+
});
|
|
47
|
+
await assert.rejects(appGit(grant,'owner/other',[]));
|
|
48
|
+
await assert.rejects(appGit({...grant,expiresAt:'2000-01-01'},'owner/repo',[]));
|
|
49
|
+
});
|
|
50
|
+
test('GitHub errors discard reflected credentials rather than exposing response bodies',async()=>{
|
|
51
|
+
await assert.rejects(githubApi('/app','sentinel',{},async()=>({ok:false,status:403,text:async()=> 'sentinel-private-key'})),error=>error.message.includes('403')&&!error.message.includes('sentinel'));
|
|
52
|
+
});
|
package/test/integration.test.js
CHANGED
|
@@ -37,9 +37,9 @@ else {
|
|
|
37
37
|
const received = args.includes('exec') ? !!process.env.PULSE_RUNNER_MCP_CREDENTIAL?.startsWith('Bearer ') : !!JSON.parse(readFileSync(file)).mcpServers.pulse.headers.Authorization;
|
|
38
38
|
const child = settings.slow ? spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'ignore'}) : null;
|
|
39
39
|
writeFileSync(${JSON.stringify(trace)}, JSON.stringify({args, received, cwd: process.cwd(), pid: process.pid, childPid: child?.pid}));
|
|
40
|
-
writeFileSync('job-output.txt', 'isolated output');
|
|
41
40
|
if (settings.publish) {
|
|
42
|
-
|
|
41
|
+
writeFileSync('job-output.txt', 'isolated output');
|
|
42
|
+
for (const argv of [['add','job-output.txt'],['-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture job']]) {
|
|
43
43
|
const result=spawnSync('git',argv,{encoding:'utf8'});
|
|
44
44
|
if(result.status!==0) throw new Error(result.stderr);
|
|
45
45
|
}
|
|
@@ -63,15 +63,18 @@ else console.log('https://github.com/owner/repo/pull/123');
|
|
|
63
63
|
await updateSettings();
|
|
64
64
|
const p = await ensureDataDir();
|
|
65
65
|
await writeCredential('runner-fixture.synthetic-device');
|
|
66
|
-
await git(['init', source]);
|
|
66
|
+
await git(['init', '--initial-branch=main', source]);
|
|
67
67
|
await writeFile(join(source, 'source.txt'), 'original');
|
|
68
68
|
await git(['-C', source, 'add', 'source.txt']);
|
|
69
69
|
await git(['-C', source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'initial']);
|
|
70
70
|
await git(['clone', '--mirror', source, join(p.repos, 'owner__repo.git')]);
|
|
71
71
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
72
72
|
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() };
|
|
73
|
-
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 };
|
|
74
|
-
const signJob = () => {
|
|
73
|
+
const job = { protocolVersion: 3, projectId: 'project-fixture', 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 };
|
|
74
|
+
const signJob = () => {
|
|
75
|
+
if (job.mode === 'review') {job.protocolVersion=2;delete job.publicationTargets;}
|
|
76
|
+
else if (job.protocolVersion===3 && !job.recoveryOf) job.publicationTargets=(job.contextRepos || [job.repoFullName]).map(repo=>({repo,branch:'pul/rjob-test',base:'main',appId:'1',installationId:'2',slug:'fixture-app'}));
|
|
77
|
+
job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url'); };
|
|
75
78
|
signJob();
|
|
76
79
|
const calls = [];
|
|
77
80
|
const control = { cancel: false, cancelWhenRunning: false, disconnectWhenRunning: false };
|
|
@@ -97,7 +100,23 @@ else console.log('https://github.com/owner/repo/pull/123');
|
|
|
97
100
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
98
101
|
config.endpoint = `http://127.0.0.1:${server.address().port}`;
|
|
99
102
|
const readinessFor = () => inspectReadiness(config, (exe, args, options) => command(exe, exe === 'git' ? ['ls-remote', '--exit-code', source, 'HEAD'] : args, options));
|
|
100
|
-
|
|
103
|
+
let pushSha;
|
|
104
|
+
const appGit = async (grant, repo, args, options) => {
|
|
105
|
+
assert.equal(grant.identity.repo, repo);
|
|
106
|
+
assert(!args.join(' ').includes('sentinel-token'));
|
|
107
|
+
const rewritten = args.map(arg => arg.startsWith('https://github.com/') ? source : arg);
|
|
108
|
+
if (args.includes('push')) pushSha=args.at(-1).split(':')[0];
|
|
109
|
+
const result=await command('git', rewritten, options); assert.equal(result.code,0,result.output); return result;
|
|
110
|
+
};
|
|
111
|
+
const authorizeRepo = async (_config, projectId, repo) => {
|
|
112
|
+
if (!repo.startsWith('owner/')) throw new Error('Unauthorized fixture repository.');
|
|
113
|
+
return {identity: {projectId,repo,appId:'1',installationId:'2',slug:'fixture-app'},base:'main',token:'sentinel-token',expiresAt:new Date(Date.now()+60000).toISOString(),api:async(path, options)=>{
|
|
114
|
+
if (path.includes('/git/ref/')) throw Object.assign(new Error('Missing fixture ref'),{status:404});
|
|
115
|
+
if (!options) return [];
|
|
116
|
+
return {number:123,html_url:`https://github.com/${repo}/pull/123`,state:'open',head:{sha:pushSha,ref:'pul/rjob-test',repo:{full_name:repo}},base:{ref:'main',repo:{full_name:repo}}};
|
|
117
|
+
}};
|
|
118
|
+
};
|
|
119
|
+
const run = (extra = {}) => runOnce(config, { readinessFor, authorizeRepo, appGit, publicationDeps: {appGit}, heartbeatIntervalMs: 20, ...extra });
|
|
101
120
|
try { await callback({root, source, trace, config, job, signJob, settings, updateSettings, calls, control, run}); }
|
|
102
121
|
finally {
|
|
103
122
|
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
|
|
@@ -149,7 +168,7 @@ test('integration: invalid signed jobs never reach checkout', () => fixture('cod
|
|
|
149
168
|
}));
|
|
150
169
|
|
|
151
170
|
for (const cause of ['remote', 'signal', 'revoked']) {
|
|
152
|
-
test(`integration: ${cause} cancellation stops the CLI and descendants, skips publication and
|
|
171
|
+
test(`integration: ${cause} cancellation stops the CLI and descendants, skips publication and preserves interrupted work`, () => fixture('codex', async f => {
|
|
153
172
|
f.settings.slow=true; await f.updateSettings();
|
|
154
173
|
f.control.cancelWhenRunning=cause==='remote';
|
|
155
174
|
f.control.disconnectWhenRunning=cause==='revoked';
|
|
@@ -165,7 +184,7 @@ for (const cause of ['remote', 'signal', 'revoked']) {
|
|
|
165
184
|
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
166
185
|
assert.equal(published,false);
|
|
167
186
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
168
|
-
assert.equal(await exists(join(paths().worktrees,f.job.id)),
|
|
187
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),true);
|
|
169
188
|
assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
|
|
170
189
|
assert.equal(await git(['-C',f.source,'status','--porcelain']),'');
|
|
171
190
|
for(const pid of [trace.pid,trace.childPid]) {
|
|
@@ -240,7 +259,7 @@ test('integration: a job expires during execution and is stopped before publicat
|
|
|
240
259
|
await f.run({publish:async()=>{published=true;}});
|
|
241
260
|
assert.equal(published,false);
|
|
242
261
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
243
|
-
assert.equal(await exists(join(paths().worktrees,f.job.id)),
|
|
262
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),true);
|
|
244
263
|
}));
|
|
245
264
|
|
|
246
265
|
for (const kind of ['codex','claude']) test(`integration: ${kind} QA reads all project snapshots without global Git auth and never publishes`,()=>fixture(kind,async f=>{
|
|
@@ -287,18 +306,18 @@ test('integration: committed job branches push to a local remote and report the
|
|
|
287
306
|
await f.run();
|
|
288
307
|
const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
289
308
|
assert.equal(completion.outcome,'completed');
|
|
290
|
-
assert.
|
|
309
|
+
assert.equal(completion.publication.repositories[0].branch,'pul/rjob-test');
|
|
291
310
|
assert.match(completion.result,/https:\/\/github.com\/owner\/repo\/pull\/123/);
|
|
292
311
|
assert.equal(await git(['-C',f.source,'rev-parse','HEAD']),original);
|
|
293
|
-
assert.notEqual(await git(['-C',f.source,'rev-parse','refs/heads/pul/
|
|
294
|
-
|
|
295
|
-
assert.
|
|
312
|
+
assert.notEqual(await git(['-C',f.source,'rev-parse','refs/heads/pul/rjob-test']),original);
|
|
313
|
+
assert.equal(await exists(join(f.root,'gh-calls.jsonl')),false);
|
|
314
|
+
assert(f.calls.some(c=>c.path==='/pulseRunnerLinkPr'));
|
|
296
315
|
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
297
316
|
}));
|
|
298
317
|
|
|
299
318
|
|
|
300
|
-
test('integration:
|
|
301
|
-
f.job.protocolVersion=
|
|
319
|
+
test('integration: v3 job uses its signed repos without a local allowlist',()=>fixture('codex',async f=>{
|
|
320
|
+
f.job.protocolVersion=3;f.job.projectId='project-fixture';f.signJob();
|
|
302
321
|
f.config.repos=[];
|
|
303
322
|
const readinessFor=(config)=>inspectReadiness(config,(exe,args,options)=>command(exe,exe==='git'?['ls-remote','--exit-code',f.source,'HEAD']:args,options));
|
|
304
323
|
await f.run({readinessFor});
|
|
@@ -309,14 +328,14 @@ test('integration: v2 job uses its signed repos without a local allowlist',()=>f
|
|
|
309
328
|
test('integration: local profile worktree/model preserve source checkout and cancellation cleanup',()=>fixture('codex',async f=>{
|
|
310
329
|
await git(['-C',f.source,'remote','add','origin','https://github.com/owner/repo.git']);
|
|
311
330
|
await writeFile(paths().profiles,JSON.stringify({[`${f.config.workspaceId}:${f.job.agentId}`]:{model:'fixture-model',directories:{'owner/repo':f.source}}}));
|
|
312
|
-
f.job.protocolVersion=
|
|
331
|
+
f.job.protocolVersion=3;f.job.projectId='project-fixture';f.signJob();
|
|
313
332
|
f.settings.slow=true;await f.updateSettings();f.control.cancelWhenRunning=true;
|
|
314
333
|
await f.run();
|
|
315
334
|
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
316
335
|
assert.equal(trace.args[trace.args.indexOf('--model')+1],'fixture-model');
|
|
317
336
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
318
337
|
assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
|
|
319
|
-
assert.equal((await git(['-C',f.source,'worktree','list','--porcelain'])).split('worktree ').length-1,
|
|
338
|
+
assert.equal((await git(['-C',f.source,'worktree','list','--porcelain'])).split('worktree ').length-1,2);
|
|
320
339
|
}));
|
|
321
340
|
|
|
322
341
|
test('integration: profile origins reject lookalike hosts, foreign repos and unauthorized context',()=>fixture('codex', async f => {
|
|
@@ -341,7 +360,7 @@ test('integration: CLI init, pairing and diagnose need no per-agent repo configu
|
|
|
341
360
|
const after=JSON.parse(await readFile(join(home,'config.json'),'utf8'));
|
|
342
361
|
assert.equal(after.signingPublicKey,before.signingPublicKey);assert.deepEqual(after.repos,[]);
|
|
343
362
|
const readiness=JSON.parse(await run(['diagnose']));
|
|
344
|
-
assert.equal(readiness.jobProtocolVersion,
|
|
363
|
+
assert.equal(readiness.jobProtocolVersion,3);assert.deepEqual(readiness.repositories,[]);
|
|
345
364
|
}));
|
|
346
365
|
|
|
347
366
|
for (const kind of ['codex','claude']) test(`integration: ${kind} QA access denial stops before provider execution and criteria`,()=>fixture(kind,async f=>{
|
|
@@ -351,3 +370,25 @@ for (const kind of ['codex','claude']) test(`integration: ${kind} QA access deni
|
|
|
351
370
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.failure.phase,'prepare-worktrees');
|
|
352
371
|
assert.equal(f.calls.some(c=>c.path.includes('submit_review')),false);
|
|
353
372
|
}));
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
test('integration: publication failure retains commits and retry does not run the agent',()=>fixture('codex',async f=>{
|
|
376
|
+
f.settings.publish=true; await f.updateSettings();
|
|
377
|
+
let publishCalls=0;
|
|
378
|
+
await f.run({publicationDeps:{appGit:async()=>{publishCalls++;throw new Error('fixture push denied');}}});
|
|
379
|
+
const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
380
|
+
assert.equal(completion.outcome,'failed');assert.equal(completion.publication.execution,'completed');
|
|
381
|
+
assert.equal(completion.publication.repositories[0].stage,'pending');
|
|
382
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
383
|
+
const manifest=JSON.parse(await readFile(join(paths().root,'publications',`${f.job.id}.json`),'utf8'));
|
|
384
|
+
const entry=manifest.repositories[0];
|
|
385
|
+
assert.equal(await git(['--git-dir',entry.storage,'rev-parse',entry.recoveryRef]),entry.sha);
|
|
386
|
+
const previousTrace=await readFile(f.trace,'utf8');
|
|
387
|
+
f.calls.length=0;f.job.recoveryOf=f.job.id;f.job.id='rjob-retry';
|
|
388
|
+
f.job.publicationTargets=[...f.job.publicationTargets.map(t=>({...t,sha:entry.sha}))];f.signJob();
|
|
389
|
+
await f.run();
|
|
390
|
+
assert.equal(await readFile(f.trace,'utf8'),previousTrace);
|
|
391
|
+
const retry=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
392
|
+
assert.equal(retry.outcome,'completed');assert.equal(retry.publication.repositories[0].sha,entry.sha);
|
|
393
|
+
assert.equal(retry.publication.repositories[0].stage,'linked');assert.equal(publishCalls,1);
|
|
394
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {mkdtemp,writeFile,readFile,rm} from 'node:fs/promises';
|
|
4
|
+
import {tmpdir} from 'node:os';
|
|
5
|
+
import {join} from 'node:path';
|
|
6
|
+
import {command} from '../src/process.js';
|
|
7
|
+
import {paths} from '../src/config.js';
|
|
8
|
+
import {preparePublication,resumePublication,publishManifest} from '../src/publication.js';
|
|
9
|
+
async function git(args) {const r=await command('git',args);assert.equal(r.code,0,r.output);return r.output.trim();}
|
|
10
|
+
test('partial multi-repo publication reconciles push/PR/link and preserves exact commits across restart',async()=>{
|
|
11
|
+
const root=await mkdtemp(join(tmpdir(),'pulse-publication-'));const saved=process.env.PULSE_RUNNER_HOME;process.env.PULSE_RUNNER_HOME=join(root,'runner');
|
|
12
|
+
try {
|
|
13
|
+
const targets=['owner/app','owner/backend'].map(repo=>({repo,branch:'pul/rjob-original',base:'main',appId:'1',installationId:'2',slug:'local-app'}));
|
|
14
|
+
const job={id:'rjob-original',protocolVersion:3,projectId:'project-1',workspaceId:'ws',runnerId:'runner',issueId:'issue',publicationTargets:targets};
|
|
15
|
+
const checkouts=[];
|
|
16
|
+
for(const target of targets) {
|
|
17
|
+
const source=join(root,target.repo.split('/')[1]);await git(['init','--initial-branch=main',source]);
|
|
18
|
+
await writeFile(join(source,'file.txt'),'base');await git(['-C',source,'add','.']);await git(['-C',source,'-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','base']);
|
|
19
|
+
const baseCommit=await git(['-C',source,'rev-parse','HEAD']);await git(['-C',source,'switch','-c',target.branch]);
|
|
20
|
+
await writeFile(join(source,'file.txt'),'changed');await git(['-C',source,'add','.']);await git(['-C',source,'-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','change']);
|
|
21
|
+
checkouts.push({repo:target.repo,worktree:source,sourceDirectory:source,baseCommit});
|
|
22
|
+
}
|
|
23
|
+
const manifest=await preparePublication(job,checkouts);
|
|
24
|
+
const refs=new Map(),prs=new Map();let pushes=0,created=0,links=0,denyLink=true;
|
|
25
|
+
const authorizeRepo=async(_config,projectId,repo)=>({identity:{projectId,repo,appId:'1',installationId:'2',slug:'local-app'},base:'main',token:'secret-sentinel',api:async(path,opts)=>{
|
|
26
|
+
if(path.includes('/git/ref/')) {if(refs.has(repo)) return {object:{sha:refs.get(repo)}};throw Object.assign(new Error('missing'),{status:404});}
|
|
27
|
+
if(!opts) return prs.has(repo)?[prs.get(repo)]:[];
|
|
28
|
+
created++;const pr={number:created,state:'open',draft:true,html_url:`https://github.com/${repo}/pull/${created}`,head:{sha:refs.get(repo),ref:targets[0].branch,repo:{full_name:repo}},base:{ref:'main',repo:{full_name:repo}}};prs.set(repo,pr);return pr;
|
|
29
|
+
}});
|
|
30
|
+
const appGit=async(_grant,repo,args)=>{pushes++;refs.set(repo,args.at(-1).split(':')[0]);};
|
|
31
|
+
const remote=async(_config,path,body)=>{assert.equal(path,'pulseRunnerLinkPr');links++;if(denyLink&&body.repo==='owner/backend')throw new Error('Pulse unavailable');};
|
|
32
|
+
const first=await publishManifest({},job,manifest,async()=>{},remote,null,{authorizeRepo,appGit});
|
|
33
|
+
assert.equal(first.failed.length,1);assert.equal(first.failed[0].phase,'link-pull-request');assert.equal(pushes,2);assert.equal(created,2);
|
|
34
|
+
assert(!JSON.stringify(manifest).includes('secret-sentinel'));
|
|
35
|
+
for(const entry of manifest.repositories) assert.equal(await git(['-C',entry.storage,'rev-parse',entry.recoveryRef]),entry.sha);
|
|
36
|
+
const retry={...job,id:'rjob-retry',recoveryOf:job.id,publicationTargets:manifest.repositories.map(e=>({...targets.find(t=>t.repo===e.repo),sha:e.sha}))};
|
|
37
|
+
denyLink=false;const recovered=await resumePublication(retry);
|
|
38
|
+
const second=await publishManifest({},retry,recovered,async()=>{},remote,null,{authorizeRepo,appGit});
|
|
39
|
+
assert.equal(second.failed.length,0);assert.equal(pushes,2,'push should not repeat');assert.equal(created,2,'PR should not duplicate');assert.equal(links,4);
|
|
40
|
+
assert(recovered.repositories.every(e=>e.stage==='linked'));
|
|
41
|
+
const durable=JSON.parse(await readFile(join(paths().root,'publications','rjob-retry.json'),'utf8'));assert.equal(durable.repositories[0].sha,manifest.repositories[0].sha);
|
|
42
|
+
refs.set('owner/backend','b'.repeat(40));const divergent=await publishManifest({},retry,recovered,async()=>{},remote,null,{authorizeRepo,appGit});
|
|
43
|
+
assert.equal(divergent.failed.length,1);assert.match(divergent.failed[0].error,/diverged/);assert.equal(pushes,2);
|
|
44
|
+
} finally {if(saved===undefined)delete process.env.PULSE_RUNNER_HOME;else process.env.PULSE_RUNNER_HOME=saved;await rm(root,{recursive:true,force:true});}
|
|
45
|
+
});
|
package/test/readiness.test.js
CHANGED
|
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { inspectReadiness, assertLocalReadiness } from '../src/readiness.js';
|
|
4
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' };
|
|
5
|
+
const job = { agentId: 'agent-1', repoFullName: 'owner/repo', mode: 'task', protocolVersion: 3 };
|
|
6
6
|
const agent = { kind: 'codex', role: 'dev' };
|
|
7
7
|
test('readiness uses native status commands and reports only booleans', async () => {
|
|
8
8
|
const calls = [];
|