patchrelay 0.69.5 → 0.70.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.69.5",
4
- "commit": "ab14b628b055",
5
- "builtAt": "2026-05-22T23:00:59.300Z"
3
+ "version": "0.70.0",
4
+ "commit": "b70636ecd6d4",
5
+ "builtAt": "2026-05-23T19:04:31.282Z"
6
6
  }
@@ -52,6 +52,7 @@ export class CodexAppServerClient extends EventEmitter {
52
52
  config;
53
53
  logger;
54
54
  spawnProcess;
55
+ childEnvProvider;
55
56
  static DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
56
57
  child;
57
58
  nextRequestId = 1;
@@ -60,11 +61,14 @@ export class CodexAppServerClient extends EventEmitter {
60
61
  started = false;
61
62
  stopping = false;
62
63
  startPromise;
63
- constructor(config, logger, spawnProcess = spawn) {
64
+ constructor(config, logger, spawnProcess = spawn,
65
+ /** Optional override for the child process env (used to strip frozen token vars). */
66
+ childEnvProvider) {
64
67
  super();
65
68
  this.config = config;
66
69
  this.logger = logger;
67
70
  this.spawnProcess = spawnProcess;
71
+ this.childEnvProvider = childEnvProvider;
68
72
  }
69
73
  /**
70
74
  * Update runtime codex settings used by future thread/thread-fork calls.
@@ -337,6 +341,7 @@ export class CodexAppServerClient extends EventEmitter {
337
341
  this.logger.info({ command: launch.command, args: launch.args }, "Starting Codex app-server");
338
342
  this.child = this.spawnProcess(launch.command, launch.args, {
339
343
  stdio: ["pipe", "pipe", "pipe"],
344
+ ...(this.childEnvProvider ? { env: this.childEnvProvider() } : {}),
340
345
  });
341
346
  this.child.stdin.on("error", (error) => {
342
347
  this.logger.error({ error: sanitizeDiagnosticText(error.message) }, "Codex app-server stdin error");
@@ -1,9 +1,9 @@
1
1
  import { createSign } from "node:crypto";
2
- import { writeFile, mkdir } from "node:fs/promises";
3
- import path from "node:path";
4
2
  import { resolveSecretWithSource } from "./resolve-secret.js";
5
3
  import { getPatchRelayDataDir } from "./runtime-paths.js";
6
- const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
4
+ import { getGhConfigDir, writeGhHostsToken } from "./github-cli-auth.js";
5
+ const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (installation tokens last 1 hour)
6
+ const TOKEN_EXPIRY_MARGIN_MS = 5 * 60_000; // treat a token expiring within 5 min as stale
7
7
  /**
8
8
  * Resolve credentials from environment. Returns undefined if not configured.
9
9
  *
@@ -29,40 +29,9 @@ export function resolveGitHubAppCredentials() {
29
29
  },
30
30
  };
31
31
  }
32
- /**
33
- * Well-known paths for the token file and the gh wrapper.
34
- */
32
+ /** Well-known path for the per-service `gh` config directory (holds the rotated hosts.yml). */
35
33
  export function getGitHubAppPaths() {
36
- const shareDir = getPatchRelayDataDir();
37
- return {
38
- tokenFile: path.join(shareDir, "gh-token"),
39
- binDir: path.join(shareDir, "bin"),
40
- ghWrapper: path.join(shareDir, "bin", "gh"),
41
- };
42
- }
43
- /**
44
- * Create the gh wrapper script that reads the token file.
45
- * Idempotent — safe to call on every startup.
46
- */
47
- export async function ensureGhWrapper(logger) {
48
- const { binDir, ghWrapper, tokenFile } = getGitHubAppPaths();
49
- await mkdir(binDir, { recursive: true });
50
- const script = `#!/bin/bash
51
- # PatchRelay gh wrapper — uses GitHub App token when available.
52
- # Falls through to the user's own gh auth if the token file is missing.
53
- TOKEN_FILE="${tokenFile}"
54
- if [ -f "$TOKEN_FILE" ]; then
55
- export GH_TOKEN=$(cat "$TOKEN_FILE")
56
- fi
57
- exec /usr/bin/gh "$@"
58
- `;
59
- await writeFile(ghWrapper, script, { mode: 0o755 });
60
- const currentPath = process.env.PATH ?? "";
61
- const pathEntries = currentPath.split(path.delimiter).filter(Boolean);
62
- if (!pathEntries.includes(binDir)) {
63
- process.env.PATH = [binDir, ...pathEntries].join(path.delimiter);
64
- }
65
- logger.debug({ path: ghWrapper }, "Wrote gh wrapper script");
34
+ return { ghConfigDir: getGhConfigDir(getPatchRelayDataDir()) };
66
35
  }
67
36
  /**
68
37
  * Generate a GitHub App JWT (RS256, 10-minute lifetime).
@@ -97,7 +66,7 @@ async function fetchInstallationToken(jwt, installationId) {
97
66
  throw new Error(`Failed to fetch installation token (${response.status}): ${body}`);
98
67
  }
99
68
  const data = await response.json();
100
- return data.token;
69
+ return { token: data.token, expiresAt: data.expires_at ?? null };
101
70
  }
102
71
  /**
103
72
  * Find the first installation ID for this app. Called once if installationId
@@ -123,17 +92,40 @@ async function resolveInstallationId(jwt) {
123
92
  return String(first.id);
124
93
  }
125
94
  /**
126
- * Creates a token manager that writes a fresh GitHub App installation token
127
- * to a well-known file every 30 minutes. The gh wrapper script reads this file.
95
+ * Creates a token manager that proactively re-mints a GitHub App installation token
96
+ * every 30 minutes and rewrites `gh`'s `hosts.yml` so both `gh` and `git` (via
97
+ * `gh auth git-credential`) authenticate as the bot with an always-fresh token.
128
98
  *
129
- * Returns undefined if credentials are not configured (optional feature).
99
+ * `onRefreshResult` is invoked after every refresh attempt so the service can update
100
+ * readiness/health and escalate when auth breaks.
130
101
  */
131
- export function createGitHubAppTokenManager(credentials, logger) {
132
- const { tokenFile } = getGitHubAppPaths();
102
+ export function createGitHubAppTokenManager(credentials, logger, onRefreshResult) {
103
+ const { ghConfigDir } = getGitHubAppPaths();
133
104
  let timer;
134
105
  let resolvedInstallationId = credentials.installationId;
135
106
  let cachedToken;
107
+ let expiresAtMs;
136
108
  let resolvedBotIdentity;
109
+ let lastRefreshAt = null;
110
+ let lastRefreshError = null;
111
+ let consecutiveFailures = 0;
112
+ function isFresh() {
113
+ if (!cachedToken)
114
+ return false;
115
+ if (expiresAtMs === undefined)
116
+ return true;
117
+ return expiresAtMs - Date.now() > TOKEN_EXPIRY_MARGIN_MS;
118
+ }
119
+ function status() {
120
+ return {
121
+ healthy: isFresh() && lastRefreshError === null,
122
+ installationId: resolvedInstallationId ?? null,
123
+ lastRefreshAt,
124
+ lastRefreshError,
125
+ consecutiveFailures,
126
+ expiresAt: expiresAtMs ? new Date(expiresAtMs).toISOString() : null,
127
+ };
128
+ }
137
129
  async function refresh() {
138
130
  try {
139
131
  const jwt = generateJwt(credentials.appId, credentials.privateKey);
@@ -145,16 +137,30 @@ export function createGitHubAppTokenManager(credentials, logger) {
145
137
  resolvedBotIdentity = await resolveBotIdentity(jwt);
146
138
  logger.info({ botName: resolvedBotIdentity.name, botEmail: resolvedBotIdentity.email }, "Resolved GitHub App bot identity");
147
139
  }
148
- const token = await fetchInstallationToken(jwt, resolvedInstallationId);
149
- await mkdir(path.dirname(tokenFile), { recursive: true });
150
- await writeFile(tokenFile, token, { mode: 0o600 });
151
- cachedToken = token;
140
+ const { token, expiresAt } = await fetchInstallationToken(jwt, resolvedInstallationId);
141
+ await writeGhHostsToken(ghConfigDir, token, resolvedBotIdentity.name);
142
+ // Keep the daemon's own env token fresh for in-process consumers that read it
143
+ // directly (webhook API helpers). This is the daemon's process env only — never a
144
+ // repo/global git config — and is stripped from the long-lived Codex child, which
145
+ // reads the rotated hosts.yml via GH_CONFIG_DIR instead.
152
146
  process.env.GH_TOKEN = token;
153
147
  process.env.GITHUB_TOKEN = token;
154
- logger.debug("Refreshed GitHub App installation token");
148
+ cachedToken = token;
149
+ expiresAtMs = expiresAt ? Date.parse(expiresAt) : undefined;
150
+ lastRefreshAt = new Date().toISOString();
151
+ lastRefreshError = null;
152
+ consecutiveFailures = 0;
153
+ logger.info({ installationId: resolvedInstallationId, expiresAt, ghConfigDir }, "Rotated GitHub App token (gh + git now authenticate as the bot with a fresh token)");
155
154
  }
156
155
  catch (error) {
157
- logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Failed to refresh GitHub App token (will retry in 30 minutes)");
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ lastRefreshError = message;
158
+ consecutiveFailures += 1;
159
+ // Escalate: a broken App token means every git/gh operation will fail.
160
+ logger.error({ error: message, consecutiveFailures, installationId: resolvedInstallationId ?? null }, "Failed to refresh GitHub App token — gh/git auth is degraded until this recovers");
161
+ }
162
+ finally {
163
+ onRefreshResult?.(status());
158
164
  }
159
165
  }
160
166
  function schedule() {
@@ -175,11 +181,17 @@ export function createGitHubAppTokenManager(credentials, logger) {
175
181
  }
176
182
  },
177
183
  currentToken() {
178
- return cachedToken;
184
+ return isFresh() ? cachedToken : undefined;
185
+ },
186
+ async refresh() {
187
+ await refresh();
179
188
  },
180
189
  botIdentity() {
181
190
  return resolvedBotIdentity;
182
191
  },
192
+ authStatus() {
193
+ return status();
194
+ },
183
195
  };
184
196
  }
185
197
  /**
@@ -210,10 +222,8 @@ async function resolveBotIdentity(jwt) {
210
222
  throw new Error(`Failed to fetch bot user ${botLogin} (${userResponse.status}): ${body}`);
211
223
  }
212
224
  const user = await userResponse.json();
213
- const { tokenFile } = getGitHubAppPaths();
214
225
  return {
215
226
  name: user.login,
216
227
  email: `${user.id}+${user.login}@users.noreply.github.com`,
217
- tokenFile,
218
228
  };
219
229
  }
@@ -0,0 +1,34 @@
1
+ import { execCommand } from "./utils.js";
2
+ const GITHUB_HELPER_KEY = "credential.https://github.com.helper";
3
+ // Markers of the helper older patchrelay versions wrote into managed repo configs:
4
+ // a shell helper that cat-ed the bot token file. Either marker identifies a leaked entry.
5
+ const LEAKED_HELPER_MARKERS = ["gh-token", "x-access-token"];
6
+ /**
7
+ * Best-effort cleanup of credentials that older patchrelay versions persisted into
8
+ * managed repo git configs: a credential helper that read the bot token file, plus a
9
+ * bot `user.name`/`user.email`. Those leaked the bot identity into interactive shell
10
+ * sessions on the shared clone. The current design delivers auth purely via process env,
11
+ * so these entries are obsolete and should be removed. Idempotent and non-fatal.
12
+ */
13
+ export async function remediateLeakedBotAuth(params) {
14
+ for (const repoPath of new Set(params.repoPaths)) {
15
+ try {
16
+ const helpers = await execCommand(params.gitBin, ["-C", repoPath, "config", "--local", "--get-all", GITHUB_HELPER_KEY], { timeoutMs: 5_000 });
17
+ if (helpers.exitCode === 0 && LEAKED_HELPER_MARKERS.some((marker) => helpers.stdout?.includes(marker))) {
18
+ await execCommand(params.gitBin, ["-C", repoPath, "config", "--local", "--unset-all", GITHUB_HELPER_KEY], { timeoutMs: 5_000 });
19
+ params.logger.info({ repoPath }, "Removed leaked bot credential helper from repo git config");
20
+ }
21
+ if (params.botName) {
22
+ const userName = await execCommand(params.gitBin, ["-C", repoPath, "config", "--local", "user.name"], { timeoutMs: 5_000 });
23
+ if (userName.exitCode === 0 && userName.stdout?.trim() === params.botName) {
24
+ await execCommand(params.gitBin, ["-C", repoPath, "config", "--local", "--unset", "user.name"], { timeoutMs: 5_000 });
25
+ await execCommand(params.gitBin, ["-C", repoPath, "config", "--local", "--unset", "user.email"], { timeoutMs: 5_000 });
26
+ params.logger.info({ repoPath }, "Removed leaked bot identity from repo git config");
27
+ }
28
+ }
29
+ }
30
+ catch (error) {
31
+ params.logger.warn({ repoPath, error: error instanceof Error ? error.message : String(error) }, "Failed to remediate leaked bot auth in repo git config (non-fatal)");
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ /**
5
+ * Unified GitHub App credential delivery for `git` and the `gh` CLI.
6
+ *
7
+ * Both tools authenticate as the App through a single source of truth: a private
8
+ * `gh` config directory (`GH_CONFIG_DIR`) whose `hosts.yml` holds the current
9
+ * installation token. `git` is pointed at `gh auth git-credential` via env-injected
10
+ * config, so it reads the same token. The token file is rewritten on every proactive
11
+ * rotation (see github-app-token.ts), so even long-lived child processes (the Codex
12
+ * app-server) stay fresh — they re-read the file on each call rather than capturing a
13
+ * token frozen at spawn.
14
+ *
15
+ * Nothing is written into any repo/worktree/global git config, so these credentials
16
+ * never leak into a developer's interactive shell sessions.
17
+ */
18
+ export const GITHUB_HOST = "github.com";
19
+ /** Path to the per-service `gh` config directory, under the service data dir. */
20
+ export function getGhConfigDir(dataDir) {
21
+ return path.join(dataDir, "gh-bot");
22
+ }
23
+ /** Resolve an absolute `gh` path so the git credential helper works under restricted PATHs. */
24
+ export function resolveGhBin() {
25
+ for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/opt/homebrew/bin/gh"]) {
26
+ if (existsSync(candidate))
27
+ return candidate;
28
+ }
29
+ return "gh";
30
+ }
31
+ /**
32
+ * Write `gh`'s `hosts.yml` so that `gh` (and therefore `git`, via
33
+ * `gh auth git-credential`) authenticate as the App using `token`.
34
+ */
35
+ export async function writeGhHostsToken(ghConfigDir, token, login) {
36
+ await mkdir(ghConfigDir, { recursive: true });
37
+ const hosts = `${GITHUB_HOST}:\n` +
38
+ ` oauth_token: ${token}\n` +
39
+ ` user: ${login}\n` +
40
+ ` git_protocol: https\n`;
41
+ await writeFile(path.join(ghConfigDir, "hosts.yml"), hosts, { mode: 0o600 });
42
+ }
43
+ /**
44
+ * Build the environment that makes both `gh` and `git` authenticate as the App.
45
+ *
46
+ * - `GH_CONFIG_DIR` points `gh` at the rotated bot config.
47
+ * - `git` delegates github.com credentials to `gh auth git-credential` (an empty
48
+ * helper first clears any inherited/global helper, then ours is appended).
49
+ * - `GIT_AUTHOR_*`/`GIT_COMMITTER_*` attribute commits to the bot without writing
50
+ * `user.name` into any repo config.
51
+ *
52
+ * Note on token env vars: the daemon keeps `GH_TOKEN`/`GITHUB_TOKEN` fresh (rotated each
53
+ * cycle) for in-process consumers that read them directly. They are stripped only when
54
+ * spawning a long-lived child (see {@link buildAgentChildEnv}) — there they would take
55
+ * precedence over `GH_CONFIG_DIR` and freeze a stale token captured at spawn.
56
+ */
57
+ export function buildGitHubCliAuthEnv(opts) {
58
+ const env = {
59
+ GH_CONFIG_DIR: opts.ghConfigDir,
60
+ GIT_TERMINAL_PROMPT: "0",
61
+ GIT_CONFIG_COUNT: "2",
62
+ GIT_CONFIG_KEY_0: `credential.https://${GITHUB_HOST}.helper`,
63
+ GIT_CONFIG_VALUE_0: "",
64
+ GIT_CONFIG_KEY_1: `credential.https://${GITHUB_HOST}.helper`,
65
+ GIT_CONFIG_VALUE_1: `!${opts.ghBin} auth git-credential`,
66
+ };
67
+ if (opts.identity) {
68
+ env.GIT_AUTHOR_NAME = opts.identity.name;
69
+ env.GIT_AUTHOR_EMAIL = opts.identity.email;
70
+ env.GIT_COMMITTER_NAME = opts.identity.name;
71
+ env.GIT_COMMITTER_EMAIL = opts.identity.email;
72
+ }
73
+ return env;
74
+ }
75
+ /** Apply {@link buildGitHubCliAuthEnv} onto a process env object in place. */
76
+ export function applyGitHubCliAuthEnv(target, opts) {
77
+ Object.assign(target, buildGitHubCliAuthEnv(opts));
78
+ }
79
+ /**
80
+ * Build the environment for a long-lived child process (e.g. the Codex app-server).
81
+ * `GH_TOKEN`/`GITHUB_TOKEN` are stripped so the child resolves credentials through the
82
+ * inherited `GH_CONFIG_DIR` (re-read fresh on each call) instead of a token frozen at
83
+ * spawn. The child still inherits the `gh` credential helper and bot identity.
84
+ */
85
+ export function buildAgentChildEnv(parentEnv = process.env) {
86
+ const env = { ...parentEnv };
87
+ delete env.GH_TOKEN;
88
+ delete env.GITHUB_TOKEN;
89
+ return env;
90
+ }
package/dist/index.js CHANGED
@@ -7,8 +7,9 @@ async function main() {
7
7
  process.exitCode = cliExitCode;
8
8
  return;
9
9
  }
10
- const [{ CodexAppServerClient }, { getAdjacentEnvFilePaths, loadConfig }, { PatchRelayDatabase }, { enforceRuntimeFilePermissions, enforceServiceEnvPermissions }, { buildHttpServer }, { DatabaseBackedLinearClientProvider }, { createLogger }, { runPreflight }, { PatchRelayService }, { ensureDir },] = await Promise.all([
10
+ const [{ CodexAppServerClient }, { buildAgentChildEnv }, { getAdjacentEnvFilePaths, loadConfig }, { PatchRelayDatabase }, { enforceRuntimeFilePermissions, enforceServiceEnvPermissions }, { buildHttpServer }, { DatabaseBackedLinearClientProvider }, { createLogger }, { runPreflight }, { PatchRelayService }, { ensureDir },] = await Promise.all([
11
11
  import("./codex-app-server.js"),
12
+ import("./github-cli-auth.js"),
12
13
  import("./config.js"),
13
14
  import("./db.js"),
14
15
  import("./file-permissions.js"),
@@ -36,7 +37,10 @@ async function main() {
36
37
  const logger = createLogger(config);
37
38
  const db = new PatchRelayDatabase(config.database.path, config.database.wal);
38
39
  db.runMigrations();
39
- const codex = new CodexAppServerClient(config.runner.codex, logger);
40
+ // The Codex app-server is long-lived: give it an env without GH_TOKEN/GITHUB_TOKEN so the
41
+ // agent's git/gh resolve credentials via the inherited GH_CONFIG_DIR (rotated hosts.yml)
42
+ // rather than a token frozen at spawn.
43
+ const codex = new CodexAppServerClient(config.runner.codex, logger, undefined, () => buildAgentChildEnv());
40
44
  const linearProvider = new DatabaseBackedLinearClientProvider(config, db, logger);
41
45
  const service = new PatchRelayService(config, db, codex, linearProvider, logger, configPath);
42
46
  await service.start();
@@ -2,7 +2,6 @@ import { buildHookEnv, runProjectHook } from "./hook-runner.js";
2
2
  import { buildRunFailureActivity } from "./linear-session-reporting.js";
3
3
  import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
4
4
  import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
5
- import { configureGitHubBotAuthForWorktree } from "./github-worktree-auth.js";
6
5
  import { sanitizeDiagnosticText } from "./utils.js";
7
6
  function slugify(value) {
8
7
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
@@ -152,13 +151,10 @@ export class RunLauncher {
152
151
  const firstThreadForIssue = !params.issue.threadId;
153
152
  try {
154
153
  await this.worktreeManager.ensureIssueWorktree(params.project.repoPath, params.project.worktreeRoot, params.worktreePath, params.branchName, { allowExistingOutsideRoot: params.issue.branchName !== undefined });
155
- if (params.botIdentity) {
156
- await configureGitHubBotAuthForWorktree({
157
- gitBin: this.config.runner.gitBin,
158
- worktreePath: params.worktreePath,
159
- botIdentity: params.botIdentity,
160
- });
161
- }
154
+ // GitHub auth (gh + git) and bot commit identity reach the agent via the inherited
155
+ // process env (GH_CONFIG_DIR + gh credential helper + GIT_AUTHOR/COMMITTER). Nothing
156
+ // is written into the worktree git config, so credentials never leak into interactive
157
+ // shell sessions on the shared clone.
162
158
  await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
163
159
  if (shouldFreshenWorktreeBeforeLaunch({
164
160
  runType: params.runType,
@@ -15,6 +15,8 @@ export class ServiceRuntime {
15
15
  issueQueue;
16
16
  ready = false;
17
17
  linearConnected = false;
18
+ githubAppAuthHealthy = true;
19
+ githubAppAuthError;
18
20
  startupError;
19
21
  reconcileTimer;
20
22
  reconcileInProgress = false;
@@ -57,11 +59,17 @@ export class ServiceRuntime {
57
59
  setLinearConnected(connected) {
58
60
  this.linearConnected = connected;
59
61
  }
62
+ setGithubAppAuthHealthy(healthy, reason) {
63
+ this.githubAppAuthHealthy = healthy;
64
+ this.githubAppAuthError = healthy ? undefined : reason;
65
+ }
60
66
  getReadiness() {
61
67
  return {
62
68
  ready: this.ready && this.codex.isStarted() && this.linearConnected,
63
69
  codexStarted: this.codex.isStarted(),
64
70
  linearConnected: this.linearConnected,
71
+ githubAppAuthHealthy: this.githubAppAuthHealthy,
72
+ ...(this.githubAppAuthError ? { githubAppAuthError: this.githubAppAuthError } : {}),
65
73
  ...(this.startupError ? { startupError: this.startupError } : {}),
66
74
  };
67
75
  }
package/dist/service.js CHANGED
@@ -1,4 +1,6 @@
1
- import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
1
+ import { resolveGitHubAppCredentials, createGitHubAppTokenManager, getGitHubAppPaths, } from "./github-app-token.js";
2
+ import { applyGitHubCliAuthEnv, resolveGhBin } from "./github-cli-auth.js";
3
+ import { remediateLeakedBotAuth } from "./github-auth-remediation.js";
2
4
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
3
5
  import { IssueQueryService } from "./issue-query-service.js";
4
6
  import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
@@ -75,7 +77,11 @@ export class PatchRelayService {
75
77
  const ghAppCredentials = resolveGitHubAppCredentials();
76
78
  if (ghAppCredentials) {
77
79
  Object.assign(config.secretSources, ghAppCredentials.secretSources);
78
- this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
80
+ this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger, (status) => {
81
+ // Surface auth health on every rotation so `patchrelay` status reflects it and
82
+ // a broken token escalates instead of silently failing later git/gh operations.
83
+ this.runtime.setGithubAppAuthHealthy(status.healthy, status.lastRefreshError ?? undefined);
84
+ });
79
85
  }
80
86
  this.codex.on("notification", (notification) => {
81
87
  this.orchestrator.handleCodexNotification(notification).catch((error) => {
@@ -126,12 +132,34 @@ export class PatchRelayService {
126
132
  this.logger.error("No projects have working Linear auth — service is NOT READY. Run 'patchrelay linear connect' to authorize.");
127
133
  }
128
134
  if (this.githubAppTokenManager) {
129
- await ensureGhWrapper(this.logger);
135
+ const { ghConfigDir } = getGitHubAppPaths();
136
+ const ghBin = resolveGhBin();
137
+ // Point gh + git at the bot config dir before the first rotation and before the
138
+ // Codex app-server spawns (it inherits this process env). GH_TOKEN/GITHUB_TOKEN are
139
+ // cleared so the rotated hosts.yml is the single source of truth.
140
+ applyGitHubCliAuthEnv(process.env, { ghConfigDir, ghBin });
130
141
  await this.githubAppTokenManager.start();
131
142
  const identity = this.githubAppTokenManager.botIdentity();
132
143
  if (identity) {
133
144
  this.orchestrator.botIdentity = identity;
145
+ // Re-apply with identity so bot commit attribution flows to git via env.
146
+ applyGitHubCliAuthEnv(process.env, { ghConfigDir, ghBin, identity });
147
+ }
148
+ const ghAuthStatus = this.githubAppTokenManager.authStatus();
149
+ this.runtime.setGithubAppAuthHealthy(ghAuthStatus.healthy, ghAuthStatus.lastRefreshError ?? undefined);
150
+ if (!ghAuthStatus.healthy) {
151
+ this.logger.error({ ghAuthStatus }, "GitHub App auth is NOT healthy at startup — git/gh operations will fail until a token is minted");
134
152
  }
153
+ else {
154
+ this.logger.info({ installationId: ghAuthStatus.installationId, expiresAt: ghAuthStatus.expiresAt }, "GitHub App auth ready — gh + git authenticate as the bot");
155
+ }
156
+ // Clean up credentials older versions persisted into managed repo configs.
157
+ await remediateLeakedBotAuth({
158
+ gitBin: this.config.runner.gitBin,
159
+ repoPaths: this.config.repositories.map((repository) => repository.localPath),
160
+ ...(identity ? { botName: identity.name } : {}),
161
+ logger: this.logger,
162
+ });
135
163
  }
136
164
  await this.runtime.start();
137
165
  void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.69.5",
3
+ "version": "0.70.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,20 +0,0 @@
1
- import { execCommand } from "./utils.js";
2
- function shellSingleQuote(value) {
3
- return `'${value.replace(/'/g, `'"'"'`)}'`;
4
- }
5
- export function buildGitHubBotCredentialHelper(tokenFile) {
6
- const quotedTokenFile = shellSingleQuote(tokenFile);
7
- return `!f() { [ "$1" = get ] || exit 0; echo "username=x-access-token"; echo "password=$(cat ${quotedTokenFile})"; }; f`;
8
- }
9
- export async function configureGitHubBotAuthForWorktree(params) {
10
- const helper = buildGitHubBotCredentialHelper(params.botIdentity.tokenFile);
11
- const gitConfigArgs = ["-C", params.worktreePath, "config"];
12
- const gitWorktreeConfigArgs = [...gitConfigArgs, "--worktree"];
13
- await execCommand(params.gitBin, [...gitConfigArgs, "extensions.worktreeConfig", "true"], { timeoutMs: 5_000 });
14
- await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "user.name", params.botIdentity.name], { timeoutMs: 5_000 });
15
- await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "user.email", params.botIdentity.email], { timeoutMs: 5_000 });
16
- // Clear inherited GitHub-specific helpers such as `gh auth git-credential`
17
- // so git HTTPS operations use the same bot token as the wrapped `gh` CLI.
18
- await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "--replace-all", "credential.https://github.com.helper", ""], { timeoutMs: 5_000 });
19
- await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "--add", "credential.https://github.com.helper", helper], { timeoutMs: 5_000 });
20
- }