@firefunc-agent/runner 0.5.0 → 0.6.0
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 +2 -2
- package/dist/api.d.ts +5 -0
- package/dist/api.js +18 -0
- package/dist/cli.js +2 -1
- package/dist/engines/claude.js +6 -4
- package/dist/git-auth.d.ts +23 -0
- package/dist/git-auth.js +74 -0
- package/dist/job.d.ts +3 -0
- package/dist/job.js +91 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# firefunc-runner
|
|
2
2
|
|
|
3
|
-
Run **your own** Claude Code on
|
|
3
|
+
Run **your own** coding agent — Claude Code, OpenAI Codex, Gemini CLI or Cursor CLI — on work FireFunc routes to you, and open PRs automatically.
|
|
4
4
|
|
|
5
5
|
This is the **self-hosted runner** for FireFunc's _Claude Code Channel_ engine. It's a tiny always-on daemon on your machine (laptop, dev box, CI host) that:
|
|
6
6
|
|
|
7
7
|
1. **long-polls FireFunc** (outbound only — works behind NAT/VPN, no inbound ports),
|
|
8
8
|
2. **claims** a bug FireFunc dispatched to your project,
|
|
9
|
-
3. runs **your local
|
|
9
|
+
3. runs **your local coding agent** (`claude -p`, `codex exec`, `gemini`, or `cursor-agent` — whichever the work is routed to) in an isolated git worktree off a clean base branch,
|
|
10
10
|
4. opens a **draft PR** and reports the result back.
|
|
11
11
|
|
|
12
12
|
**Why this and not the cloud Routine?** No ~15/day cap (it uses _your_ Claude subscription's limits), your **code never leaves your machine**, and it has your full local context — running app, AWS creds, `gh`/`git` auth, MCP servers, `CLAUDE.md`.
|
package/dist/api.d.ts
CHANGED
|
@@ -59,6 +59,11 @@ export declare class RunnerApi {
|
|
|
59
59
|
config(): Promise<{
|
|
60
60
|
maxParallel: number;
|
|
61
61
|
} | null>;
|
|
62
|
+
gitCredential(runId: string): Promise<{
|
|
63
|
+
token: string;
|
|
64
|
+
expiresAt: string;
|
|
65
|
+
repo: string;
|
|
66
|
+
} | null>;
|
|
62
67
|
event(runId: string, event: RunnerEvent, detail?: Record<string, unknown>): Promise<void>;
|
|
63
68
|
result(runId: string, payload: ResultPayload): Promise<void>;
|
|
64
69
|
}
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,24 @@ export class RunnerApi {
|
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
async gitCredential(runId) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${this.apiUrl}/channel/runs/${runId}/git-credential`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { ...this.headers(), 'content-type': 'application/json' },
|
|
64
|
+
body: '{}',
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return null;
|
|
68
|
+
const body = (await res.json());
|
|
69
|
+
if (!body.ok || !body.token || !body.repo)
|
|
70
|
+
return null;
|
|
71
|
+
return { token: body.token, expiresAt: body.expiresAt ?? '', repo: body.repo };
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
59
77
|
async event(runId, event, detail) {
|
|
60
78
|
await fetch(`${this.apiUrl}/channel/runs/${runId}/events`, {
|
|
61
79
|
method: 'POST',
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { loadConfig, saveConfig, configPath } from './config.js';
|
|
3
3
|
import { startDaemon } from './daemon.js';
|
|
4
4
|
import { runBrief } from './brief.js';
|
|
5
|
-
const HELP = `firefunc-runner — run YOUR local
|
|
5
|
+
const HELP = `firefunc-runner — run YOUR local coding agent on work FireFunc routes to you.
|
|
6
|
+
(Claude Code, OpenAI Codex, Gemini CLI or Cursor CLI — whichever you have installed.)
|
|
6
7
|
|
|
7
8
|
Usage:
|
|
8
9
|
firefunc-runner connect <token> [options] Save your runner token + settings
|
package/dist/engines/claude.js
CHANGED
|
@@ -98,10 +98,12 @@ export const claudeAdapter = {
|
|
|
98
98
|
if (t.includes('invalid x-api-key') ||
|
|
99
99
|
t.includes('invalid api key') ||
|
|
100
100
|
t.includes('authentication_error') ||
|
|
101
|
-
t
|
|
102
|
-
t.includes('
|
|
103
|
-
t.includes('please run /login')
|
|
104
|
-
|
|
101
|
+
/oauth\b.*\btoken\b.*\bexpired/.test(t) ||
|
|
102
|
+
t.includes('failed to authenticate') ||
|
|
103
|
+
t.includes('please run /login') ||
|
|
104
|
+
t.includes('not logged in') ||
|
|
105
|
+
/\b401\b/.test(t)) {
|
|
106
|
+
return 'Claude Code could not authenticate — its login has expired or is invalid. On the runner machine run `claude` and sign in (or `claude setup-token` and pass `--claude-oauth-token`); clear a stale ANTHROPIC_API_KEY if you set one. Then retry this item.';
|
|
105
107
|
}
|
|
106
108
|
return null;
|
|
107
109
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ExecResult } from './exec.js';
|
|
2
|
+
export type GitCredential = {
|
|
3
|
+
token: string;
|
|
4
|
+
expiresAt: string;
|
|
5
|
+
repo: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function redact(text: string, token?: string): string;
|
|
8
|
+
export declare function gitAuthEnv(token: string, repo: string): NodeJS.ProcessEnv;
|
|
9
|
+
export declare function cloneRepo(repo: string, dest: string, cred: GitCredential): Promise<ExecResult>;
|
|
10
|
+
export declare function pushBranch(worktree: string, branch: string, repo: string, cred: GitCredential): Promise<ExecResult>;
|
|
11
|
+
export declare function openDraftPr(args: {
|
|
12
|
+
repo: string;
|
|
13
|
+
head: string;
|
|
14
|
+
base: string;
|
|
15
|
+
title: string;
|
|
16
|
+
body: string;
|
|
17
|
+
cred: GitCredential;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
url: string;
|
|
20
|
+
number: number;
|
|
21
|
+
} | {
|
|
22
|
+
error: string;
|
|
23
|
+
}>;
|
package/dist/git-auth.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { exec } from './exec.js';
|
|
2
|
+
export function redact(text, token) {
|
|
3
|
+
let out = text;
|
|
4
|
+
if (token)
|
|
5
|
+
out = out.split(token).join('***');
|
|
6
|
+
return out
|
|
7
|
+
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '***')
|
|
8
|
+
.replace(/\bghs_[A-Za-z0-9]{20,}\b/g, '***')
|
|
9
|
+
.replace(/(Authorization:\s*(?:Basic|Bearer)\s+)\S+/gi, '$1***')
|
|
10
|
+
.replace(/(https:\/\/)[^@\s/]+:[^@\s/]+@/g, '$1***@');
|
|
11
|
+
}
|
|
12
|
+
export function gitAuthEnv(token, repo) {
|
|
13
|
+
const basic = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
14
|
+
return {
|
|
15
|
+
GIT_CONFIG_COUNT: '3',
|
|
16
|
+
GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
|
|
17
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
|
18
|
+
GIT_CONFIG_KEY_1: 'credential.helper',
|
|
19
|
+
GIT_CONFIG_VALUE_1: '',
|
|
20
|
+
GIT_CONFIG_KEY_2: 'remote.origin.pushurl',
|
|
21
|
+
GIT_CONFIG_VALUE_2: `https://github.com/${repo}.git`,
|
|
22
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
23
|
+
GIT_ASKPASS: undefined,
|
|
24
|
+
SSH_ASKPASS: undefined,
|
|
25
|
+
GIT_TRACE: undefined,
|
|
26
|
+
GIT_TRACE_CURL: undefined,
|
|
27
|
+
GIT_TRACE_PACKET: undefined,
|
|
28
|
+
GIT_CURL_VERBOSE: undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function cloneRepo(repo, dest, cred) {
|
|
32
|
+
const res = await exec('git', ['clone', '--no-tags', `https://github.com/${repo}.git`, dest], {
|
|
33
|
+
timeoutMs: 600_000,
|
|
34
|
+
env: gitAuthEnv(cred.token, repo),
|
|
35
|
+
});
|
|
36
|
+
return { ...res, stderr: redact(res.stderr, cred.token), stdout: redact(res.stdout, cred.token) };
|
|
37
|
+
}
|
|
38
|
+
export async function pushBranch(worktree, branch, repo, cred) {
|
|
39
|
+
const res = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000, env: gitAuthEnv(cred.token, repo) });
|
|
40
|
+
return { ...res, stderr: redact(res.stderr, cred.token), stdout: redact(res.stdout, cred.token) };
|
|
41
|
+
}
|
|
42
|
+
export async function openDraftPr(args) {
|
|
43
|
+
const { repo, head, base, title, body, cred } = args;
|
|
44
|
+
const headers = {
|
|
45
|
+
authorization: `Bearer ${cred.token}`,
|
|
46
|
+
accept: 'application/vnd.github+json',
|
|
47
|
+
'x-github-api-version': '2022-11-28',
|
|
48
|
+
'content-type': 'application/json',
|
|
49
|
+
'user-agent': 'firefunc-runner',
|
|
50
|
+
};
|
|
51
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/pulls`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers,
|
|
54
|
+
body: JSON.stringify({ title, head, base, body, draft: true }),
|
|
55
|
+
});
|
|
56
|
+
if (res.ok) {
|
|
57
|
+
const j = (await res.json());
|
|
58
|
+
if (typeof j.html_url === 'string' && Number.isFinite(j.number))
|
|
59
|
+
return { url: j.html_url, number: Number(j.number) };
|
|
60
|
+
return { error: 'pr created but the response carried no url/number' };
|
|
61
|
+
}
|
|
62
|
+
if (res.status === 422) {
|
|
63
|
+
const owner = repo.split('/')[0];
|
|
64
|
+
const list = await fetch(`https://api.github.com/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${head}`)}&state=open`, { headers });
|
|
65
|
+
if (list.ok) {
|
|
66
|
+
const rows = (await list.json());
|
|
67
|
+
const hit = rows.find((r) => typeof r.html_url === 'string' && Number.isFinite(r.number));
|
|
68
|
+
if (hit)
|
|
69
|
+
return { url: hit.html_url, number: Number(hit.number) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { error: `github refused to open the pull request (${res.status})` };
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=git-auth.js.map
|
package/dist/job.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { RunnerApi, ClaimedJob } from './api.js';
|
|
|
2
2
|
import { type RunnerConfig } from './config.js';
|
|
3
3
|
import type { SessionViewer } from './viewer.js';
|
|
4
4
|
export declare function resolveRepoPath(repo: string | null | undefined, cfg: RunnerConfig): string | null;
|
|
5
|
+
export declare function cloneDestination(repo: string, cfg: RunnerConfig): string | null;
|
|
6
|
+
export declare function rememberRepoPath(repo: string, path: string, cfg: RunnerConfig): void;
|
|
7
|
+
export declare function hasGh(cwd: string): Promise<boolean>;
|
|
5
8
|
export declare function runJob(job: ClaimedJob, cfg: RunnerConfig, api: RunnerApi, viewer?: SessionViewer, opts?: {
|
|
6
9
|
devServerPort?: number;
|
|
7
10
|
}): Promise<void>;
|
package/dist/job.js
CHANGED
|
@@ -2,7 +2,8 @@ import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync, copyFileSync
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join, basename, dirname } from 'node:path';
|
|
4
4
|
import { exec, execOut, spawnBackground, killProcessTree } from './exec.js';
|
|
5
|
-
import { configPath } from './config.js';
|
|
5
|
+
import { configPath, loadConfig, saveConfig } from './config.js';
|
|
6
|
+
import { cloneRepo, openDraftPr, pushBranch, redact } from './git-auth.js';
|
|
6
7
|
import { createKeyedMutex } from './locks.js';
|
|
7
8
|
import { getAdapter, stripForeignEngineCreds } from './engines/index.js';
|
|
8
9
|
function log(msg) {
|
|
@@ -52,6 +53,40 @@ export function resolveRepoPath(repo, cfg) {
|
|
|
52
53
|
}
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
56
|
+
export function cloneDestination(repo, cfg) {
|
|
57
|
+
const name = basename(repo);
|
|
58
|
+
if (!name)
|
|
59
|
+
return null;
|
|
60
|
+
const root = cfg.reposDir ?? join(dirname(configPath()), 'repos');
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(root, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return join(root, name);
|
|
68
|
+
}
|
|
69
|
+
export function rememberRepoPath(repo, path, cfg) {
|
|
70
|
+
try {
|
|
71
|
+
const onDisk = loadConfig();
|
|
72
|
+
if (!onDisk)
|
|
73
|
+
return;
|
|
74
|
+
onDisk.repos = { ...(onDisk.repos ?? {}), [repo]: path };
|
|
75
|
+
saveConfig(onDisk);
|
|
76
|
+
cfg.repos = { ...(cfg.repos ?? {}), [repo]: path };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function hasGh(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await exec('gh', ['auth', 'status'], { cwd, timeoutMs: 15_000 });
|
|
84
|
+
return res.code === 0;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
55
90
|
function installCmdFor(dir) {
|
|
56
91
|
if (existsSync(join(dir, 'pnpm-lock.yaml')))
|
|
57
92
|
return 'pnpm install --frozen-lockfile';
|
|
@@ -96,12 +131,33 @@ export async function runJob(job, cfg, api, viewer, opts) {
|
|
|
96
131
|
log(`job ${job.runId} FAILED — ${error.replace(/\s+/g, ' ').slice(0, 300)}`);
|
|
97
132
|
return report({ status: 'failed', error });
|
|
98
133
|
};
|
|
99
|
-
|
|
134
|
+
let repoPath = resolveRepoPath(job.repo, cfg);
|
|
135
|
+
if (!repoPath && job.repo) {
|
|
136
|
+
const cred = await api.gitCredential(job.runId);
|
|
137
|
+
const dest = cloneDestination(job.repo, cfg);
|
|
138
|
+
if (cred && dest) {
|
|
139
|
+
log(`no local checkout for ${job.repo} — cloning into ${dest}`);
|
|
140
|
+
await api.event(job.runId, 'cloning', { repo: job.repo });
|
|
141
|
+
const cloned = await cloneRepo(job.repo, dest, cred);
|
|
142
|
+
if (cloned.code === 0) {
|
|
143
|
+
repoPath = dest;
|
|
144
|
+
rememberRepoPath(job.repo, dest, cfg);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
return report({
|
|
148
|
+
status: 'failed',
|
|
149
|
+
error: `Could not clone ${job.repo}: ${redact(cloned.stderr, cred.token).slice(0, 300)}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
100
154
|
if (!repoPath) {
|
|
101
|
-
log(`no local checkout for ${job.repo}
|
|
155
|
+
log(`no local checkout for ${job.repo} and no credential to clone with`);
|
|
102
156
|
return report({
|
|
103
157
|
status: 'failed',
|
|
104
|
-
error: `Runner has no local checkout
|
|
158
|
+
error: `Runner has no local checkout for ${job.repo} and could not obtain a ` +
|
|
159
|
+
`credential to clone it. Either map it with \`firefunc-runner repo ` +
|
|
160
|
+
`${job.repo} <path>\`, set --repos-dir, or connect GitHub in FireFunc.`,
|
|
105
161
|
});
|
|
106
162
|
}
|
|
107
163
|
const adapter = getAdapter(job.engine ?? cfg.engine);
|
|
@@ -310,11 +366,40 @@ export async function runJob(job, cfg, api, viewer, opts) {
|
|
|
310
366
|
log(`agent already committed its changes for ${job.runId} — reconciling to a PR`);
|
|
311
367
|
}
|
|
312
368
|
await api.event(job.runId, 'pushing', { branch });
|
|
313
|
-
const
|
|
369
|
+
const gitCred = job.repo ? await api.gitCredential(job.runId) : null;
|
|
370
|
+
if (gitCred)
|
|
371
|
+
log(`using a FireFunc-issued git credential for ${gitCred.repo}`);
|
|
372
|
+
const push = gitCred
|
|
373
|
+
? await pushBranch(worktree, branch, gitCred.repo, gitCred)
|
|
374
|
+
: await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
|
|
314
375
|
if (push.code !== 0) {
|
|
315
|
-
return fail(`git push failed: ${push.stderr.slice(0, 400)}`);
|
|
376
|
+
return fail(`git push failed: ${redact(push.stderr, gitCred?.token).slice(0, 400)}`);
|
|
316
377
|
}
|
|
317
378
|
const body = `Automated fix by FireFunc (self-hosted ${engineName} runner) for ${job.externalId ?? 'a reported bug'}.\n\nReview before merging.`;
|
|
379
|
+
if (gitCred && !(await hasGh(worktree))) {
|
|
380
|
+
const pr = await openDraftPr({
|
|
381
|
+
repo: gitCred.repo,
|
|
382
|
+
head: branch,
|
|
383
|
+
base,
|
|
384
|
+
title,
|
|
385
|
+
body,
|
|
386
|
+
cred: gitCred,
|
|
387
|
+
});
|
|
388
|
+
if ('error' in pr)
|
|
389
|
+
return fail(redact(pr.error, gitCred.token));
|
|
390
|
+
await api.event(job.runId, 'pr_opened', { url: pr.url, branch });
|
|
391
|
+
log(`opened PR for ${job.runId}: ${pr.url}`);
|
|
392
|
+
return report({
|
|
393
|
+
status: 'pr_opened',
|
|
394
|
+
pr: {
|
|
395
|
+
repo: job.repo ?? undefined,
|
|
396
|
+
number: pr.number,
|
|
397
|
+
url: pr.url,
|
|
398
|
+
branch,
|
|
399
|
+
summary: title,
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
}
|
|
318
403
|
const create = await exec('gh', [
|
|
319
404
|
'pr',
|
|
320
405
|
'create',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firefunc-agent/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "FireFunc self-hosted runner — an always-on daemon that runs YOUR local coding agent (Claude Code, Codex, Gemini CLI, Cursor CLI) on work FireFunc routes to it, opens pull requests, and reports back. Your machine, your subscriptions.",
|