@pulsehub/runner 0.1.5 → 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 +39 -4
- package/package.json +1 -1
- package/src/config.js +13 -2
- package/src/index.js +6 -6
- package/src/job.js +5 -2
- package/src/process.js +17 -7
- package/src/profile.js +63 -0
- package/src/qa-source.py +120 -0
- package/src/readiness.js +3 -3
- package/src/runtime.js +54 -17
- package/src/service.js +0 -1
- package/src/worktree.js +5 -4
- package/test/integration.test.js +81 -6
- package/test/job.test.js +17 -2
- package/test/qa-source.test.js +45 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ The Runner only uses local Git and provider sessions, creates disposable worktre
|
|
|
16
16
|
|
|
17
17
|
## QA review jobs
|
|
18
18
|
|
|
19
|
-
An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize each QA instance with `--agent-role qa`, and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key,
|
|
19
|
+
An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize each QA instance with `--agent-role qa`, and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key, enable every repository that has a PR in the issue project, pair its one-time device credential, and select it on the QA agent. Review jobs call `pulse_next_review` and `pulse_get_review_context` through Pulse MCP, inspect every PR, and submit their verdict with `pulse_submit_review`. They do not publish source changes or create PRs.
|
|
20
20
|
|
|
21
21
|
## Preparation and preflight (TES-304)
|
|
22
22
|
|
|
@@ -43,7 +43,7 @@ Authentication command references: [Codex CLI](https://developers.openai.com/cod
|
|
|
43
43
|
|
|
44
44
|
## Installation and background services (TES-286)
|
|
45
45
|
|
|
46
|
-
Install Node 20+, Git, the GitHub CLI and the provider CLI on macOS, Linux or Windows. Sign in locally with `gh auth login` and the provider's login command. No GitHub token needs to be entered in Pulse. Initialize with the public signing key shipped in this package, register the generated public key
|
|
46
|
+
Install Node 20+, Git, the GitHub CLI and the provider CLI on macOS, Linux or Windows. Sign in locally with `gh auth login` and the provider's login command. No GitHub token needs to be entered in Pulse. Initialize with the public signing key shipped in this package, register the generated public key in Settings → Agentes → Pulse Runners and enable repositories in the issue project, then pair the one-time device credential on that machine. A signing **public** key is safe to install locally; never copy the backend's signing private key.
|
|
47
47
|
|
|
48
48
|
Run `pulse-runner diagnose`, then use `pulse-runner run --once` for one job or `pulse-runner start` for foreground operation. `service install` registers a LaunchAgent on macOS, a systemd user unit on Linux, or an on-logon Scheduled Task on Windows. `service start|stop|status|uninstall` manages that installation. Run these commands as the same OS user who owns the local provider and Git sessions. Linux background execution requires a working user systemd session; macOS requires a logged-in GUI user; the Windows task starts on user logon.
|
|
49
49
|
|
|
@@ -57,16 +57,51 @@ While executing, the Runner sends an outbound heartbeat every two seconds with i
|
|
|
57
57
|
|
|
58
58
|
Ctrl+C or SIGTERM also cancels foreground work. A force-killed daemon recovers on its next start: its local journal stores only the job ID, cleans interrupted worktrees/MCP files, prunes Git registrations and reports the interrupted job canceled. Committed branches remain in the local mirror; uncommitted disposable worktree edits are removed. The source checkout remains untouched. On Windows, forcibly ending the daemon may defer cleanup until that restart.
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Repository authorization comes from the issue project's enabled repositories, intersected with the workspace GitHub installation. Pairing and agent binding do not require a repository list. `repo add/remove` is deprecated for new jobs; configure repositories in Pulse's project settings. Existing local lists remain usable only for queued legacy jobs. Each project must explicitly declare its repositories; missing configuration blocks dispatch with a visible issue diagnostic.
|
|
61
61
|
|
|
62
62
|
Codex receives the ephemeral MCP Authorization header through a child-process environment variable, never command-line arguments. Claude receives a user-only temporary MCP configuration file, deleted after execution. Provider authentication is always discovered with native status commands; Pulse receives readiness booleans and sanitized lifecycle/results, not provider sessions.
|
|
63
63
|
|
|
64
64
|
## Signing and rollout
|
|
65
65
|
|
|
66
|
-
Backend `RUNNER_JOB_SIGNING_PRIVATE_KEY` must already exist before deployment, be Ed25519, and match this release's `keys/runner-job-v1-public.pem` and `runner-job-v1` key ID. `contextRepos` is included in the signed envelope. Unknown keys, malformed/future/expired timestamps, invalid identities or unauthorized repositories are rejected before worktree creation. Older HMAC envelopes without algorithm/key ID are deliberately rejected; never reinterpret them locally.
|
|
66
|
+
Backend `RUNNER_JOB_SIGNING_PRIVATE_KEY` must already exist before deployment, be Ed25519, and match this release's `keys/runner-job-v1-public.pem` and `runner-job-v1` key ID. `contextRepos` is included in the signed envelope. Unknown keys, malformed/future/expired timestamps, invalid identities or unauthorized repositories are rejected before worktree creation. Older HMAC envelopes without algorithm/key ID are deliberately rejected; never reinterpret them locally. Project-scoped jobs use `protocolVersion: 2` and sign both that version and `projectId`. They retain the existing public key and `runner-job-v1` key ID. This Runner also validates queued legacy envelopes using their original payload, without rewriting signatures. Unknown protocol versions are rejected.
|
|
67
|
+
|
|
68
|
+
Upgrade with `pulse-runner service stop`, `npm install -g @pulsehub/runner@latest`, `pulse-runner service install`, then `pulse-runner diagnose`. Keep the existing data directory, pairing credential and signing key configuration. The heartbeat reports `jobProtocolVersion: 2`; the backend blocks older Runners before enqueue with these upgrade instructions. Publish and update the Runner before deploying the backend. No re-pairing or `repo add` is required.
|
|
67
69
|
|
|
68
70
|
Upgrade Runners before enabling the cancellation UI/backend. Older Runners do not observe cancellation requests while their CLI runs, even if the backend revokes MCP access. The backend transaction preserves a requested cancellation if a completion races it. This version retains the TES-304 readiness and Dev/QA identity requirements.
|
|
69
71
|
|
|
70
72
|
## Verification
|
|
71
73
|
|
|
72
74
|
`npm test` runs native fixture CLIs for both providers against a loopback control plane and real temporary Git repositories. It covers native session detection, signed envelope rejection before checkout, multi-repo isolation, remote/local/revocation/expiry cancellation, process descendants, sanitized reports, temporary-file cleanup, lock exclusion, interrupted-job recovery and QA permissions. The CI matrix runs the suite and CLI smoke on macOS, Linux and Windows. Fixture tests do not prove live model execution or actual service installation in each OS session.
|
|
75
|
+
|
|
76
|
+
## Local profiles
|
|
77
|
+
|
|
78
|
+
Profiles select local directories and an optional provider model; they do not grant repository access. The Runner checks that a directory is a repository root with a GitHub origin matching a signed job repository, then creates a disposable worktree. The source checkout and its uncommitted changes remain untouched. Cancellation and restart recovery prune these worktree registrations too.
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
pulse-runner profile set --repo owner/repo --directory /absolute/repository/root
|
|
82
|
+
pulse-runner profile set --model MODEL_SUPPORTED_BY_YOUR_PROVIDER
|
|
83
|
+
pulse-runner profile list
|
|
84
|
+
pulse-runner profile remove --repo owner/repo
|
|
85
|
+
```
|
|
86
|
+
|
|
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/config.js
CHANGED
|
@@ -9,11 +9,11 @@ export function dataDir() {
|
|
|
9
9
|
return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'pulse-runner');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export const paths = () => ({ root: dataDir(), config: join(dataDir(), 'config.json'), credential: join(dataDir(), 'device-credential'), repos: join(dataDir(), 'repos'), worktrees: join(dataDir(), 'worktrees'), logs: join(dataDir(), 'logs') });
|
|
12
|
+
export const paths = () => ({ root: dataDir(), config: join(dataDir(), 'config.json'), profiles: join(dataDir(), 'agent-profiles.json'), credential: join(dataDir(), 'device-credential'), repos: join(dataDir(), 'repos'), worktrees: join(dataDir(), 'worktrees'), logs: join(dataDir(), 'logs') });
|
|
13
13
|
|
|
14
14
|
export async function ensureDataDir() {
|
|
15
15
|
const p = paths();
|
|
16
|
-
await Promise.all(Object.values(p).filter((value) => value !== p.config && value !== p.credential).map((value) => mkdir(value, { recursive: true, mode: 0o700 })));
|
|
16
|
+
await Promise.all(Object.values(p).filter((value) => value !== p.config && value !== p.profiles && value !== p.credential).map((value) => mkdir(value, { recursive: true, mode: 0o700 })));
|
|
17
17
|
return p;
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -29,6 +29,17 @@ export async function writeConfig(config) {
|
|
|
29
29
|
await chmod(p.config, 0o600);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export async function readProfiles() {
|
|
33
|
+
try { return JSON.parse(await readFile(paths().profiles, 'utf8')); }
|
|
34
|
+
catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function writeProfiles(profiles) {
|
|
38
|
+
const p = await ensureDataDir();
|
|
39
|
+
await writeFile(p.profiles, `${JSON.stringify(profiles, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
await chmod(p.profiles, 0o600);
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
export async function readCredential() {
|
|
33
44
|
try { return (await readFile(paths().credential, 'utf8')).trim(); }
|
|
34
45
|
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
5
5
|
import { readConfig, readCredential, writeConfig, writeCredential } from './config.js';
|
|
6
6
|
import { sanitize } from './job.js';
|
|
7
7
|
import { service } from './service.js';
|
|
8
|
+
import { profileCommand } from './profile.js';
|
|
8
9
|
|
|
9
10
|
import { runOnce, recoverInterruptedWork, withRunnerLock, request } from './runtime.js';
|
|
10
11
|
|
|
@@ -20,7 +21,7 @@ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
|
20
21
|
});
|
|
21
22
|
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
22
23
|
const has = (flag) => args.includes(flag);
|
|
23
|
-
const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|identity|repo|service>\n\ninit --workspace WS --agent AGENT [--agent-kind codex|claude] [--agent-role dev|qa] --signing-public-key FILE --allow-autonomous [--endpoint URL]
|
|
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`);
|
|
24
25
|
|
|
25
26
|
async function start(config) {
|
|
26
27
|
return withRunnerLock(async () => {
|
|
@@ -41,11 +42,10 @@ async function init() {
|
|
|
41
42
|
if (!['codex', 'claude'].includes(value('--agent-kind') || 'codex') || !['dev', 'qa'].includes(value('--agent-role') || 'dev')) throw new Error('init requires a valid --agent-kind codex|claude and --agent-role dev|qa.');
|
|
42
43
|
const { publicKey } = generateKeyPairSync('ed25519'); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
43
44
|
const signingPublicKey = await readFile(publicKeyPath, 'utf8');
|
|
44
|
-
await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', agentRole: value('--agent-role') || 'dev', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos:
|
|
45
|
+
await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', agentRole: value('--agent-role') || 'dev', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos: [], autonomousConfirmed: true });
|
|
45
46
|
console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
|
|
46
47
|
}
|
|
47
|
-
async function
|
|
48
|
-
async function repo(config) { const op = args[1]; const name = args[2]; if (op === 'list') return console.log(config.repos.join('\n')); if (!['add', 'remove'].includes(op) || !name || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name)) throw new Error('Repository must be owner/repo.'); const repos = new Set(config.repos); op === 'add' ? repos.add(name) : repos.delete(name); const updated = { ...config, repos: [...repos].sort() }; await syncRepos(updated); await writeConfig(updated); }
|
|
48
|
+
async function repo() { throw new Error('Repository access comes from the issue project. pulse-runner repo is deprecated.'); }
|
|
49
49
|
async function diagnose(config) {
|
|
50
50
|
const readiness = await inspectReadiness(config);
|
|
51
51
|
console.log(JSON.stringify(readiness, null, 2));
|
|
@@ -70,6 +70,6 @@ async function identity(config) {
|
|
|
70
70
|
try {
|
|
71
71
|
if (!args.length || has('--help')) usage();
|
|
72
72
|
else if (args[0] === 'init') await init();
|
|
73
|
-
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);
|
|
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(config); 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(); }
|
|
73
|
+
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
75
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -3,7 +3,8 @@ import { verify } from 'node:crypto';
|
|
|
3
3
|
const b64 = (value) => Buffer.from(value, 'base64url');
|
|
4
4
|
export function jobPayload(job) {
|
|
5
5
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])].sort().join(',');
|
|
6
|
-
|
|
6
|
+
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('.');
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
/** Reject an envelope before it can cause filesystem or process activity. */
|
|
@@ -11,6 +12,8 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
11
12
|
const agent = typeof agentOrNow === 'object' && agentOrNow !== null ? agentOrNow : null;
|
|
12
13
|
const now = typeof agentOrNow === 'number' ? agentOrNow : suppliedNow;
|
|
13
14
|
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.');
|
|
14
17
|
if (!config.signingPublicKey || !verify(null, Buffer.from(jobPayload(job)), config.signingPublicKey, b64(job.signature))) throw new Error('Job signature is invalid.');
|
|
15
18
|
const issuedAt = Date.parse(job.issuedAt);
|
|
16
19
|
const expiresAt = Date.parse(job.expiresAt);
|
|
@@ -20,7 +23,7 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
20
23
|
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
21
24
|
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.');
|
|
22
25
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
|
|
23
|
-
if (!contextRepos.includes(job.repoFullName) || contextRepos.some((repo) => !config.repos.includes(repo))) throw new Error('Job repository context is not allowed by this Runner.');
|
|
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.');
|
|
24
27
|
return true;
|
|
25
28
|
}
|
|
26
29
|
|
package/src/process.js
CHANGED
|
@@ -65,6 +65,12 @@ export function agentPermissionArgs(kind, mode) {
|
|
|
65
65
|
return [];
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export async function supportsModelOption(kind) {
|
|
69
|
+
if (!['codex', 'claude'].includes(kind)) return false;
|
|
70
|
+
const result = await command(resolveExecutable(kind), kind === 'codex' ? ['exec', '--help'] : ['--help'], { timeout: 10_000 }).catch(() => ({ code: 1, output: '' }));
|
|
71
|
+
return result.code === 0 && /(?:^|\s)--model(?:\s|[,=])/m.test(result.output);
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
export function mcpEndpoint(endpoint) {
|
|
69
75
|
return endpoint.replace(/\/(?:pulseRunner\w*|pulseMcp)\/?$/, '').replace(/\/$/, '') + '/pulseMcp';
|
|
70
76
|
}
|
|
@@ -89,19 +95,23 @@ export function providerInvocation(kind, worktree, additionalDirs, config, mcp,
|
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
export async function runAgent(kind, job, worktree, additionalWorktrees, config, credential, tempDir, options = {}) {
|
|
92
|
-
const
|
|
98
|
+
const reviewWorktrees = job.mode === 'review' ? [{ repo: job.repoFullName, worktree }, ...additionalWorktrees] : additionalWorktrees;
|
|
99
|
+
const context = reviewWorktrees.length
|
|
93
100
|
? job.mode === 'review'
|
|
94
|
-
? ` Additional review worktrees for inspection: ${
|
|
95
|
-
: ` 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.`
|
|
96
103
|
: '';
|
|
97
104
|
const prompt = job.mode === 'review'
|
|
98
|
-
? `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}`
|
|
99
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}`;
|
|
100
107
|
const mcp = await mcpConfig(tempDir, credential, config.endpoint);
|
|
101
|
-
const additionalDirs =
|
|
108
|
+
const additionalDirs = reviewWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
|
|
102
109
|
try {
|
|
103
|
-
|
|
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);
|
|
113
|
+
if (options.profile?.model) invocation.args.splice(kind === 'codex' ? 1 : 0, 0, '--model', options.profile.model);
|
|
104
114
|
const env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
|
|
105
|
-
return await command(invocation.executable, invocation.args, { cwd:
|
|
115
|
+
return await command(invocation.executable, invocation.args, { cwd: agentDirectory, env, signal: options.signal });
|
|
106
116
|
} finally { await rm(mcp, { force: true }); }
|
|
107
117
|
}
|
package/src/profile.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { readProfiles, writeProfiles } from './config.js';
|
|
4
|
+
import { command, supportsModelOption } from './process.js';
|
|
5
|
+
export const profileKey = (config) => `${config.workspaceId}:${config.agentId}`;
|
|
6
|
+
const validModel = (model) => typeof model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(model);
|
|
7
|
+
function remoteRepo(remote) {
|
|
8
|
+
const match = String(remote || '').trim().match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/i);
|
|
9
|
+
return match?.[1]?.toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
export async function validateLocalRepo(config, repo, directory, authorizedRepos) {
|
|
12
|
+
if (!repo || (authorizedRepos && !authorizedRepos.includes(repo))) throw new Error('Repository is not authorized by the signed project context.');
|
|
13
|
+
const path = await realpath(resolve(directory));
|
|
14
|
+
const rootResult = await command('git', ['-C', path, 'rev-parse', '--show-toplevel']);
|
|
15
|
+
if (rootResult.code) throw new Error('Selected directory is not an accessible Git repository.');
|
|
16
|
+
const root = await realpath(rootResult.output.trim());
|
|
17
|
+
if (path !== root) throw new Error('Select the repository root directory.');
|
|
18
|
+
const remoteResult = await command('git', ['-C', root, 'remote', 'get-url', 'origin']);
|
|
19
|
+
if (remoteResult.code || remoteRepo(remoteResult.output) !== repo.toLowerCase()) throw new Error('Selected directory origin does not match the allowed repository.');
|
|
20
|
+
return root;
|
|
21
|
+
}
|
|
22
|
+
export async function agentProfile(config) {
|
|
23
|
+
const profiles = await readProfiles();
|
|
24
|
+
return profiles[profileKey(config)] || {};
|
|
25
|
+
}
|
|
26
|
+
export async function resolveProfile(config, job) {
|
|
27
|
+
const profile = await agentProfile({ ...config, agentId: job.agentId });
|
|
28
|
+
if (profile.model && !validModel(profile.model)) throw new Error('Configured model name is invalid.');
|
|
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 = job.mode === 'review' ? undefined : profile.directories?.[job.repoFullName];
|
|
31
|
+
if (!directory) return { ...profile, directory: undefined };
|
|
32
|
+
return { ...profile, directory: await validateLocalRepo(config, job.repoFullName, directory, job.contextRepos || [job.repoFullName]) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function profileCommand(config, args) {
|
|
36
|
+
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
37
|
+
const op = args[1]; const profiles = await readProfiles(); const key = profileKey(config);
|
|
38
|
+
const current = profiles[key] || { directories: {} };
|
|
39
|
+
if (op === 'list') return console.log(JSON.stringify(current, null, 2));
|
|
40
|
+
if (op === 'set') {
|
|
41
|
+
const updated = { ...current, directories: { ...(current.directories || {}) } };
|
|
42
|
+
const model = value('--model'); const repo = value('--repo'); const directory = value('--directory');
|
|
43
|
+
if (!model && !directory) throw new Error('profile set requires --model and/or --directory.');
|
|
44
|
+
if (repo && !directory) throw new Error('--repo requires --directory when setting a profile.');
|
|
45
|
+
if (model) {
|
|
46
|
+
if (!validModel(model)) throw new Error('Model name is invalid; use letters, numbers, dot, underscore, colon, slash or hyphen.');
|
|
47
|
+
if (!(await supportsModelOption(config.agentKind))) throw new Error(`The installed ${config.agentKind} CLI does not support the --model option.`);
|
|
48
|
+
updated.model = model;
|
|
49
|
+
}
|
|
50
|
+
if (directory) {
|
|
51
|
+
if (!repo) throw new Error('--directory requires --repo owner/repo.');
|
|
52
|
+
updated.directories[repo] = await validateLocalRepo(config, repo, directory);
|
|
53
|
+
}
|
|
54
|
+
profiles[key] = updated; await writeProfiles(profiles); return console.log('Local agent profile saved.');
|
|
55
|
+
}
|
|
56
|
+
if (op === 'remove') {
|
|
57
|
+
const repo = value('--repo');
|
|
58
|
+
if (repo) { delete current.directories?.[repo]; if (current.model || Object.keys(current.directories || {}).length) profiles[key] = current; else delete profiles[key]; }
|
|
59
|
+
else delete profiles[key];
|
|
60
|
+
await writeProfiles(profiles); return console.log('Local agent profile updated.');
|
|
61
|
+
}
|
|
62
|
+
throw new Error('Supported profile commands: list, set, remove.');
|
|
63
|
+
}
|
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
|
@@ -18,12 +18,12 @@ export async function inspectReadiness(config, run = command) {
|
|
|
18
18
|
providers[kind] = { cli, session: !!session };
|
|
19
19
|
}
|
|
20
20
|
const repositories = [];
|
|
21
|
-
for (const repo of config.repos) {
|
|
21
|
+
for (const repo of config.repos || []) {
|
|
22
22
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error('Invalid repository.');
|
|
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 };
|
|
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,9 +1,11 @@
|
|
|
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
|
-
import { ensureDataDir, paths, readCredential, removeTemporary } from './config.js';
|
|
4
|
+
import { ensureDataDir, paths, readCredential, readProfiles, removeTemporary } from './config.js';
|
|
4
5
|
import { validateJob, sanitize } from './job.js';
|
|
5
6
|
import { command, resolveExecutable, runAgent } from './process.js';
|
|
6
7
|
import { addIsolatedWorktree, removeIsolatedWorktree } from './worktree.js';
|
|
8
|
+
import { resolveProfile, validateLocalRepo } from './profile.js';
|
|
7
9
|
import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
|
|
8
10
|
|
|
9
11
|
export async function logEvent(event, details = {}) {
|
|
@@ -48,7 +50,8 @@ function completionSummary(result, publication) {
|
|
|
48
50
|
const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
|
|
49
51
|
return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
|
|
50
52
|
}
|
|
51
|
-
async function checkoutRepo(repo, worktree, signal) {
|
|
53
|
+
async function checkoutRepo(repo, worktree, signal, sourceDirectory) {
|
|
54
|
+
if (sourceDirectory) return addIsolatedWorktree(repo, sourceDirectory, worktree, { signal, sourceDirectory });
|
|
52
55
|
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
53
56
|
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
54
57
|
if (existing.code !== 0) {
|
|
@@ -59,11 +62,12 @@ async function checkoutRepo(repo, worktree, signal) {
|
|
|
59
62
|
return addIsolatedWorktree(repo, mirror, worktree, { signal });
|
|
60
63
|
}
|
|
61
64
|
async function checkout(job, options = {}) {
|
|
65
|
+
if (job.mode === 'review') return checkoutQa(job, options);
|
|
62
66
|
const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
|
|
63
67
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
68
|
try {
|
|
65
69
|
for (const repo of repos) {
|
|
66
|
-
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal);
|
|
70
|
+
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal, repo === job.repoFullName ? options.profile?.directory : undefined);
|
|
67
71
|
await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
|
|
68
72
|
checkouts.push(checkout);
|
|
69
73
|
}
|
|
@@ -72,13 +76,35 @@ async function checkout(job, options = {}) {
|
|
|
72
76
|
return { primary, checkouts };
|
|
73
77
|
} catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
|
|
74
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
|
+
}
|
|
75
98
|
async function cleanup(checkouts) {
|
|
76
|
-
await Promise.all(checkouts.map(async ({ worktree, mirror }) => {
|
|
77
|
-
|
|
99
|
+
await Promise.all(checkouts.map(async ({ worktree, mirror, sourceDirectory, snapshot }) => {
|
|
100
|
+
if (snapshot) {
|
|
101
|
+
await makeWritable(worktree); await removeTemporary(worktree); return;
|
|
102
|
+
}
|
|
103
|
+
await removeIsolatedWorktree({ mirror, worktree, sourceDirectory });
|
|
78
104
|
await removeTemporary(worktree);
|
|
79
105
|
}));
|
|
80
106
|
}
|
|
81
|
-
async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
107
|
+
async function publishCheckout(checkout, jobId, issueId, checkActive, signal) {
|
|
82
108
|
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current'], { signal });
|
|
83
109
|
const branch = branchResult.output.trim();
|
|
84
110
|
if (!branch || !branch.startsWith('pul/')) {
|
|
@@ -96,12 +122,15 @@ async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
|
96
122
|
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
97
123
|
return '';
|
|
98
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';
|
|
99
128
|
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
100
129
|
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
101
130
|
phase = 'push';
|
|
102
131
|
await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
|
|
103
132
|
await checkActive();
|
|
104
|
-
const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { signal, timeout: 120_000 });
|
|
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 });
|
|
105
134
|
if (pushed.code !== 0) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
|
|
106
135
|
await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
|
|
107
136
|
phase = 'find-pull-request';
|
|
@@ -113,16 +142,17 @@ async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
|
113
142
|
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
114
143
|
phase = 'create-pull-request';
|
|
115
144
|
await checkActive();
|
|
116
|
-
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 });
|
|
117
147
|
if (created.code !== 0) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
118
148
|
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
119
149
|
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
120
150
|
}
|
|
121
|
-
async function publish(checkouts, jobId, checkActive, signal) {
|
|
151
|
+
async function publish(checkouts, jobId, issueId, checkActive, signal) {
|
|
122
152
|
const publications = { succeeded: [], failed: [] };
|
|
123
153
|
for (const checkout of checkouts) {
|
|
124
154
|
try {
|
|
125
|
-
const published = await publishCheckout(checkout, jobId, checkActive, signal);
|
|
155
|
+
const published = await publishCheckout(checkout, jobId, issueId, checkActive, signal);
|
|
126
156
|
if (published) publications.succeeded.push(published);
|
|
127
157
|
} catch (error) {
|
|
128
158
|
if (error.name === 'AbortError') throw error;
|
|
@@ -175,7 +205,9 @@ export async function runOnce(config, options = {}) {
|
|
|
175
205
|
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
176
206
|
validateJob(job, config, agent);
|
|
177
207
|
jobPhase = 'local-preflight';
|
|
178
|
-
|
|
208
|
+
const profile = await resolveProfile({ ...config, agentKind: agent.kind }, job);
|
|
209
|
+
const jobConfig = job.protocolVersion === 2 ? { ...config, repos: job.contextRepos || [job.repoFullName] } : config;
|
|
210
|
+
assertLocalReadiness(job, jobConfig, agent, await readinessForJob(jobConfig, true));
|
|
179
211
|
await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
|
|
180
212
|
await checkActive();
|
|
181
213
|
expiryTimer = setTimeout(cancel, Math.max(0, Date.parse(job.expiresAt) - Date.now()));
|
|
@@ -196,19 +228,19 @@ export async function runOnce(config, options = {}) {
|
|
|
196
228
|
jobPhase = 'prepare-worktrees';
|
|
197
229
|
await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
|
|
198
230
|
await chmod(journal, 0o600);
|
|
199
|
-
const { primary, checkouts } = await prepare(job, { signal: controller.signal });
|
|
231
|
+
const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile, config, credential: delivery.mcpCredential });
|
|
200
232
|
const temp = join(paths().root, 'tmp', job.id);
|
|
201
233
|
let completion;
|
|
202
234
|
try {
|
|
203
235
|
controller.signal.throwIfAborted();
|
|
204
236
|
jobPhase = 'run-agent';
|
|
205
237
|
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
206
|
-
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal });
|
|
238
|
+
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile });
|
|
207
239
|
controller.signal.throwIfAborted();
|
|
208
240
|
await checkActive();
|
|
209
241
|
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
|
210
242
|
jobPhase = 'publication';
|
|
211
|
-
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: [] };
|
|
212
244
|
await checkActive();
|
|
213
245
|
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
214
246
|
const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
|
|
@@ -249,10 +281,17 @@ export async function recoverInterruptedWork(config) {
|
|
|
249
281
|
for (const area of [p.worktrees, join(p.root, 'tmp')]) {
|
|
250
282
|
await mkdir(area, { recursive: true, mode: 0o700 });
|
|
251
283
|
const jobs = await readdir(area, { withFileTypes: true });
|
|
252
|
-
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 }); }));
|
|
253
285
|
}
|
|
254
286
|
const repositories = await readdir(p.repos, { withFileTypes: true });
|
|
255
287
|
await Promise.all(repositories.filter((entry) => entry.isDirectory()).map((entry) => command('git', ['--git-dir', join(p.repos, entry.name), 'worktree', 'prune']).catch(() => {})));
|
|
288
|
+
const profiles = await readProfiles();
|
|
289
|
+
for (const profile of Object.values(profiles)) {
|
|
290
|
+
for (const [repo, directory] of Object.entries(profile.directories || {})) {
|
|
291
|
+
const source = await validateLocalRepo(config, repo, directory).catch(() => null);
|
|
292
|
+
if (source) await command('git', ['-C', source, 'worktree', 'prune']).catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
256
295
|
if (config) {
|
|
257
296
|
const journal = join(p.root, 'active-job.json');
|
|
258
297
|
const interrupted = await readFile(journal, 'utf8').then(JSON.parse).catch((error) => {
|
|
@@ -288,5 +327,3 @@ export async function withRunnerLock(run) {
|
|
|
288
327
|
try { await lock.writeFile(`${process.pid}\n`); return await run(); }
|
|
289
328
|
finally { await lock.close(); await rm(lockPath, { force: true }); }
|
|
290
329
|
}
|
|
291
|
-
|
|
292
|
-
|
package/src/service.js
CHANGED
package/src/worktree.js
CHANGED
|
@@ -3,16 +3,17 @@ import { sanitize } from './job.js';
|
|
|
3
3
|
|
|
4
4
|
export async function addIsolatedWorktree(repo, gitDir, worktree, options = {}) {
|
|
5
5
|
options.signal?.throwIfAborted();
|
|
6
|
-
const
|
|
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
8
|
if (added.code !== 0) throw new Error(sanitize(added.output));
|
|
8
9
|
const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD'], options);
|
|
9
10
|
if (base.code !== 0) {
|
|
10
|
-
await command('git', [
|
|
11
|
+
await command('git', [...prefix, 'worktree', 'remove', '--force', worktree]).catch(() => {});
|
|
11
12
|
throw new Error(sanitize(base.output));
|
|
12
13
|
}
|
|
13
|
-
return { repo, mirror: gitDir, worktree, baseCommit: base.output.trim() };
|
|
14
|
+
return { repo, mirror: options.sourceDirectory ? null : gitDir, sourceDirectory: options.sourceDirectory, worktree, baseCommit: base.output.trim() };
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export async function removeIsolatedWorktree(checkout) {
|
|
17
|
-
await command('git', ['--git-dir', checkout.mirror, 'worktree', 'remove', '--force', checkout.worktree]).catch(() => {});
|
|
18
|
+
await command('git', [...(checkout.sourceDirectory ? ['-C', checkout.sourceDirectory] : ['--git-dir', checkout.mirror]), 'worktree', 'remove', '--force', checkout.worktree]).catch(() => {});
|
|
18
19
|
}
|
package/test/integration.test.js
CHANGED
|
@@ -2,9 +2,11 @@ import test from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
4
4
|
import { createServer } from 'node:http';
|
|
5
|
-
import { mkdtemp, mkdir, readFile, writeFile, rm, access } from 'node:fs/promises';
|
|
5
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, access, realpath } from 'node:fs/promises';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { validateLocalRepo } from '../src/profile.js';
|
|
8
10
|
import { paths, ensureDataDir, writeCredential } from '../src/config.js';
|
|
9
11
|
import { jobPayload } from '../src/job.js';
|
|
10
12
|
import { command } from '../src/process.js';
|
|
@@ -27,13 +29,14 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
27
29
|
const args = process.argv.slice(2);
|
|
28
30
|
const settings = JSON.parse(readFileSync(${JSON.stringify(join(root, 'settings.json'))}, 'utf8'));
|
|
29
31
|
if (args.includes('--version')) console.log('fixture-cli');
|
|
32
|
+
else if (args.includes('--help')) console.log('--model MODEL');
|
|
30
33
|
else if (args[0] === 'login') process.exit(settings.session ? 0 : 1);
|
|
31
34
|
else if (args[0] === 'auth') console.log(JSON.stringify({loggedIn: settings.session}));
|
|
32
35
|
else {
|
|
33
36
|
const file = args[args.indexOf('--mcp-config') + 1];
|
|
34
37
|
const received = args.includes('exec') ? !!process.env.PULSE_RUNNER_MCP_CREDENTIAL?.startsWith('Bearer ') : !!JSON.parse(readFileSync(file)).mcpServers.pulse.headers.Authorization;
|
|
35
38
|
const child = settings.slow ? spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'ignore'}) : null;
|
|
36
|
-
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}));
|
|
37
40
|
writeFileSync('job-output.txt', 'isolated output');
|
|
38
41
|
if (settings.publish) {
|
|
39
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']]) {
|
|
@@ -72,11 +75,19 @@ else console.log('https://github.com/owner/repo/pull/123');
|
|
|
72
75
|
signJob();
|
|
73
76
|
const calls = [];
|
|
74
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');
|
|
75
81
|
const server = createServer(async (req, res) => {
|
|
76
82
|
let raw = ''; for await (const chunk of req) raw += chunk;
|
|
77
83
|
const body = JSON.parse(raw); calls.push({path:req.url, body});
|
|
78
84
|
res.setHeader('Content-Type', 'application/json');
|
|
79
|
-
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'}));
|
|
80
91
|
else if (body.jobId && req.url === '/pulseRunnerHeartbeat') {
|
|
81
92
|
const running = await exists(trace);
|
|
82
93
|
if (control.disconnectWhenRunning && running) {res.statusCode=401;res.end('{}');}
|
|
@@ -190,6 +201,10 @@ test('integration: recovery prunes interrupted worktrees and temporary MCP files
|
|
|
190
201
|
const interrupted=join(paths().worktrees,'rjob-interrupted');
|
|
191
202
|
await git(['--git-dir',mirror,'worktree','add','--detach',interrupted,'HEAD']);
|
|
192
203
|
await writeFile(join(interrupted,'source.txt'),'interrupted edits');
|
|
204
|
+
await git(['-C',f.source,'remote','add','origin','https://github.com/owner/repo.git']);
|
|
205
|
+
const localInterrupted=join(paths().worktrees,'rjob-local-interrupted');
|
|
206
|
+
await git(['-C',f.source,'worktree','add','--detach',localInterrupted,'HEAD']);
|
|
207
|
+
await writeFile(paths().profiles,JSON.stringify({[`${f.config.workspaceId}:${f.job.agentId}`]:{directories:{'owner/repo':f.source}}}));
|
|
193
208
|
const temp=join(paths().root,'tmp','rjob-interrupted');
|
|
194
209
|
await mkdir(temp,{recursive:true}); await writeFile(join(temp,'pulse-mcp.json'),'synthetic temporary data');
|
|
195
210
|
await writeFile(join(paths().root, 'active-job.json'), JSON.stringify({jobId:f.job.id}));
|
|
@@ -198,6 +213,7 @@ test('integration: recovery prunes interrupted worktrees and temporary MCP files
|
|
|
198
213
|
assert.equal(await exists(join(paths().root,'active-job.json')),false);
|
|
199
214
|
f.calls.length=0;
|
|
200
215
|
assert.equal(await exists(interrupted),false);
|
|
216
|
+
assert.ok(!(await git(['-C',f.source,'worktree','list','--porcelain'])).includes('rjob-local-interrupted'));
|
|
201
217
|
assert.equal(await exists(temp),false);
|
|
202
218
|
assert.ok(!(await git(['--git-dir',mirror,'worktree','list','--porcelain'])).includes('rjob-interrupted'));
|
|
203
219
|
assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
|
|
@@ -227,13 +243,16 @@ test('integration: a job expires during execution and is stopped before publicat
|
|
|
227
243
|
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
228
244
|
}));
|
|
229
245
|
|
|
230
|
-
test(
|
|
231
|
-
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();
|
|
232
248
|
f.config.agentRole='qa';
|
|
233
249
|
let published=false;
|
|
234
250
|
await f.run({publish:async()=>{published=true;}});
|
|
235
251
|
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
236
|
-
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);
|
|
237
256
|
assert.equal(published,false);
|
|
238
257
|
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
239
258
|
}));
|
|
@@ -267,3 +286,59 @@ test('integration: committed job branches push to a local remote and report the
|
|
|
267
286
|
assert.equal(calls[0][1],'view'); assert.equal(calls[1][1],'create');
|
|
268
287
|
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
269
288
|
}));
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
test('integration: v2 job uses its signed repos without a local allowlist',()=>fixture('codex',async f=>{
|
|
292
|
+
f.job.protocolVersion=2;f.job.projectId='project-fixture';f.signJob();
|
|
293
|
+
f.config.repos=[];
|
|
294
|
+
const readinessFor=(config)=>inspectReadiness(config,(exe,args,options)=>command(exe,exe==='git'?['ls-remote','--exit-code',f.source,'HEAD']:args,options));
|
|
295
|
+
await f.run({readinessFor});
|
|
296
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
297
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
test('integration: local profile worktree/model preserve source checkout and cancellation cleanup',()=>fixture('codex',async f=>{
|
|
301
|
+
await git(['-C',f.source,'remote','add','origin','https://github.com/owner/repo.git']);
|
|
302
|
+
await writeFile(paths().profiles,JSON.stringify({[`${f.config.workspaceId}:${f.job.agentId}`]:{model:'fixture-model',directories:{'owner/repo':f.source}}}));
|
|
303
|
+
f.job.protocolVersion=2;f.job.projectId='project-fixture';f.signJob();
|
|
304
|
+
f.settings.slow=true;await f.updateSettings();f.control.cancelWhenRunning=true;
|
|
305
|
+
await f.run();
|
|
306
|
+
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
307
|
+
assert.equal(trace.args[trace.args.indexOf('--model')+1],'fixture-model');
|
|
308
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
309
|
+
assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
|
|
310
|
+
assert.equal((await git(['-C',f.source,'worktree','list','--porcelain'])).split('worktree ').length-1,1);
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
test('integration: profile origins reject lookalike hosts, foreign repos and unauthorized context',()=>fixture('codex', async f => {
|
|
314
|
+
await git(['-C', f.source, 'remote', 'add', 'origin', 'https://evilgithub.com/owner/repo.git']);
|
|
315
|
+
await assert.rejects(validateLocalRepo(f.config,'owner/repo',f.source,['owner/repo']), /origin/);
|
|
316
|
+
await git(['-C', f.source, 'remote', 'set-url', 'origin', 'https://github.com/owner/foreign.git']);
|
|
317
|
+
await assert.rejects(validateLocalRepo(f.config,'owner/repo',f.source,['owner/repo']), /origin/);
|
|
318
|
+
await git(['-C', f.source, 'remote', 'set-url', 'origin', 'git@github.com:owner/repo.git']);
|
|
319
|
+
assert.equal(await validateLocalRepo(f.config,'owner/repo',f.source,['owner/repo']),await realpath(f.source));
|
|
320
|
+
await assert.rejects(validateLocalRepo(f.config,'owner/repo',f.source,['owner/other']), /not authorized/);
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
test('integration: CLI init, pairing and diagnose need no per-agent repo configuration',()=>fixture('codex',async f => {
|
|
324
|
+
const cli=fileURLToPath(new URL('../src/index.js',import.meta.url));
|
|
325
|
+
const home=join(f.root,'fresh-home'), key=join(f.root,'signing-public.pem');
|
|
326
|
+
await writeFile(key,f.config.signingPublicKey);
|
|
327
|
+
const run=async args=>{const result=await command(process.execPath,[cli,...args,'--home',home]);assert.equal(result.code,0,result.output);return result.output;};
|
|
328
|
+
await run(['init','--workspace','ws-test','--agent','agent-test','--signing-public-key',key,'--allow-autonomous','--endpoint',f.config.endpoint]);
|
|
329
|
+
const before=JSON.parse(await readFile(join(home,'config.json'),'utf8'));
|
|
330
|
+
assert.equal(before.signingKeyId,'runner-job-v1');assert.deepEqual(before.repos,[]);
|
|
331
|
+
await run(['pair','--credential','runner-abcdefghijkl.'+'a'.repeat(32)]);
|
|
332
|
+
const after=JSON.parse(await readFile(join(home,'config.json'),'utf8'));
|
|
333
|
+
assert.equal(after.signingPublicKey,before.signingPublicKey);assert.deepEqual(after.repos,[]);
|
|
334
|
+
const readiness=JSON.parse(await run(['diagnose']));
|
|
335
|
+
assert.equal(readiness.jobProtocolVersion,2);assert.deepEqual(readiness.repositories,[]);
|
|
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
|
+
}));
|
package/test/job.test.js
CHANGED
|
@@ -5,15 +5,30 @@ import { jobPayload, sanitize, validateJob } from '../src/job.js';
|
|
|
5
5
|
|
|
6
6
|
test('accepts a signed, bound job and rejects changed identity', () => {
|
|
7
7
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
8
|
-
const job = { id: 'rjob-123', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repoFullName: 'owner/repo', contextRepos: ['owner/repo', 'owner/app'], mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'runner-job-v1' };
|
|
8
|
+
const job = { id: 'rjob-123', workspaceId: 'ws-1', projectId: 'project-1', protocolVersion: 2, issueId: 'issue-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repoFullName: 'owner/repo', contextRepos: ['owner/repo', 'owner/app'], mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'runner-job-v1' };
|
|
9
9
|
job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url');
|
|
10
|
-
const config = { workspaceId: 'ws-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repos: [
|
|
10
|
+
const config = { workspaceId: 'ws-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repos: [], signingKeyId: 'runner-job-v1', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
|
|
11
11
|
assert.equal(validateJob(job, config), true);
|
|
12
12
|
assert.throws(() => validateJob({ ...job, repoFullName: 'owner/other' }, config));
|
|
13
13
|
assert.throws(() => validateJob({ ...job, contextRepos: ['owner/repo'] }, config));
|
|
14
14
|
});
|
|
15
15
|
test('sanitizes provider tokens', () => assert.equal(sanitize('Bearer secret.token sk-ant-secret'), 'Bearer [redacted] [redacted]'));
|
|
16
16
|
|
|
17
|
+
test('upgraded Runner keeps the installed v1 key and accepts queued v1 jobs and signed project jobs', () => {
|
|
18
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
19
|
+
const config = {workspaceId:'ws',runnerId:'runner',agentId:'dev',agentKind:'codex',repos:['owner/repo'],signingKeyId:'runner-job-v1',signingPublicKey:publicKey.export({type:'spki',format:'pem'}).toString()};
|
|
20
|
+
const legacy={id:'rjob-legacy',workspaceId:'ws',issueId:'issue',agentId:'dev',runnerId:'runner',repoFullName:'owner/repo',mode:'task',issuedAt:new Date().toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),signatureAlgorithm:'ed25519',signingKeyId:'runner-job-v1'};
|
|
21
|
+
// This is the exact deployed v1 payload, with no project/protocol fields.
|
|
22
|
+
const payload=[legacy.id,legacy.workspaceId,legacy.issueId,legacy.agentId,legacy.runnerId,legacy.repoFullName,legacy.repoFullName,legacy.mode,legacy.issuedAt,legacy.expiresAt,legacy.signatureAlgorithm,legacy.signingKeyId].join('.');
|
|
23
|
+
const old={...legacy,signature:sign(null,Buffer.from(payload),privateKey).toString('base64url')};
|
|
24
|
+
assert.equal(validateJob(old,config),true);
|
|
25
|
+
const project={...legacy,protocolVersion:2,projectId:'project',contextRepos:['owner/repo','owner/private']};
|
|
26
|
+
project.signature=sign(null,Buffer.from(jobPayload(project)),privateKey).toString('base64url');
|
|
27
|
+
assert.equal(validateJob(project,{...config,repos:[]}),true);
|
|
28
|
+
for(const mutation of [{projectId:'other'},{protocolVersion:undefined},{contextRepos:['owner/repo']},{projectId:undefined},{protocolVersion:3}]) assert.throws(()=>validateJob({...project,...mutation},config));
|
|
29
|
+
assert.throws(()=>validateJob(old,{...config,repos:[]}),/repository/);
|
|
30
|
+
});
|
|
31
|
+
|
|
17
32
|
test('signed QA jobs require explicit agent identity and provider/role compatibility', () => {
|
|
18
33
|
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
19
34
|
const job = { id: 'rjob-qa', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'qa-1', runnerId: 'runner-1', repoFullName: 'owner/repo', mode: 'review', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'key' };
|
|
@@ -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
|
+
});
|