patchrelay 0.12.1 → 0.12.2

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.12.1",
4
- "commit": "dfda2eb5c3db",
5
- "builtAt": "2026-03-24T13:11:33.565Z"
3
+ "version": "0.12.2",
4
+ "commit": "a39d9226ae29",
5
+ "builtAt": "2026-03-24T13:32:47.905Z"
6
6
  }
package/dist/config.js CHANGED
@@ -3,7 +3,7 @@ import { isIP } from "node:net";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
5
  import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
6
- import { resolveSecret } from "./resolve-secret.js";
6
+ import { resolveSecretWithSource } from "./resolve-secret.js";
7
7
  import { ensureAbsolutePath } from "./utils.js";
8
8
  const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
9
9
  const REPO_SETTINGS_DIRNAME = ".patchrelay";
@@ -286,13 +286,18 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
286
286
  const parsedFile = parseJsonFile(requestedPath, "config file");
287
287
  const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
288
288
  const requirements = getLoadProfileRequirements(profile);
289
- const webhookSecret = resolveSecret("linear-webhook-secret", parsed.linear.webhook_secret_env, env);
290
- const tokenEncryptionKey = resolveSecret("token-encryption-key", parsed.linear.token_encryption_key_env, env);
291
- const oauthClientId = resolveSecret("linear-oauth-client-id", parsed.linear.oauth.client_id_env, env);
292
- const oauthClientSecret = resolveSecret("linear-oauth-client-secret", parsed.linear.oauth.client_secret_env, env);
293
- const operatorApiToken = parsed.operator_api.bearer_token_env
294
- ? resolveSecret("operator-api-token", parsed.operator_api.bearer_token_env, env)
289
+ const rWebhookSecret = resolveSecretWithSource("linear-webhook-secret", parsed.linear.webhook_secret_env, env);
290
+ const rTokenEncryptionKey = resolveSecretWithSource("token-encryption-key", parsed.linear.token_encryption_key_env, env);
291
+ const rOAuthClientId = resolveSecretWithSource("linear-oauth-client-id", parsed.linear.oauth.client_id_env, env);
292
+ const rOAuthClientSecret = resolveSecretWithSource("linear-oauth-client-secret", parsed.linear.oauth.client_secret_env, env);
293
+ const rOperatorApiToken = parsed.operator_api.bearer_token_env
294
+ ? resolveSecretWithSource("operator-api-token", parsed.operator_api.bearer_token_env, env)
295
295
  : undefined;
296
+ const webhookSecret = rWebhookSecret.value;
297
+ const tokenEncryptionKey = rTokenEncryptionKey.value;
298
+ const oauthClientId = rOAuthClientId.value;
299
+ const oauthClientSecret = rOAuthClientSecret.value;
300
+ const operatorApiToken = rOperatorApiToken?.value;
296
301
  if (requirements.requireWebhookSecret && !webhookSecret) {
297
302
  throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
298
303
  }
@@ -400,6 +405,13 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
400
405
  } : {}),
401
406
  };
402
407
  }),
408
+ secretSources: {
409
+ "linear-webhook-secret": rWebhookSecret.source,
410
+ "token-encryption-key": rTokenEncryptionKey.source,
411
+ "linear-oauth-client-id": rOAuthClientId.source,
412
+ "linear-oauth-client-secret": rOAuthClientSecret.source,
413
+ ...(rOperatorApiToken ? { "operator-api-token": rOperatorApiToken.source } : {}),
414
+ },
403
415
  };
404
416
  validateConfigSemantics(config, {
405
417
  allowMissingOperatorApiToken: requirements.allowMissingOperatorApiToken,
@@ -1,7 +1,7 @@
1
1
  import { createSign } from "node:crypto";
2
2
  import { writeFile, mkdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { resolveSecret } from "./resolve-secret.js";
4
+ import { resolveSecretWithSource } from "./resolve-secret.js";
5
5
  import { getPatchRelayDataDir } from "./runtime-paths.js";
6
6
  const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
7
7
  /**
@@ -14,14 +14,19 @@ const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
14
14
  */
15
15
  export function resolveGitHubAppCredentials() {
16
16
  const appId = process.env.PATCHRELAY_GITHUB_APP_ID;
17
- const privateKey = resolveSecret("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
18
- if (!appId || !privateKey)
17
+ const rPrivateKey = resolveSecretWithSource("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
18
+ const rWebhookSecret = resolveSecretWithSource("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
19
+ if (!appId || !rPrivateKey.value)
19
20
  return undefined;
20
21
  const installationId = process.env.PATCHRELAY_GITHUB_APP_INSTALLATION_ID;
21
22
  return {
22
23
  appId,
23
- privateKey,
24
+ privateKey: rPrivateKey.value,
24
25
  ...(installationId ? { installationId } : {}),
26
+ secretSources: {
27
+ "github-app-pem": rPrivateKey.source,
28
+ ...(rWebhookSecret.value ? { "github-app-webhook-secret": rWebhookSecret.source } : {}),
29
+ },
25
30
  };
26
31
  }
27
32
  /**
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ async function main() {
59
59
  port: config.server.port,
60
60
  webhookPath: config.ingress.linearWebhookPath,
61
61
  configPath: process.env.PATCHRELAY_CONFIG,
62
+ secrets: config.secretSources,
62
63
  }, "PatchRelay started");
63
64
  const shutdown = async () => {
64
65
  service.stop();
package/dist/preflight.js CHANGED
@@ -2,8 +2,10 @@ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs"
2
2
  import path from "node:path";
3
3
  import { runPatchRelayMigrations } from "./db/migrations.js";
4
4
  import { SqliteConnection } from "./db/shared.js";
5
+ import { resolveSecret } from "./resolve-secret.js";
5
6
  import { execCommand } from "./utils.js";
6
- export async function runPreflight(config) {
7
+ export async function runPreflight(config, options) {
8
+ const connectivity = options?.connectivity ?? true;
7
9
  const checks = [];
8
10
  if (!config.linear.webhookSecret) {
9
11
  checks.push(fail("linear", "LINEAR_WEBHOOK_SECRET is missing"));
@@ -82,11 +84,79 @@ export async function runPreflight(config) {
82
84
  }
83
85
  checks.push(await checkExecutable("git", config.runner.gitBin));
84
86
  checks.push(await checkExecutable("codex", config.runner.codex.bin));
87
+ // Connectivity checks — verify secrets actually work against live APIs.
88
+ // Skipped when graphqlUrl uses a non-routable domain (.example, .test, .invalid).
89
+ const skipConnectivity = !connectivity || isNonRoutableDomain(config.linear.graphqlUrl);
90
+ if (!skipConnectivity) {
91
+ if (config.linear.oauth.clientId && config.linear.oauth.clientSecret) {
92
+ checks.push(await checkLinearApi(config.linear.graphqlUrl));
93
+ }
94
+ const ghAppId = process.env.PATCHRELAY_GITHUB_APP_ID;
95
+ const ghAppKey = resolveSecret("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
96
+ if (ghAppId && ghAppKey) {
97
+ checks.push(await checkGitHubApp(ghAppId, ghAppKey));
98
+ }
99
+ }
85
100
  return {
86
101
  checks,
87
102
  ok: checks.every((check) => check.status !== "fail"),
88
103
  };
89
104
  }
105
+ async function checkLinearApi(graphqlUrl) {
106
+ try {
107
+ const response = await fetch(graphqlUrl, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ query: "{ __typename }" }),
111
+ signal: AbortSignal.timeout(5000),
112
+ });
113
+ if (response.ok) {
114
+ return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl}`);
115
+ }
116
+ return warn("linear_api", `Linear GraphQL API returned ${response.status} — may be unreachable or rate-limited`);
117
+ }
118
+ catch (error) {
119
+ return fail("linear_api", `Linear GraphQL API is unreachable at ${graphqlUrl}: ${formatError(error)}`);
120
+ }
121
+ }
122
+ async function checkGitHubApp(appId, privateKey) {
123
+ try {
124
+ const { createSign } = await import("node:crypto");
125
+ const now = Math.floor(Date.now() / 1000);
126
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
127
+ const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 120, iss: appId })).toString("base64url");
128
+ const signer = createSign("RSA-SHA256");
129
+ signer.update(`${header}.${payload}`);
130
+ let signature;
131
+ try {
132
+ signature = signer.sign(privateKey, "base64url");
133
+ }
134
+ catch (error) {
135
+ return fail("github_app", `GitHub App private key is invalid: ${formatError(error)}`);
136
+ }
137
+ const jwt = `${header}.${payload}.${signature}`;
138
+ const response = await fetch("https://api.github.com/app", {
139
+ headers: {
140
+ Authorization: `Bearer ${jwt}`,
141
+ Accept: "application/vnd.github+json",
142
+ "X-GitHub-Api-Version": "2022-11-28",
143
+ },
144
+ signal: AbortSignal.timeout(5000),
145
+ });
146
+ if (response.ok) {
147
+ const app = await response.json();
148
+ const label = app.slug ?? app.name ?? appId;
149
+ return pass("github_app", `GitHub App authenticated as "${label}"`);
150
+ }
151
+ if (response.status === 401) {
152
+ return fail("github_app", "GitHub App authentication failed — check APP_ID and private key");
153
+ }
154
+ return warn("github_app", `GitHub App API returned ${response.status}`);
155
+ }
156
+ catch (error) {
157
+ return fail("github_app", `GitHub API is unreachable: ${formatError(error)}`);
158
+ }
159
+ }
90
160
  function checkDatabaseHealth(config) {
91
161
  const checks = [];
92
162
  let connection;
@@ -242,6 +312,15 @@ function checkOAuthRedirectUri(config) {
242
312
  function isLoopbackHost(host) {
243
313
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
244
314
  }
315
+ function isNonRoutableDomain(url) {
316
+ try {
317
+ const hostname = new URL(url).hostname;
318
+ return /\.(example|test|invalid|localhost)$/i.test(hostname);
319
+ }
320
+ catch {
321
+ return false;
322
+ }
323
+ }
245
324
  function formatError(error) {
246
325
  return error instanceof Error ? error.message : String(error);
247
326
  }
@@ -13,11 +13,19 @@ import path from "node:path";
13
13
  * Returns `undefined` when the secret is not found at any level.
14
14
  */
15
15
  export function resolveSecret(credentialName, envKey, env = process.env) {
16
+ return resolveSecretWithSource(credentialName, envKey, env).value;
17
+ }
18
+ /**
19
+ * Same as `resolveSecret` but also returns which layer provided the value.
20
+ */
21
+ export function resolveSecretWithSource(credentialName, envKey, env = process.env) {
16
22
  // 1. systemd credentials directory (private mount, highest trust)
17
23
  const credDir = process.env.CREDENTIALS_DIRECTORY;
18
24
  if (credDir) {
19
25
  try {
20
- return readFileSync(path.join(credDir, credentialName), "utf8").trim();
26
+ const value = readFileSync(path.join(credDir, credentialName), "utf8").trim();
27
+ if (value)
28
+ return { value, source: "creddir" };
21
29
  }
22
30
  catch {
23
31
  // credential not in this directory — fall through
@@ -27,12 +35,17 @@ export function resolveSecret(credentialName, envKey, env = process.env) {
27
35
  const filePath = env[`${envKey}_FILE`];
28
36
  if (filePath) {
29
37
  try {
30
- return readFileSync(filePath, "utf8").trim();
38
+ const value = readFileSync(filePath, "utf8").trim();
39
+ if (value)
40
+ return { value, source: "file" };
31
41
  }
32
42
  catch {
33
43
  // file not readable — fall through
34
44
  }
35
45
  }
36
46
  // 3. Direct env var
37
- return env[envKey] || undefined;
47
+ const value = env[envKey] || undefined;
48
+ if (value)
49
+ return { value, source: "env" };
50
+ return { value: undefined, source: "missing" };
38
51
  }
@@ -171,26 +171,33 @@ export class RunOrchestrator {
171
171
  }
172
172
  // Reuse the existing thread only for review_fix (reviewer context matters).
173
173
  // Implementation, ci_repair, and queue_repair get fresh threads.
174
- // Fall back to a fresh thread if the stored one is stale (e.g. after app-server restart).
175
174
  if (issue.threadId && runType === "review_fix") {
176
- try {
177
- await this.codex.readThread(issue.threadId, false);
178
- threadId = issue.threadId;
179
- }
180
- catch {
181
- this.logger.info({ issueKey: issue.issueKey, staleThreadId: issue.threadId }, "Stored thread is stale, starting fresh for review_fix");
182
- const thread = await this.codex.startThread({ cwd: worktreePath });
183
- threadId = thread.id;
184
- this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
185
- }
175
+ threadId = issue.threadId;
186
176
  }
187
177
  else {
188
178
  const thread = await this.codex.startThread({ cwd: worktreePath });
189
179
  threadId = thread.id;
190
180
  this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
191
181
  }
192
- const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
193
- turnId = turn.turnId;
182
+ try {
183
+ const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
184
+ turnId = turn.turnId;
185
+ }
186
+ catch (turnError) {
187
+ // If the thread is stale (e.g. after app-server restart), start fresh and retry once.
188
+ const msg = turnError instanceof Error ? turnError.message : String(turnError);
189
+ if (msg.includes("thread not found") || msg.includes("not materialized")) {
190
+ this.logger.info({ issueKey: issue.issueKey, staleThreadId: threadId }, "Thread is stale, retrying with fresh thread");
191
+ const thread = await this.codex.startThread({ cwd: worktreePath });
192
+ threadId = thread.id;
193
+ this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
194
+ const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
195
+ turnId = turn.turnId;
196
+ }
197
+ else {
198
+ throw turnError;
199
+ }
200
+ }
194
201
  }
195
202
  catch (error) {
196
203
  const message = error instanceof Error ? error.message : String(error);
package/dist/service.js CHANGED
@@ -69,6 +69,7 @@ export class PatchRelayService {
69
69
  // Optional GitHub App token management for bot identity
70
70
  const ghAppCredentials = resolveGitHubAppCredentials();
71
71
  if (ghAppCredentials) {
72
+ Object.assign(config.secretSources, ghAppCredentials.secretSources);
72
73
  this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
73
74
  }
74
75
  this.codex.on("notification", (notification) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {