@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsehub/runner",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Local, credential-safe Pulse Runner",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 === 2 ? [...fields, job.protocolVersion, job.projectId] : fields).join('.');
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 !== 2) throw new Error('Job protocol is not supported.');
16
- if (job.protocolVersion === 2 && (typeof job.projectId !== 'string' || !/^[-A-Za-z0-9_]+$/.test(job.projectId))) throw new Error('Job project is invalid.');
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 !== 2 && !(config.repos || []).includes(repo)))) throw new Error('Job repository context is not allowed by this Runner.');
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 env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
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 repositories = [];
21
- for (const repo of config.repos || []) {
22
- if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error('Invalid repository.');
23
- const accessible = await run('git', ['ls-remote', '--exit-code', `https://github.com/${repo}.git`, 'HEAD'], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 10_000, maxOutput: 4096 }).then((result) => result.code === 0).catch(() => false);
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, resolveExecutable, runAgent } from './process.js';
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) return addIsolatedWorktree(repo, sourceDirectory, worktree, { signal, 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 command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror], { signal, timeout: 120_000 });
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 command('git', ['--git-dir', mirror, 'fetch', '--prune', 'origin'], { signal, timeout: 120_000 }); if (fetchResult.code !== 0) throw new Error(sanitize(fetchResult.output));
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 || 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 === 2 ? { ...config, repos: job.contextRepos || [job.repoFullName] } : config;
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
- try {
235
- controller.signal.throwIfAborted();
236
- jobPhase = 'run-agent';
237
- await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
238
- 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 }) });
239
- controller.signal.throwIfAborted();
240
- await checkActive();
241
- await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
242
- jobPhase = 'publication';
243
- const publication = result.code === 0 && job.mode !== 'review' ? await publishWork(checkouts, job.id, job.issueId, checkActive, controller.signal) : { succeeded: [], failed: [] };
244
- await checkActive();
245
- const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
246
- const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
247
- jobPhase = 'report-completion';
248
- completion = { jobId: job.id, outcome, result: sanitize(completionSummary(result, publication)), failure };
249
- } finally {
250
- await removeTemporary(temp);
251
- await cleanWork(checkouts);
252
- await removeTemporary(join(paths().worktrees, job.id));
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
- await remote(config, 'pulseRunnerComplete', { 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) => {
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
+ });
@@ -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
- for (const argv of [['switch','-c','pul/fixture'],['add','job-output.txt'],['-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture job']]) {
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 = () => { job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url'); };
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
- const run = (extra = {}) => runOnce(config, { readinessFor, heartbeatIntervalMs: 20, ...extra });
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 cleans worktrees`, () => fixture('codex', async f => {
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)),false);
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)),false);
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.match(completion.result,/owner\/repo:pul\/fixture/);
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/fixture']),original);
294
- const calls=(await readFile(join(f.root,'gh-calls.jsonl'),'utf8')).trim().split('\n').map(JSON.parse);
295
- assert.equal(calls[0][1],'view'); assert.equal(calls[1][1],'create');
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: v2 job uses its signed repos without a local allowlist',()=>fixture('codex',async f=>{
301
- f.job.protocolVersion=2;f.job.projectId='project-fixture';f.signJob();
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=2;f.job.projectId='project-fixture';f.signJob();
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,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,2);assert.deepEqual(readiness.repositories,[]);
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
+ });
@@ -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 = [];