@firefunc-agent/runner 0.5.1 → 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/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',
@@ -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.1",
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.",