@pulsehub/runner 0.1.6 → 0.1.7
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 +20 -0
- package/package.json +1 -1
- package/src/process.js +10 -7
- package/src/profile.js +1 -1
- package/src/qa-source.py +120 -0
- package/src/readiness.js +2 -2
- package/src/runtime.js +36 -8
- package/test/integration.test.js +24 -5
- package/test/qa-source.test.js +45 -0
package/README.md
CHANGED
|
@@ -85,3 +85,23 @@ pulse-runner profile remove --repo owner/repo
|
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
Profiles are scoped by workspace and agent and saved locally with owner-only permissions. `--model` requires support in the installed provider CLI. Repository access probes run locally for the signed job context before checkout; no provider sessions are sent to Pulse.
|
|
88
|
+
|
|
89
|
+
### QA de proyectos con repos privados
|
|
90
|
+
|
|
91
|
+
QA requiere Python 3 además del CLI y su sesión local (Codex o Claude).
|
|
92
|
+
Los snapshots completos se descargan por `pulseQaSource` con la key efímera
|
|
93
|
+
del job; la identidad GitHub de lectura permanece en el backend y se revoca
|
|
94
|
+
tras cada descarga. No se requiere login global de Git/gh ni permisos duplicados
|
|
95
|
+
por agente. El proyecto actual determina todos los repos; un cambio desde el
|
|
96
|
+
dispatch o un head cambiado obliga a repetir el run.
|
|
97
|
+
|
|
98
|
+
El manifiesto `qa-sources.json` registra cada SHA exacto. Los snapshots son de
|
|
99
|
+
sólo lectura, sin `.git`; el proveedor arranca en un directorio temporal fuera
|
|
100
|
+
de los repos para evitar hooks/configuraciones del código revisado. QA no corre
|
|
101
|
+
scripts, builds o tests locales con la sesión del proveedor presente. Un fallo
|
|
102
|
+
al preparar repos se reporta como infraestructura antes de reclamar la revisión.
|
|
103
|
+
|
|
104
|
+
Los ZIPs tienen un límite de 30 MiB comprimidos/256 MiB expandidos por repo;
|
|
105
|
+
enlaces, archivos especiales y traversal se rechazan con diagnóstico visible.
|
|
106
|
+
La limpieza y recuperación eliminan también snapshots interrumpidos de sólo
|
|
107
|
+
lectura. Este protocolo requiere desplegar la versión compatible del backend.
|
package/package.json
CHANGED
package/src/process.js
CHANGED
|
@@ -95,20 +95,23 @@ export function providerInvocation(kind, worktree, additionalDirs, config, mcp,
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export async function runAgent(kind, job, worktree, additionalWorktrees, config, credential, tempDir, options = {}) {
|
|
98
|
-
const
|
|
98
|
+
const reviewWorktrees = job.mode === 'review' ? [{ repo: job.repoFullName, worktree }, ...additionalWorktrees] : additionalWorktrees;
|
|
99
|
+
const context = reviewWorktrees.length
|
|
99
100
|
? job.mode === 'review'
|
|
100
|
-
? ` Additional review worktrees for inspection: ${
|
|
101
|
-
: ` Additional writable project worktrees: ${
|
|
101
|
+
? ` Snapshot manifest: ${join(worktree, '..', 'qa-sources.json')}. Additional review worktrees for inspection: ${reviewWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}.`
|
|
102
|
+
: ` 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.`
|
|
102
103
|
: '';
|
|
103
104
|
const prompt = job.mode === 'review'
|
|
104
|
-
? `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. 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}`
|
|
105
|
+
? `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}`
|
|
105
106
|
: `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}`;
|
|
106
107
|
const mcp = await mcpConfig(tempDir, credential, config.endpoint);
|
|
107
|
-
const additionalDirs =
|
|
108
|
+
const additionalDirs = reviewWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
|
|
108
109
|
try {
|
|
109
|
-
|
|
110
|
+
// Start QA outside every snapshot so repository CLI settings/hooks cannot run.
|
|
111
|
+
const agentDirectory = job.mode === 'review' ? tempDir : worktree;
|
|
112
|
+
const invocation = providerInvocation(kind, agentDirectory, additionalDirs, config, mcp, prompt, job.mode);
|
|
110
113
|
if (options.profile?.model) invocation.args.splice(kind === 'codex' ? 1 : 0, 0, '--model', options.profile.model);
|
|
111
114
|
const env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
|
|
112
|
-
return await command(invocation.executable, invocation.args, { cwd:
|
|
115
|
+
return await command(invocation.executable, invocation.args, { cwd: agentDirectory, env, signal: options.signal });
|
|
113
116
|
} finally { await rm(mcp, { force: true }); }
|
|
114
117
|
}
|
package/src/profile.js
CHANGED
|
@@ -27,7 +27,7 @@ export async function resolveProfile(config, job) {
|
|
|
27
27
|
const profile = await agentProfile({ ...config, agentId: job.agentId });
|
|
28
28
|
if (profile.model && !validModel(profile.model)) throw new Error('Configured model name is invalid.');
|
|
29
29
|
if (profile.model && !(await supportsModelOption(config.agentKind))) throw new Error(`The installed ${config.agentKind} CLI does not support the --model option.`);
|
|
30
|
-
const directory = profile.directories?.[job.repoFullName];
|
|
30
|
+
const directory = job.mode === 'review' ? undefined : profile.directories?.[job.repoFullName];
|
|
31
31
|
if (!directory) return { ...profile, directory: undefined };
|
|
32
32
|
return { ...profile, directory: await validateLocalRepo(config, job.repoFullName, directory, job.contextRepos || [job.repoFullName]) };
|
|
33
33
|
}
|
package/src/qa-source.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Trusted preparation, shared with the generated Actions workflow. No repo code runs."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
import stat
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
import zipfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def call(body):
|
|
14
|
+
request = urllib.request.Request(os.environ['PULSE_QA_SOURCE_URL'],
|
|
15
|
+
data=json.dumps(body).encode(), headers={
|
|
16
|
+
'Authorization': 'Bearer ' + os.environ['PULSE_QA_CREDENTIAL'],
|
|
17
|
+
'Content-Type': 'application/json'})
|
|
18
|
+
try:
|
|
19
|
+
with urllib.request.urlopen(request, timeout=540) as response:
|
|
20
|
+
return response.read(30 * 1024 * 1024 + 1)
|
|
21
|
+
except urllib.error.HTTPError as error:
|
|
22
|
+
# Only structured infrastructure diagnostics; never echo headers or URLs.
|
|
23
|
+
try:
|
|
24
|
+
diagnostic = json.loads(error.read())
|
|
25
|
+
problems = diagnostic.get('problems') or [{'message': diagnostic.get('message', 'acceso denegado')}]
|
|
26
|
+
raise RuntimeError('; '.join((p.get('repo', '') + ': ' if p.get('repo') and not p['message'].startswith(p['repo']) else '') + p['message'] for p in problems)) from None
|
|
27
|
+
except (ValueError, KeyError):
|
|
28
|
+
raise RuntimeError('QA infrastructure HTTP %d' % error.code) from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract(data, target):
|
|
32
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
33
|
+
total = 0
|
|
34
|
+
entries = []
|
|
35
|
+
roots = set()
|
|
36
|
+
for entry in archive.infolist():
|
|
37
|
+
path = PurePosixPath(entry.filename)
|
|
38
|
+
mode = entry.external_attr >> 16
|
|
39
|
+
if path.is_absolute() or '..' in path.parts or '\\' in entry.filename:
|
|
40
|
+
raise RuntimeError('Snapshot con ruta insegura')
|
|
41
|
+
roots.add(path.parts[0])
|
|
42
|
+
if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) not in (0, stat.S_IFREG, stat.S_IFDIR)):
|
|
43
|
+
raise RuntimeError('Snapshot con enlace o archivo especial')
|
|
44
|
+
if '.git' in path.parts:
|
|
45
|
+
raise RuntimeError('Snapshot con metadatos Git')
|
|
46
|
+
total += entry.file_size
|
|
47
|
+
if total > 256 * 1024 * 1024:
|
|
48
|
+
raise RuntimeError('Snapshot expandido supera 256 MiB')
|
|
49
|
+
entries.append((entry, path.parts[1:]))
|
|
50
|
+
if len(roots) != 1:
|
|
51
|
+
raise RuntimeError('Snapshot sin raíz única')
|
|
52
|
+
target.mkdir(parents=True)
|
|
53
|
+
for entry, parts in entries:
|
|
54
|
+
if not parts:
|
|
55
|
+
continue
|
|
56
|
+
dest = target.joinpath(*parts)
|
|
57
|
+
if entry.is_dir():
|
|
58
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
else:
|
|
60
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with dest.open('xb') as output:
|
|
62
|
+
output.write(archive.read(entry))
|
|
63
|
+
dest.chmod(0o444)
|
|
64
|
+
for directory in sorted((p for p in target.rglob('*') if p.is_dir()), reverse=True):
|
|
65
|
+
directory.chmod(0o555)
|
|
66
|
+
target.chmod(0o555)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
identifier = os.environ['PULSE_QA_IDENTIFIER']
|
|
71
|
+
root = Path(os.environ['PULSE_QA_ROOT'])
|
|
72
|
+
manifest = json.loads(call({'identifier': identifier}))
|
|
73
|
+
expected = json.loads(os.environ.get('PULSE_QA_EXPECTED_REPOS', 'null'))
|
|
74
|
+
if expected is not None and sorted(expected) != sorted(r['repo'] for r in manifest['repositories']):
|
|
75
|
+
raise RuntimeError('El proyecto cambió desde el dispatch; repetí el run')
|
|
76
|
+
primary = os.environ.get('PULSE_QA_PRIMARY_REPO')
|
|
77
|
+
primary_snapshot = next((r for r in manifest['repositories'] if r['repo'] == primary), None)
|
|
78
|
+
if os.environ.get('PULSE_QA_MANIFEST_ONLY') == '1':
|
|
79
|
+
if not primary_snapshot:
|
|
80
|
+
raise RuntimeError('Repositorio anfitrión fuera del proyecto')
|
|
81
|
+
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
|
|
82
|
+
output.write('head_sha=' + primary_snapshot['sha'] + '\n')
|
|
83
|
+
return
|
|
84
|
+
expected_head = os.environ.get('PULSE_QA_EXPECTED_PRIMARY_SHA')
|
|
85
|
+
if expected_head and (not primary_snapshot or primary_snapshot['sha'] != expected_head):
|
|
86
|
+
raise RuntimeError('El head cambió desde verify; repetí el run')
|
|
87
|
+
for snapshot in manifest['repositories']:
|
|
88
|
+
repo = snapshot['repo']
|
|
89
|
+
parts = repo.split('/')
|
|
90
|
+
if len(parts) != 2 or any(not part or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-' for c in part) for part in parts):
|
|
91
|
+
raise RuntimeError('Repositorio inválido en snapshot')
|
|
92
|
+
archive = call({'identifier': identifier, 'repo': repo, 'sha': snapshot['sha']})
|
|
93
|
+
extract(archive, root / repo.replace('/', '__'))
|
|
94
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
(root / 'qa-sources.json').write_text(json.dumps(manifest))
|
|
96
|
+
print('QA sources ready.')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == '__main__':
|
|
100
|
+
try:
|
|
101
|
+
main()
|
|
102
|
+
except Exception as error:
|
|
103
|
+
# Infrastructure is reported before claiming/evaluating a review.
|
|
104
|
+
try:
|
|
105
|
+
body = {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', 'params': {'name': 'pulse_comment_issue', 'arguments': {'identifier': os.environ['PULSE_QA_IDENTIFIER'], 'body': 'QA no arrancó: fallo de infraestructura al preparar repositorios. ' + str(error)}}}
|
|
106
|
+
request = urllib.request.Request(os.environ['PULSE_QA_SOURCE_URL'].replace('/pulseQaSource', '/pulseMcp'), data=json.dumps(body).encode(), headers={'Authorization': 'Bearer ' + os.environ['PULSE_QA_CREDENTIAL'], 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream'})
|
|
107
|
+
with urllib.request.urlopen(request, timeout=30):
|
|
108
|
+
pass
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
if os.environ.get('PULSE_QA_RUN_ID'):
|
|
112
|
+
try:
|
|
113
|
+
body = {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call', 'params': {'name': 'pulse_report_run', 'arguments': {'runId': os.environ['PULSE_QA_RUN_ID'], 'outcome': 'failed'}}}
|
|
114
|
+
request = urllib.request.Request(os.environ['PULSE_QA_SOURCE_URL'].replace('/pulseQaSource', '/pulseMcp'), data=json.dumps(body).encode(), headers={'Authorization': 'Bearer ' + os.environ['PULSE_QA_CREDENTIAL'], 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream'})
|
|
115
|
+
with urllib.request.urlopen(request, timeout=30):
|
|
116
|
+
pass
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
print('QA infrastructure: ' + str(error), file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
package/src/readiness.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function inspectReadiness(config, run = command) {
|
|
|
23
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
24
|
repositories.push({ repo, accessible });
|
|
25
25
|
}
|
|
26
|
-
return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories, jobProtocolVersion: 2 };
|
|
26
|
+
return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories, jobProtocolVersion: 2, qaSourceProtocolVersion: 1 };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function assertLocalReadiness(job, config, agent, readiness) {
|
|
@@ -31,5 +31,5 @@ export function assertLocalReadiness(job, config, agent, readiness) {
|
|
|
31
31
|
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
32
32
|
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
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.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.');
|
|
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.');
|
|
35
35
|
}
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { appendFile, chmod, mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { ensureDataDir, paths, readCredential, readProfiles, removeTemporary } from './config.js';
|
|
4
5
|
import { validateJob, sanitize } from './job.js';
|
|
@@ -61,6 +62,7 @@ async function checkoutRepo(repo, worktree, signal, sourceDirectory) {
|
|
|
61
62
|
return addIsolatedWorktree(repo, mirror, worktree, { signal });
|
|
62
63
|
}
|
|
63
64
|
async function checkout(job, options = {}) {
|
|
65
|
+
if (job.mode === 'review') return checkoutQa(job, options);
|
|
64
66
|
const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
|
|
65
67
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
66
68
|
try {
|
|
@@ -74,13 +76,35 @@ async function checkout(job, options = {}) {
|
|
|
74
76
|
return { primary, checkouts };
|
|
75
77
|
} catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
|
|
76
78
|
}
|
|
79
|
+
export async function checkoutQa(job, options = {}) {
|
|
80
|
+
const root = join(paths().worktrees, job.id);
|
|
81
|
+
const result = await command('python3', [fileURLToPath(new URL('./qa-source.py', import.meta.url))], {
|
|
82
|
+
signal: options.signal, timeout: 540_000,
|
|
83
|
+
env: { ...process.env, PULSE_QA_SOURCE_URL: options.config.endpoint.replace(/\/$/, '') + '/pulseQaSource',
|
|
84
|
+
PULSE_QA_CREDENTIAL: options.credential, PULSE_QA_IDENTIFIER: job.issueId, PULSE_QA_ROOT: root,
|
|
85
|
+
PULSE_QA_EXPECTED_REPOS: JSON.stringify(job.contextRepos || [job.repoFullName]) },
|
|
86
|
+
});
|
|
87
|
+
if (result.code !== 0) { await makeWritable(root).catch(() => {}); await removeTemporary(root); throw new Error(sanitize(result.output)); }
|
|
88
|
+
const manifest = JSON.parse(await readFile(join(root, 'qa-sources.json'), 'utf8'));
|
|
89
|
+
const checkouts = manifest.repositories.map(({ repo, sha }) => ({ repo, worktree: join(root, repoPath(repo)), baseCommit: sha, snapshot: true }));
|
|
90
|
+
const primary = checkouts.find((entry) => entry.repo === job.repoFullName);
|
|
91
|
+
if (!primary) throw new Error('QA snapshot target is missing.');
|
|
92
|
+
return { primary, checkouts };
|
|
93
|
+
}
|
|
94
|
+
async function makeWritable(path) {
|
|
95
|
+
await chmod(path, 0o700);
|
|
96
|
+
for (const entry of await readdir(path, { withFileTypes: true })) if (entry.isDirectory()) await makeWritable(join(path, entry.name));
|
|
97
|
+
}
|
|
77
98
|
async function cleanup(checkouts) {
|
|
78
|
-
await Promise.all(checkouts.map(async ({ worktree, mirror, sourceDirectory }) => {
|
|
99
|
+
await Promise.all(checkouts.map(async ({ worktree, mirror, sourceDirectory, snapshot }) => {
|
|
100
|
+
if (snapshot) {
|
|
101
|
+
await makeWritable(worktree); await removeTemporary(worktree); return;
|
|
102
|
+
}
|
|
79
103
|
await removeIsolatedWorktree({ mirror, worktree, sourceDirectory });
|
|
80
104
|
await removeTemporary(worktree);
|
|
81
105
|
}));
|
|
82
106
|
}
|
|
83
|
-
async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
107
|
+
async function publishCheckout(checkout, jobId, issueId, checkActive, signal) {
|
|
84
108
|
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current'], { signal });
|
|
85
109
|
const branch = branchResult.output.trim();
|
|
86
110
|
if (!branch || !branch.startsWith('pul/')) {
|
|
@@ -98,6 +122,9 @@ async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
|
98
122
|
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
99
123
|
return '';
|
|
100
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';
|
|
101
128
|
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
102
129
|
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
103
130
|
phase = 'push';
|
|
@@ -115,16 +142,17 @@ async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
|
115
142
|
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
116
143
|
phase = 'create-pull-request';
|
|
117
144
|
await checkActive();
|
|
118
|
-
const
|
|
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 });
|
|
119
147
|
if (created.code !== 0) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
120
148
|
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
121
149
|
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
122
150
|
}
|
|
123
|
-
async function publish(checkouts, jobId, checkActive, signal) {
|
|
151
|
+
async function publish(checkouts, jobId, issueId, checkActive, signal) {
|
|
124
152
|
const publications = { succeeded: [], failed: [] };
|
|
125
153
|
for (const checkout of checkouts) {
|
|
126
154
|
try {
|
|
127
|
-
const published = await publishCheckout(checkout, jobId, checkActive, signal);
|
|
155
|
+
const published = await publishCheckout(checkout, jobId, issueId, checkActive, signal);
|
|
128
156
|
if (published) publications.succeeded.push(published);
|
|
129
157
|
} catch (error) {
|
|
130
158
|
if (error.name === 'AbortError') throw error;
|
|
@@ -200,7 +228,7 @@ export async function runOnce(config, options = {}) {
|
|
|
200
228
|
jobPhase = 'prepare-worktrees';
|
|
201
229
|
await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
|
|
202
230
|
await chmod(journal, 0o600);
|
|
203
|
-
const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile });
|
|
231
|
+
const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile, config, credential: delivery.mcpCredential });
|
|
204
232
|
const temp = join(paths().root, 'tmp', job.id);
|
|
205
233
|
let completion;
|
|
206
234
|
try {
|
|
@@ -212,7 +240,7 @@ export async function runOnce(config, options = {}) {
|
|
|
212
240
|
await checkActive();
|
|
213
241
|
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
|
214
242
|
jobPhase = 'publication';
|
|
215
|
-
const publication = result.code === 0 && job.mode !== 'review' ? await publishWork(checkouts, job.id, checkActive, controller.signal) : { succeeded: [], failed: [] };
|
|
243
|
+
const publication = result.code === 0 && job.mode !== 'review' ? await publishWork(checkouts, job.id, job.issueId, checkActive, controller.signal) : { succeeded: [], failed: [] };
|
|
216
244
|
await checkActive();
|
|
217
245
|
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
218
246
|
const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
|
|
@@ -253,7 +281,7 @@ export async function recoverInterruptedWork(config) {
|
|
|
253
281
|
for (const area of [p.worktrees, join(p.root, 'tmp')]) {
|
|
254
282
|
await mkdir(area, { recursive: true, mode: 0o700 });
|
|
255
283
|
const jobs = await readdir(area, { withFileTypes: true });
|
|
256
|
-
await Promise.all(jobs.map((entry) => rm(join(area, entry.name), { recursive: true, force: 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 }); }));
|
|
257
285
|
}
|
|
258
286
|
const repositories = await readdir(p.repos, { withFileTypes: true });
|
|
259
287
|
await Promise.all(repositories.filter((entry) => entry.isDirectory()).map((entry) => command('git', ['--git-dir', join(p.repos, entry.name), 'worktree', 'prune']).catch(() => {})));
|
package/test/integration.test.js
CHANGED
|
@@ -36,7 +36,7 @@ else {
|
|
|
36
36
|
const file = args[args.indexOf('--mcp-config') + 1];
|
|
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
|
-
writeFileSync(${JSON.stringify(trace)}, JSON.stringify({args, received, pid: process.pid, childPid: child?.pid}));
|
|
39
|
+
writeFileSync(${JSON.stringify(trace)}, JSON.stringify({args, received, cwd: process.cwd(), pid: process.pid, childPid: child?.pid}));
|
|
40
40
|
writeFileSync('job-output.txt', 'isolated output');
|
|
41
41
|
if (settings.publish) {
|
|
42
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']]) {
|
|
@@ -75,11 +75,19 @@ else console.log('https://github.com/owner/repo/pull/123');
|
|
|
75
75
|
signJob();
|
|
76
76
|
const calls = [];
|
|
77
77
|
const control = { cancel: false, cancelWhenRunning: false, disconnectWhenRunning: false };
|
|
78
|
+
const zipResult = await command('python3', ['-c', "import io,zipfile,base64; b=io.BytesIO(); z=zipfile.ZipFile(b,'w'); z.writestr('snapshot/source.txt','QA fixture'); z.close(); print(base64.b64encode(b.getvalue()).decode())"]);
|
|
79
|
+
assert.equal(zipResult.code, 0);
|
|
80
|
+
const archive = Buffer.from(zipResult.output.trim(), 'base64');
|
|
78
81
|
const server = createServer(async (req, res) => {
|
|
79
82
|
let raw = ''; for await (const chunk of req) raw += chunk;
|
|
80
83
|
const body = JSON.parse(raw); calls.push({path:req.url, body});
|
|
81
84
|
res.setHeader('Content-Type', 'application/json');
|
|
82
|
-
if (req.url === '/
|
|
85
|
+
if (req.url === '/pulseQaSource') {
|
|
86
|
+
if (control.sourceDenied) { res.statusCode=409; res.end(JSON.stringify({problems:[{repo:'owner/private-runner',message:'owner/private-runner: HTTP 403'}]})); }
|
|
87
|
+
else if (body.repo) { res.setHeader('Content-Type','application/zip'); res.end(archive); }
|
|
88
|
+
else res.end(JSON.stringify({projectId:'project-fixture',repositories:(job.contextRepos || [job.repoFullName]).map(repo=>({repo,sha:'a'.repeat(40)}))}));
|
|
89
|
+
}
|
|
90
|
+
else if (req.url === '/pulseRunnerPoll') res.end(JSON.stringify({job, agent:{kind,role: job.mode === 'review' ? 'qa' : 'dev'}, mcpCredential:'pulse_sk_fixture'}));
|
|
83
91
|
else if (body.jobId && req.url === '/pulseRunnerHeartbeat') {
|
|
84
92
|
const running = await exists(trace);
|
|
85
93
|
if (control.disconnectWhenRunning && running) {res.statusCode=401;res.end('{}');}
|
|
@@ -235,13 +243,16 @@ test('integration: a job expires during execution and is stopped before publicat
|
|
|
235
243
|
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
236
244
|
}));
|
|
237
245
|
|
|
238
|
-
test(
|
|
239
|
-
f.job.mode='review'; f.signJob();
|
|
246
|
+
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=>{
|
|
247
|
+
f.job.mode='review'; f.job.contextRepos=['owner/repo','owner/app','owner/private-runner']; f.config.repos=f.job.contextRepos; f.signJob();
|
|
240
248
|
f.config.agentRole='qa';
|
|
241
249
|
let published=false;
|
|
242
250
|
await f.run({publish:async()=>{published=true;}});
|
|
243
251
|
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
244
|
-
assert.ok(trace.args.includes('read-only'));
|
|
252
|
+
assert.ok(trace.args.includes(kind==='codex' ? 'read-only' : 'plan'));
|
|
253
|
+
assert.match(trace.args.at(-1), /owner\/private-runner/);
|
|
254
|
+
assert.match(trace.cwd.replaceAll('\\','/'),new RegExp(`/runner/tmp/${f.job.id}$`,'i'));
|
|
255
|
+
assert.equal(f.calls.filter(c=>c.path==='/pulseQaSource' && c.body.repo).length,3);
|
|
245
256
|
assert.equal(published,false);
|
|
246
257
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
247
258
|
}));
|
|
@@ -323,3 +334,11 @@ test('integration: CLI init, pairing and diagnose need no per-agent repo configu
|
|
|
323
334
|
const readiness=JSON.parse(await run(['diagnose']));
|
|
324
335
|
assert.equal(readiness.jobProtocolVersion,2);assert.deepEqual(readiness.repositories,[]);
|
|
325
336
|
}));
|
|
337
|
+
|
|
338
|
+
for (const kind of ['codex','claude']) test(`integration: ${kind} QA access denial stops before provider execution and criteria`,()=>fixture(kind,async f=>{
|
|
339
|
+
f.job.mode='review'; f.config.agentRole='qa'; f.signJob(); f.control.sourceDenied=true;
|
|
340
|
+
await assert.rejects(f.run(),/private-runner.*403/);
|
|
341
|
+
assert.equal(await exists(f.trace),false);
|
|
342
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.failure.phase,'prepare-worktrees');
|
|
343
|
+
assert.equal(f.calls.some(c=>c.path.includes('submit_review')),false);
|
|
344
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { command } from '../src/process.js';
|
|
5
|
+
import { assertLocalReadiness } from '../src/readiness.js';
|
|
6
|
+
|
|
7
|
+
test('QA source extractor preserves full files read-only and rejects unsafe archives before extraction', async () => {
|
|
8
|
+
const script = `
|
|
9
|
+
import importlib.util,io,os,stat,tempfile,zipfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
spec=importlib.util.spec_from_file_location('qa_source',os.environ['QA_SCRIPT'])
|
|
12
|
+
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
|
13
|
+
def archive(name,mode=0):
|
|
14
|
+
b=io.BytesIO()
|
|
15
|
+
with zipfile.ZipFile(b,'w') as z:
|
|
16
|
+
info=zipfile.ZipInfo(name);info.external_attr=mode<<16;z.writestr(info,'complete code')
|
|
17
|
+
return b.getvalue()
|
|
18
|
+
with tempfile.TemporaryDirectory() as root:
|
|
19
|
+
target=Path(root)/'safe'
|
|
20
|
+
m.extract(archive('head/src/private.js'),target)
|
|
21
|
+
assert (target/'src/private.js').read_text()=='complete code'
|
|
22
|
+
assert (target/'src/private.js').stat().st_mode & 0o222 == 0
|
|
23
|
+
assert target.stat().st_mode & 0o222 == 0
|
|
24
|
+
for i,(name,mode) in enumerate([('head/../../escape',0),('/absolute',0),('head/.git/config',0),('head/link',stat.S_IFLNK|0o777),('head/device',stat.S_IFCHR|0o777)]):
|
|
25
|
+
invalid=Path(root)/str(i)
|
|
26
|
+
try:
|
|
27
|
+
m.extract(archive(name,mode),invalid)
|
|
28
|
+
raise AssertionError('unsafe archive accepted')
|
|
29
|
+
except RuntimeError:
|
|
30
|
+
assert not invalid.exists()
|
|
31
|
+
for path,dirs,files in os.walk(root):os.chmod(path,0o700)
|
|
32
|
+
`;
|
|
33
|
+
const result = await command('python3', ['-c', script], { env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', QA_SCRIPT: fileURLToPath(new URL('../src/qa-source.py', import.meta.url)) } });
|
|
34
|
+
assert.equal(result.code, 0, result.output);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('both local QA identities use provider readiness independently of global Git credentials', () => {
|
|
38
|
+
for (const kind of ['codex','claude']) {
|
|
39
|
+
const config = { identities: [{ agentId: kind, kind, role: 'qa' }] };
|
|
40
|
+
const job = { mode: 'review', agentId: kind, contextRepos: ['owner/private-runner'] };
|
|
41
|
+
const readiness = { providers: { [kind]: { cli: true, session: true } }, repositories: [{ repo: 'owner/private-runner', accessible: false }] };
|
|
42
|
+
assert.doesNotThrow(() => assertLocalReadiness(job, config, { kind, role: 'qa' }, readiness));
|
|
43
|
+
assert.throws(() => assertLocalReadiness({ ...job, mode: 'task' }, config, { kind, role: 'dev' }, readiness));
|
|
44
|
+
}
|
|
45
|
+
});
|