patchrelay 0.69.5 → 0.71.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/build-info.json +3 -3
- package/dist/codex-app-server.js +6 -1
- package/dist/github-app-token.js +61 -51
- package/dist/github-auth-remediation.js +34 -0
- package/dist/github-cli-auth.js +90 -0
- package/dist/index.js +6 -2
- package/dist/main-branch-health-monitor.js +22 -89
- package/dist/run-launcher.js +4 -8
- package/dist/run-orchestrator.js +1 -1
- package/dist/service-runtime.js +8 -0
- package/dist/service.js +31 -3
- package/package.json +1 -1
- package/dist/github-worktree-auth.js +0 -20
package/dist/build-info.json
CHANGED
package/dist/codex-app-server.js
CHANGED
|
@@ -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");
|
package/dist/github-app-token.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
127
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
|
|
2
1
|
import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
|
|
3
|
-
import { buildMainRepairBranchName,
|
|
2
|
+
import { buildMainRepairBranchName, isMainRepairIssue, } from "./main-repair.js";
|
|
4
3
|
import { execCommand } from "./utils.js";
|
|
5
4
|
const MAIN_BRANCH_HEALTH_GRACE_MS = 120_000;
|
|
6
5
|
function isUnhealthyMainConclusion(conclusion) {
|
|
@@ -14,14 +13,14 @@ export class MainBranchHealthMonitor {
|
|
|
14
13
|
db;
|
|
15
14
|
config;
|
|
16
15
|
linearProvider;
|
|
17
|
-
wakeDispatcher;
|
|
18
16
|
logger;
|
|
19
17
|
feed;
|
|
20
|
-
|
|
18
|
+
/** Per-project throttle for the information-only "main is red" log. */
|
|
19
|
+
lastUnhealthyReportAt = new Map();
|
|
20
|
+
constructor(db, config, linearProvider, logger, feed) {
|
|
21
21
|
this.db = db;
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.linearProvider = linearProvider;
|
|
24
|
-
this.wakeDispatcher = wakeDispatcher;
|
|
25
24
|
this.logger = logger;
|
|
26
25
|
this.feed = feed;
|
|
27
26
|
}
|
|
@@ -46,64 +45,27 @@ export class MainBranchHealthMonitor {
|
|
|
46
45
|
}
|
|
47
46
|
return;
|
|
48
47
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const client = await this.linearProvider.forProject(projectId);
|
|
61
|
-
if (!client?.createIssue) {
|
|
62
|
-
this.logger.warn({ projectId, repoFullName: project.github.repoFullName }, "Cannot create main repair issue because Linear issue creation is unavailable");
|
|
48
|
+
// main CI is red. The merge queue (merge-steward) gates only on its own
|
|
49
|
+
// speculative-SHA checks and ignores main entirely, so a red main no longer
|
|
50
|
+
// warrants an automated repair job — main CI is information-only. Report it
|
|
51
|
+
// (throttled) and post nothing. Any pre-existing repair issue is left to close
|
|
52
|
+
// via resolveRecoveredMainRepair once main recovers.
|
|
53
|
+
this.reportUnhealthyMain(projectId, project.github.repoFullName, baseBranch, summary);
|
|
54
|
+
}
|
|
55
|
+
reportUnhealthyMain(projectId, repoFullName, baseBranch, summary) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const lastReportedAt = this.lastUnhealthyReportAt.get(projectId);
|
|
58
|
+
if (lastReportedAt !== undefined && now - lastReportedAt < MAIN_BRANCH_HEALTH_GRACE_MS) {
|
|
63
59
|
return;
|
|
64
60
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
title: buildMainRepairTitle(project),
|
|
68
|
-
description: buildMainRepairDescription(project, summary, protocol.priorityLabel),
|
|
69
|
-
});
|
|
70
|
-
const issue = this.db.upsertIssue({
|
|
61
|
+
this.lastUnhealthyReportAt.set(projectId, now);
|
|
62
|
+
this.logger.warn({
|
|
71
63
|
projectId,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
...(created.url ? { url: created.url } : {}),
|
|
78
|
-
...(created.priority != null ? { priority: created.priority } : {}),
|
|
79
|
-
...(created.estimate != null ? { estimate: created.estimate } : {}),
|
|
80
|
-
...(created.stateName ? { currentLinearState: created.stateName } : {}),
|
|
81
|
-
...(created.stateType ? { currentLinearStateType: created.stateType } : {}),
|
|
82
|
-
branchName,
|
|
83
|
-
factoryState: "delegated",
|
|
84
|
-
});
|
|
85
|
-
this.wakeDispatcher.recordEventAndDispatch(projectId, issue.linearIssueId, {
|
|
86
|
-
eventType: "delegated",
|
|
87
|
-
eventJson: JSON.stringify({
|
|
88
|
-
runType: "main_repair",
|
|
89
|
-
baseSha: summary.baseSha,
|
|
90
|
-
failingChecks: summary.failingChecks,
|
|
91
|
-
pendingChecks: summary.pendingChecks,
|
|
92
|
-
priorityLabel: protocol.priorityLabel,
|
|
93
|
-
promptContext: buildMainRepairPromptContext(project, summary, protocol.priorityLabel),
|
|
94
|
-
}),
|
|
95
|
-
dedupeKey: `main_repair:${projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
96
|
-
});
|
|
97
|
-
this.feed?.publish({
|
|
98
|
-
level: "warn",
|
|
99
|
-
kind: "github",
|
|
100
|
-
issueKey: issue.issueKey,
|
|
101
|
-
projectId,
|
|
102
|
-
stage: "delegated",
|
|
103
|
-
status: "main_repair_queued",
|
|
104
|
-
summary: `Queued main_repair for ${project.github.repoFullName}@${baseBranch}`,
|
|
105
|
-
detail: summary.failingChecks.map((check) => check.name).join(", "),
|
|
106
|
-
});
|
|
64
|
+
repoFullName,
|
|
65
|
+
baseBranch,
|
|
66
|
+
baseSha: summary.baseSha,
|
|
67
|
+
failingChecks: summary.failingChecks.map((check) => check.name),
|
|
68
|
+
}, "main branch CI is red — information only; no repair job posted (merge queue gates on its own spec CI)");
|
|
107
69
|
}
|
|
108
70
|
findExistingMainRepair(projectId, branchName) {
|
|
109
71
|
const candidates = this.db.listIssues()
|
|
@@ -132,35 +94,6 @@ export class MainBranchHealthMonitor {
|
|
|
132
94
|
return 3;
|
|
133
95
|
return 4;
|
|
134
96
|
}
|
|
135
|
-
queueExistingMainRepair(issue, summary, priorityLabel) {
|
|
136
|
-
if (issue.activeRunId !== undefined)
|
|
137
|
-
return;
|
|
138
|
-
if (this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId))
|
|
139
|
-
return;
|
|
140
|
-
if (issue.prState === "open" || issue.factoryState === "awaiting_queue" || issue.factoryState === "pr_open")
|
|
141
|
-
return;
|
|
142
|
-
this.db.upsertIssue({
|
|
143
|
-
projectId: issue.projectId,
|
|
144
|
-
linearIssueId: issue.linearIssueId,
|
|
145
|
-
delegatedToPatchRelay: true,
|
|
146
|
-
factoryState: "delegated",
|
|
147
|
-
pendingRunType: null,
|
|
148
|
-
pendingRunContextJson: null,
|
|
149
|
-
activeRunId: null,
|
|
150
|
-
});
|
|
151
|
-
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
152
|
-
eventType: "delegated",
|
|
153
|
-
eventJson: JSON.stringify({
|
|
154
|
-
runType: "main_repair",
|
|
155
|
-
baseSha: summary.baseSha,
|
|
156
|
-
failingChecks: summary.failingChecks,
|
|
157
|
-
pendingChecks: summary.pendingChecks,
|
|
158
|
-
priorityLabel,
|
|
159
|
-
promptContext: buildMainRepairPromptContext(this.config.projects.find((project) => project.id === issue.projectId) ?? { id: issue.projectId }, summary, priorityLabel),
|
|
160
|
-
}),
|
|
161
|
-
dedupeKey: `main_repair:${issue.projectId}:${summary.baseSha}:${summary.failingChecks.map((check) => check.name).join("|")}`,
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
97
|
async resolveRecoveredMainRepair(issue) {
|
|
165
98
|
if (issue.activeRunId !== undefined)
|
|
166
99
|
return;
|
package/dist/run-launcher.js
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -137,7 +137,7 @@ export class RunOrchestrator {
|
|
|
137
137
|
this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
|
|
138
138
|
this.runWakePlanner = new RunWakePlanner(db);
|
|
139
139
|
this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed);
|
|
140
|
-
this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider,
|
|
140
|
+
this.mainBranchHealthMonitor = new MainBranchHealthMonitor(db, config, linearProvider, logger, feed);
|
|
141
141
|
this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
|
|
142
142
|
this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
|
|
143
143
|
advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
|
package/dist/service-runtime.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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,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
|
-
}
|