@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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # firefunc-runner
2
2
 
3
- Run **your own** Claude Code on bugs FireFunc routes to you and open PRs automatically.
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 Claude Code** (`claude -p`) in an isolated git worktree off a clean base branch,
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 Claude Code on bugs FireFunc routes to you.
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
@@ -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.includes('oauth token has expired') ||
102
- t.includes('oauth token expired') ||
103
- t.includes('please run /login')) {
104
- return 'Claude Code could not authenticate. Fix the runner’s Claude auth — run `claude setup-token` and pass `--claude-oauth-token`, or clear a stale/invalid ANTHROPIC_API_KEY — then retry.';
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
+ }>;
@@ -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
- const repoPath = resolveRepoPath(job.repo, cfg);
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} set it in config.repos or reposDir`);
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 configured for ${job.repo}.`,
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 push = await exec('git', ['-C', worktree, 'push', '--force-with-lease', 'origin', `${branch}:${branch}`], { timeoutMs: 120_000 });
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.5.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.",