patchrelay 0.11.0 → 0.12.1

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.11.0",
4
- "commit": "d4b6131b08e4",
5
- "builtAt": "2026-03-23T23:29:46.267Z"
3
+ "version": "0.12.1",
4
+ "commit": "dfda2eb5c3db",
5
+ "builtAt": "2026-03-24T13:11:33.565Z"
6
6
  }
package/dist/config.js CHANGED
@@ -3,6 +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
7
  import { ensureAbsolutePath } from "./utils.js";
7
8
  const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
8
9
  const REPO_SETTINGS_DIRNAME = ".patchrelay";
@@ -285,12 +286,12 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
285
286
  const parsedFile = parseJsonFile(requestedPath, "config file");
286
287
  const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
287
288
  const requirements = getLoadProfileRequirements(profile);
288
- const webhookSecret = env[parsed.linear.webhook_secret_env];
289
- const tokenEncryptionKey = env[parsed.linear.token_encryption_key_env];
290
- const oauthClientId = env[parsed.linear.oauth.client_id_env];
291
- const oauthClientSecret = env[parsed.linear.oauth.client_secret_env];
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);
292
293
  const operatorApiToken = parsed.operator_api.bearer_token_env
293
- ? env[parsed.operator_api.bearer_token_env]
294
+ ? resolveSecret("operator-api-token", parsed.operator_api.bearer_token_env, env)
294
295
  : undefined;
295
296
  if (requirements.requireWebhookSecret && !webhookSecret) {
296
297
  throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
@@ -14,11 +14,11 @@ export const ALLOWED_TRANSITIONS = {
14
14
  delegated: ["preparing", "failed"],
15
15
  preparing: ["implementing", "failed"],
16
16
  implementing: ["pr_open", "awaiting_input", "failed", "escalated"],
17
- pr_open: ["awaiting_review", "repairing_ci", "failed"],
17
+ pr_open: ["awaiting_review", "awaiting_queue", "changes_requested", "repairing_ci", "failed"],
18
18
  awaiting_review: ["changes_requested", "awaiting_queue", "repairing_ci"],
19
19
  changes_requested: ["implementing", "awaiting_input", "escalated"],
20
20
  repairing_ci: ["pr_open", "awaiting_review", "escalated", "failed"],
21
- awaiting_queue: ["done", "repairing_queue", "repairing_ci"],
21
+ awaiting_queue: ["done", "repairing_queue", "repairing_ci", "changes_requested"],
22
22
  repairing_queue: ["pr_open", "awaiting_review", "awaiting_queue", "escalated", "failed"],
23
23
  awaiting_input: ["implementing", "delegated", "escalated"],
24
24
  escalated: [],
@@ -34,7 +34,9 @@ export function resolveFactoryStateFromGitHub(triggerEvent, current) {
34
34
  case "review_approved":
35
35
  return current === "awaiting_review" || current === "pr_open" ? "awaiting_queue" : undefined;
36
36
  case "review_changes_requested":
37
- return current === "awaiting_review" || current === "pr_open" ? "changes_requested" : undefined;
37
+ return current === "awaiting_review" || current === "pr_open" || current === "awaiting_queue"
38
+ ? "changes_requested"
39
+ : undefined;
38
40
  case "review_commented":
39
41
  return undefined; // informational only
40
42
  case "check_passed":
@@ -0,0 +1,205 @@
1
+ import { createSign } from "node:crypto";
2
+ import { writeFile, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveSecret } from "./resolve-secret.js";
5
+ import { getPatchRelayDataDir } from "./runtime-paths.js";
6
+ const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
7
+ /**
8
+ * Resolve credentials from environment. Returns undefined if not configured.
9
+ *
10
+ * The private key is resolved via the provider-agnostic `resolveSecret`:
11
+ * 1. `$CREDENTIALS_DIRECTORY/github-app-pem` (systemd-creds)
12
+ * 2. `$PATCHRELAY_GITHUB_APP_PRIVATE_KEY_FILE` (explicit file path)
13
+ * 3. `$PATCHRELAY_GITHUB_APP_PRIVATE_KEY` (direct env var)
14
+ */
15
+ export function resolveGitHubAppCredentials() {
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)
19
+ return undefined;
20
+ const installationId = process.env.PATCHRELAY_GITHUB_APP_INSTALLATION_ID;
21
+ return {
22
+ appId,
23
+ privateKey,
24
+ ...(installationId ? { installationId } : {}),
25
+ };
26
+ }
27
+ /**
28
+ * Well-known paths for the token file and the gh wrapper.
29
+ */
30
+ export function getGitHubAppPaths() {
31
+ const shareDir = getPatchRelayDataDir();
32
+ return {
33
+ tokenFile: path.join(shareDir, "gh-token"),
34
+ binDir: path.join(shareDir, "bin"),
35
+ ghWrapper: path.join(shareDir, "bin", "gh"),
36
+ };
37
+ }
38
+ /**
39
+ * Create the gh wrapper script that reads the token file.
40
+ * Idempotent — safe to call on every startup.
41
+ */
42
+ export async function ensureGhWrapper(logger) {
43
+ const { binDir, ghWrapper, tokenFile } = getGitHubAppPaths();
44
+ await mkdir(binDir, { recursive: true });
45
+ const script = `#!/bin/bash
46
+ # PatchRelay gh wrapper — uses GitHub App token when available.
47
+ # Falls through to the user's own gh auth if the token file is missing.
48
+ TOKEN_FILE="${tokenFile}"
49
+ if [ -f "$TOKEN_FILE" ]; then
50
+ export GH_TOKEN=$(cat "$TOKEN_FILE")
51
+ fi
52
+ exec /usr/bin/gh "$@"
53
+ `;
54
+ await writeFile(ghWrapper, script, { mode: 0o755 });
55
+ logger.debug({ path: ghWrapper }, "Wrote gh wrapper script");
56
+ }
57
+ /**
58
+ * Generate a GitHub App JWT (RS256, 10-minute lifetime).
59
+ */
60
+ function generateJwt(appId, privateKey) {
61
+ const now = Math.floor(Date.now() / 1000);
62
+ const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
63
+ const payload = Buffer.from(JSON.stringify({
64
+ iat: now - 60, // 60s clock drift allowance
65
+ exp: now + 600, // 10 minutes
66
+ iss: appId,
67
+ })).toString("base64url");
68
+ const signer = createSign("RSA-SHA256");
69
+ signer.update(`${header}.${payload}`);
70
+ const signature = signer.sign(privateKey, "base64url");
71
+ return `${header}.${payload}.${signature}`;
72
+ }
73
+ /**
74
+ * Exchange a JWT for an installation access token (1-hour lifetime).
75
+ */
76
+ async function fetchInstallationToken(jwt, installationId) {
77
+ const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
78
+ method: "POST",
79
+ headers: {
80
+ Authorization: `Bearer ${jwt}`,
81
+ Accept: "application/vnd.github+json",
82
+ "X-GitHub-Api-Version": "2022-11-28",
83
+ },
84
+ });
85
+ if (!response.ok) {
86
+ const body = await response.text();
87
+ throw new Error(`Failed to fetch installation token (${response.status}): ${body}`);
88
+ }
89
+ const data = await response.json();
90
+ return data.token;
91
+ }
92
+ /**
93
+ * Find the first installation ID for this app. Called once if installationId
94
+ * is not pre-configured.
95
+ */
96
+ async function resolveInstallationId(jwt) {
97
+ const response = await fetch("https://api.github.com/app/installations", {
98
+ headers: {
99
+ Authorization: `Bearer ${jwt}`,
100
+ Accept: "application/vnd.github+json",
101
+ "X-GitHub-Api-Version": "2022-11-28",
102
+ },
103
+ });
104
+ if (!response.ok) {
105
+ const body = await response.text();
106
+ throw new Error(`Failed to list installations (${response.status}): ${body}`);
107
+ }
108
+ const installations = await response.json();
109
+ const first = installations[0];
110
+ if (!first) {
111
+ throw new Error("GitHub App has no installations. Install it on a repository first.");
112
+ }
113
+ return String(first.id);
114
+ }
115
+ /**
116
+ * Creates a token manager that writes a fresh GitHub App installation token
117
+ * to a well-known file every 30 minutes. The gh wrapper script reads this file.
118
+ *
119
+ * Returns undefined if credentials are not configured (optional feature).
120
+ */
121
+ export function createGitHubAppTokenManager(credentials, logger) {
122
+ const { tokenFile } = getGitHubAppPaths();
123
+ let timer;
124
+ let resolvedInstallationId = credentials.installationId;
125
+ let cachedToken;
126
+ let resolvedBotIdentity;
127
+ async function refresh() {
128
+ try {
129
+ const jwt = generateJwt(credentials.appId, credentials.privateKey);
130
+ if (!resolvedInstallationId) {
131
+ resolvedInstallationId = await resolveInstallationId(jwt);
132
+ logger.info({ installationId: resolvedInstallationId }, "Resolved GitHub App installation ID");
133
+ }
134
+ if (!resolvedBotIdentity) {
135
+ resolvedBotIdentity = await resolveBotIdentity(jwt);
136
+ logger.info({ botName: resolvedBotIdentity.name, botEmail: resolvedBotIdentity.email }, "Resolved GitHub App bot identity");
137
+ }
138
+ const token = await fetchInstallationToken(jwt, resolvedInstallationId);
139
+ await mkdir(path.dirname(tokenFile), { recursive: true });
140
+ await writeFile(tokenFile, token, { mode: 0o600 });
141
+ cachedToken = token;
142
+ logger.debug("Refreshed GitHub App installation token");
143
+ }
144
+ catch (error) {
145
+ logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Failed to refresh GitHub App token (will retry in 30 minutes)");
146
+ }
147
+ }
148
+ function schedule() {
149
+ timer = setTimeout(() => {
150
+ void refresh().finally(schedule);
151
+ }, TOKEN_REFRESH_MS);
152
+ timer.unref?.();
153
+ }
154
+ return {
155
+ async start() {
156
+ await refresh();
157
+ schedule();
158
+ },
159
+ stop() {
160
+ if (timer) {
161
+ clearTimeout(timer);
162
+ timer = undefined;
163
+ }
164
+ },
165
+ currentToken() {
166
+ return cachedToken;
167
+ },
168
+ botIdentity() {
169
+ return resolvedBotIdentity;
170
+ },
171
+ };
172
+ }
173
+ /**
174
+ * Resolve the bot user identity (name + noreply email) from the GitHub API.
175
+ * The bot user ID (not the App ID) is required for the noreply email —
176
+ * using the App ID causes the [bot] badge to not render on commits.
177
+ */
178
+ async function resolveBotIdentity(jwt) {
179
+ const response = await fetch("https://api.github.com/app", {
180
+ headers: {
181
+ Authorization: `Bearer ${jwt}`,
182
+ Accept: "application/vnd.github+json",
183
+ "X-GitHub-Api-Version": "2022-11-28",
184
+ },
185
+ });
186
+ if (!response.ok) {
187
+ const body = await response.text();
188
+ throw new Error(`Failed to fetch app info (${response.status}): ${body}`);
189
+ }
190
+ const app = await response.json();
191
+ const botLogin = `${app.slug}[bot]`;
192
+ // Fetch the bot user to get the user ID (different from App ID)
193
+ const userResponse = await fetch(`https://api.github.com/users/${encodeURIComponent(botLogin)}`, {
194
+ headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
195
+ });
196
+ if (!userResponse.ok) {
197
+ const body = await userResponse.text();
198
+ throw new Error(`Failed to fetch bot user ${botLogin} (${userResponse.status}): ${body}`);
199
+ }
200
+ const user = await userResponse.json();
201
+ return {
202
+ name: user.login,
203
+ email: `${user.id}+${user.login}@users.noreply.github.com`,
204
+ };
205
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
+ import { resolveSecret } from "./resolve-secret.js";
3
4
  import { safeJsonParse } from "./utils.js";
4
5
  /**
5
6
  * GitHub sends both check_run and check_suite completion events.
@@ -73,7 +74,7 @@ export class GitHubWebhookHandler {
73
74
  ? this.config.projects.find((p) => p.github?.repoFullName === repoName)
74
75
  : undefined;
75
76
  // Verify signature using global GitHub App webhook secret
76
- const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
77
+ const webhookSecret = resolveSecret("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
77
78
  if (webhookSecret) {
78
79
  if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
79
80
  return { status: 401, body: { ok: false, reason: "invalid_signature" } };
@@ -97,7 +97,7 @@ export class MergeQueue {
97
97
  projectId: issue.projectId,
98
98
  stage: "awaiting_queue",
99
99
  status: "blocked",
100
- summary: "Branch up to date but auto-merge not enabled — set GITHUB_TOKEN to unblock",
100
+ summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
101
101
  });
102
102
  }
103
103
  return;
@@ -123,9 +123,19 @@ export class MergeQueue {
123
123
  summary: `Branch updated to latest ${baseBranch} — CI will run`,
124
124
  });
125
125
  }
126
+ /**
127
+ * Seed the merge queue on startup: for each project, ensure the front-of-queue
128
+ * issue has pendingMergePrep set. Catches issues that entered awaiting_queue
129
+ * but whose merge prep was never triggered or was lost to a crash/restart.
130
+ */
131
+ seedOnStartup() {
132
+ for (const project of this.config.projects) {
133
+ this.advanceQueue(project.id);
134
+ }
135
+ }
126
136
  /**
127
137
  * Advance the queue: find the next awaiting_queue issue and prepare it.
128
- * Called when a PR merges (pr_merged event).
138
+ * Called when a PR merges (pr_merged event) and on startup.
129
139
  */
130
140
  advanceQueue(projectId) {
131
141
  const queue = this.db.listIssuesByState(projectId, "awaiting_queue");
@@ -138,14 +148,9 @@ export class MergeQueue {
138
148
  }
139
149
  /** Returns true if auto-merge was successfully enabled (or already enabled). */
140
150
  async enableAutoMerge(issue, repoFullName) {
141
- const token = process.env.GITHUB_TOKEN;
142
- if (!token) {
143
- this.logger.warn({ issueKey: issue.issueKey }, "Merge prep: GITHUB_TOKEN not set — auto-merge cannot be enabled");
144
- return false;
145
- }
151
+ // Uses the host's existing gh auth — same credentials Codex uses to create PRs.
146
152
  const result = await execCommand("gh", ["pr", "merge", String(issue.prNumber), "--repo", repoFullName, "--auto", "--squash"], {
147
153
  timeoutMs: 30_000,
148
- env: { ...process.env, GH_TOKEN: token },
149
154
  });
150
155
  if (result.exitCode !== 0) {
151
156
  this.logger.warn({ issueKey: issue.issueKey, stderr: result.stderr?.slice(0, 200) }, "Merge prep: auto-merge enablement failed");
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Resolve a secret value using a three-level fallback:
5
+ *
6
+ * 1. `$CREDENTIALS_DIRECTORY/<credentialName>` — systemd-creds, Docker secrets,
7
+ * or any provider that mounts secrets as files in a private directory.
8
+ * 2. `${envKey}_FILE` — reads the secret from an arbitrary file path.
9
+ * Works with any file-based provider (age, sops, mounted volumes).
10
+ * 3. `$envKey` — direct environment variable (dev, `op run`, `sops exec-env`,
11
+ * or the legacy `service.env` EnvironmentFile).
12
+ *
13
+ * Returns `undefined` when the secret is not found at any level.
14
+ */
15
+ export function resolveSecret(credentialName, envKey, env = process.env) {
16
+ // 1. systemd credentials directory (private mount, highest trust)
17
+ const credDir = process.env.CREDENTIALS_DIRECTORY;
18
+ if (credDir) {
19
+ try {
20
+ return readFileSync(path.join(credDir, credentialName), "utf8").trim();
21
+ }
22
+ catch {
23
+ // credential not in this directory — fall through
24
+ }
25
+ }
26
+ // 2. _FILE convention (works with any file-based provider)
27
+ const filePath = env[`${envKey}_FILE`];
28
+ if (filePath) {
29
+ try {
30
+ return readFileSync(filePath, "utf8").trim();
31
+ }
32
+ catch {
33
+ // file not readable — fall through
34
+ }
35
+ }
36
+ // 3. Direct env var
37
+ return env[envKey] || undefined;
38
+ }
@@ -5,6 +5,7 @@ import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionP
5
5
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
6
6
  import { WorktreeManager } from "./worktree-manager.js";
7
7
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
8
+ import { execCommand } from "./utils.js";
8
9
  const DEFAULT_CI_REPAIR_BUDGET = 2;
9
10
  const DEFAULT_QUEUE_REPAIR_BUDGET = 2;
10
11
  function slugify(value) {
@@ -66,6 +67,7 @@ export class RunOrchestrator {
66
67
  logger;
67
68
  feed;
68
69
  worktreeManager;
70
+ botIdentity;
69
71
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
70
72
  this.config = config;
71
73
  this.db = db;
@@ -155,6 +157,12 @@ export class RunOrchestrator {
155
157
  try {
156
158
  // Ensure worktree
157
159
  await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
160
+ // Set bot git identity when GitHub App is configured
161
+ if (this.botIdentity) {
162
+ const gitBin = this.config.runner.gitBin;
163
+ await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
164
+ await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
165
+ }
158
166
  // Run prepare-worktree hook
159
167
  const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
160
168
  const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
@@ -163,8 +171,18 @@ export class RunOrchestrator {
163
171
  }
164
172
  // Reuse the existing thread only for review_fix (reviewer context matters).
165
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).
166
175
  if (issue.threadId && runType === "review_fix") {
167
- threadId = issue.threadId;
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
+ }
168
186
  }
169
187
  else {
170
188
  const thread = await this.codex.startThread({ cwd: worktreePath });
package/dist/service.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
1
2
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
2
3
  import { IssueQueryService } from "./issue-query-service.js";
3
4
  import { LinearOAuthService } from "./linear-oauth-service.js";
@@ -16,6 +17,7 @@ export class PatchRelayService {
16
17
  linearProvider;
17
18
  orchestrator;
18
19
  mergeQueue;
20
+ githubAppTokenManager;
19
21
  webhookHandler;
20
22
  githubWebhookHandler;
21
23
  oauthService;
@@ -64,14 +66,29 @@ export class PatchRelayService {
64
66
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
65
67
  this.queryService = new IssueQueryService(db, codex, this.orchestrator);
66
68
  this.runtime = runtime;
69
+ // Optional GitHub App token management for bot identity
70
+ const ghAppCredentials = resolveGitHubAppCredentials();
71
+ if (ghAppCredentials) {
72
+ this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
73
+ }
67
74
  this.codex.on("notification", (notification) => {
68
75
  void this.orchestrator.handleCodexNotification(notification);
69
76
  });
70
77
  }
71
78
  async start() {
79
+ if (this.githubAppTokenManager) {
80
+ await ensureGhWrapper(this.logger);
81
+ await this.githubAppTokenManager.start();
82
+ const identity = this.githubAppTokenManager.botIdentity();
83
+ if (identity) {
84
+ this.orchestrator.botIdentity = identity;
85
+ }
86
+ }
72
87
  await this.runtime.start();
88
+ this.mergeQueue.seedOnStartup();
73
89
  }
74
90
  stop() {
91
+ this.githubAppTokenManager?.stop();
75
92
  this.runtime.stop();
76
93
  }
77
94
  async createLinearOAuthStart(params) {
@@ -1,5 +1,5 @@
1
1
  [Unit]
2
- Description=PatchRelay (systemd user service)
2
+ Description=PatchRelay
3
3
  After=network-online.target
4
4
  Wants=network-online.target
5
5
  StartLimitIntervalSec=60
@@ -7,24 +7,47 @@ StartLimitBurst=8
7
7
 
8
8
  [Service]
9
9
  Type=simple
10
+ User=your-user
11
+ Group=your-user
10
12
  WorkingDirectory=/home/your-user
13
+
14
+ # Non-secret config: runtime overrides (paths, log level) and
15
+ # service-only identifiers (GitHub App IDs, etc.)
11
16
  EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
12
- EnvironmentFile=/home/your-user/.config/patchrelay/service.env
17
+ EnvironmentFile=-/home/your-user/.config/patchrelay/service.env
13
18
  Environment=NODE_ENV=production
14
19
  Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
15
- Environment=PATH=/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
20
+ Environment=PATH=/home/your-user/.local/share/patchrelay/bin:/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
21
+
22
+ # Secrets — encrypted at rest via systemd-creds, decrypted into a private
23
+ # ramfs at $CREDENTIALS_DIRECTORY visible only to this service.
24
+ # See docs/secrets.md for setup instructions.
25
+ #
26
+ # When credstore is not configured, PatchRelay falls back to reading secrets
27
+ # from env vars (EnvironmentFile, op run, etc.) via the resolve-secret module.
28
+ LoadCredentialEncrypted=linear-webhook-secret
29
+ LoadCredentialEncrypted=token-encryption-key
30
+ LoadCredentialEncrypted=linear-oauth-client-id
31
+ LoadCredentialEncrypted=linear-oauth-client-secret
32
+ # Uncomment when GitHub App integration is configured:
33
+ # LoadCredentialEncrypted=github-app-pem
34
+ # LoadCredentialEncrypted=github-app-webhook-secret
35
+
16
36
  ExecStart=/usr/bin/env patchrelay serve
17
37
  Restart=on-failure
18
38
  RestartSec=5
39
+
40
+ # Hardening
19
41
  NoNewPrivileges=true
20
42
  PrivateTmp=true
21
- ProtectSystem=full
43
+ PrivateMounts=true
44
+ ProtectSystem=strict
22
45
  ProtectHome=false
23
46
  ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
24
47
 
25
- # PatchRelay is intended to run as your real user so Codex inherits your
26
- # existing git, SSH, and local tool permissions.
27
- # Add your managed repository roots to ReadWritePaths if you keep hardening enabled.
48
+ # PatchRelay runs as your real user so Codex inherits your existing git,
49
+ # SSH, and local tool permissions.
50
+ # Add your managed repository roots to ReadWritePaths.
28
51
 
29
52
  [Install]
30
- WantedBy=default.target
53
+ WantedBy=multi-user.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,7 +1,25 @@
1
- # PatchRelay service secrets.
2
- # Keep these values at the machine level for this PatchRelay instance.
1
+ # PatchRelay service-only configuration.
2
+ #
3
+ # Secrets in this file are generated by `patchrelay init` for initial setup.
4
+ # For production, encrypt them with systemd-creds and remove the plaintext
5
+ # values below. See docs/secrets.md.
6
+ #
7
+ # Non-secret service identifiers (GitHub App IDs) stay here permanently.
3
8
 
4
9
  LINEAR_WEBHOOK_SECRET=replace-with-linear-webhook-secret
5
10
  PATCHRELAY_TOKEN_ENCRYPTION_KEY=replace-with-long-random-secret
6
11
  LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
7
12
  LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret
13
+
14
+ # Optional: GitHub App for bot identity.
15
+ # When configured, PatchRelay generates short-lived installation tokens
16
+ # and writes a gh wrapper so Codex operates as app-name[bot].
17
+ # Create a GitHub App at Settings > Developer settings > GitHub Apps.
18
+ #
19
+ # The private key resolves through the provider-agnostic fallback:
20
+ # 1. $CREDENTIALS_DIRECTORY/github-app-pem (systemd-creds)
21
+ # 2. PATCHRELAY_GITHUB_APP_PRIVATE_KEY_FILE (path to PEM file)
22
+ # 3. PATCHRELAY_GITHUB_APP_PRIVATE_KEY (inline PEM string)
23
+ #
24
+ # PATCHRELAY_GITHUB_APP_ID=123456
25
+ # PATCHRELAY_GITHUB_APP_INSTALLATION_ID=optional-skip-auto-discovery