@open-everywhere/provider-github 0.8.0 → 0.9.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.
Files changed (49) hide show
  1. package/agent/app-manifest.ts +39 -17
  2. package/agent/channels/github-catch-up.ts +9 -4
  3. package/agent/channels/github-credentials.ts +41 -0
  4. package/agent/channels/github-engagement.ts +1 -1
  5. package/agent/channels/github-event-reach.ts +9 -4
  6. package/agent/channels/github-post-arm.ts +15 -12
  7. package/agent/channels/github-reaction-feedback.ts +2 -2
  8. package/agent/channels/github.ts +58 -14
  9. package/agent/docs-generated.ts +42 -23
  10. package/agent/memory/memory.ts +13 -12
  11. package/agent/ops.ts +9 -6
  12. package/dist/agent/app-manifest.d.ts +16 -3
  13. package/dist/agent/app-manifest.d.ts.map +1 -1
  14. package/dist/agent/app-manifest.js +32 -17
  15. package/dist/agent/app-manifest.js.map +1 -1
  16. package/dist/agent/channels/github-catch-up.d.ts.map +1 -1
  17. package/dist/agent/channels/github-catch-up.js +9 -5
  18. package/dist/agent/channels/github-catch-up.js.map +1 -1
  19. package/dist/agent/channels/github-credentials.d.ts +10 -0
  20. package/dist/agent/channels/github-credentials.d.ts.map +1 -0
  21. package/dist/agent/channels/github-credentials.js +40 -0
  22. package/dist/agent/channels/github-credentials.js.map +1 -0
  23. package/dist/agent/channels/github-engagement.d.ts +1 -1
  24. package/dist/agent/channels/github-engagement.js +1 -1
  25. package/dist/agent/channels/github-event-reach.d.ts +3 -2
  26. package/dist/agent/channels/github-event-reach.d.ts.map +1 -1
  27. package/dist/agent/channels/github-event-reach.js +8 -4
  28. package/dist/agent/channels/github-event-reach.js.map +1 -1
  29. package/dist/agent/channels/github-post-arm.d.ts.map +1 -1
  30. package/dist/agent/channels/github-post-arm.js +15 -14
  31. package/dist/agent/channels/github-post-arm.js.map +1 -1
  32. package/dist/agent/channels/github-reaction-feedback.d.ts +1 -1
  33. package/dist/agent/channels/github-reaction-feedback.js +2 -2
  34. package/dist/agent/channels/github-reaction-feedback.js.map +1 -1
  35. package/dist/agent/channels/github.d.ts +33 -0
  36. package/dist/agent/channels/github.d.ts.map +1 -1
  37. package/dist/agent/channels/github.js +57 -13
  38. package/dist/agent/channels/github.js.map +1 -1
  39. package/dist/agent/docs-generated.d.ts.map +1 -1
  40. package/dist/agent/docs-generated.js +42 -23
  41. package/dist/agent/docs-generated.js.map +1 -1
  42. package/dist/agent/memory/memory.d.ts +1 -0
  43. package/dist/agent/memory/memory.d.ts.map +1 -1
  44. package/dist/agent/memory/memory.js +12 -12
  45. package/dist/agent/memory/memory.js.map +1 -1
  46. package/dist/agent/ops.d.ts.map +1 -1
  47. package/dist/agent/ops.js +9 -6
  48. package/dist/agent/ops.js.map +1 -1
  49. package/package.json +6 -2
@@ -108,11 +108,34 @@ export function githubAppManifest(opts: {
108
108
  };
109
109
  }
110
110
 
111
- /** The env names a created App fills, for each shape. The OE_ set is the source-control plane; the
112
- * bare set is the conversational surface (docs/deploy/surfaces/github.md). One App serves both. */
111
+ /** The env names a created App fills, for each shape — its SECRETS only. The OE_ key is the
112
+ * source-control plane's; the bare key is the conversational surface's (docs/deploy/surfaces/github.md).
113
+ * One App serves both. Which App it is (id, slug, client id, bot user id) is the `github.*` settings
114
+ * since OPE-2650, so no id is an env name here: see {@link githubAppSettings}. */
113
115
  export function githubAppEnvNames(variant: GithubAppVariant): string[] {
114
- const scm = ["OE_GITHUB_APP_ID", "OE_GITHUB_APP_SLUG", "OE_GITHUB_APP_PRIVATE_KEY", "OE_GITHUB_APP_CLIENT_ID", "OE_GITHUB_BOT_USER_ID", "GITHUB_WEBHOOK_SECRET"];
115
- return variant === "full" ? [...scm, "GITHUB_APP_ID", "GITHUB_APP_SLUG", "GITHUB_APP_PRIVATE_KEY"] : scm;
116
+ // ONE private-key name for both variants (OPE-2650 folded OE_GITHUB_APP_PRIVATE_KEY into it): the key
117
+ // and the webhook secret are operator secrets, so `secret_set` moves them from .env.local into the store.
118
+ return ["GITHUB_APP_PRIVATE_KEY", "GITHUB_WEBHOOK_SECRET"];
119
+ }
120
+
121
+ /** The PUBLIC settings a created App fills, setting path → value (OPE-2650). The source-control plane and
122
+ * the conversation surface read the one `github.appId`/`github.appSlug`. `github.conversation` arms the
123
+ * conversation surface, so it is `true` for the `full` App only: a `source-control` App subscribes to no
124
+ * comment event and must start no conversation. */
125
+ export function githubAppSettings(app: {
126
+ readonly variant: GithubAppVariant;
127
+ readonly id: string;
128
+ readonly slug: string;
129
+ readonly clientId: string;
130
+ readonly botUserId?: string;
131
+ }): Record<string, string | boolean> {
132
+ return {
133
+ "github.appId": app.id,
134
+ "github.appSlug": app.slug,
135
+ "github.appClientId": app.clientId,
136
+ ...(app.botUserId === undefined ? {} : { "github.botUserId": app.botUserId }),
137
+ "github.conversation": app.variant === "full",
138
+ };
116
139
  }
117
140
 
118
141
  interface Conversion {
@@ -128,7 +151,7 @@ const API = "https://api.github.com";
128
151
  const HEADERS = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28" };
129
152
 
130
153
  /**
131
- * Turn the redirect's code into this deployment's env. One POST to GitHub's conversion endpoint
154
+ * Turn the redirect's code into this deployment's env and settings. One POST to GitHub's conversion endpoint
132
155
  * (it needs no credential: the code is the proof, and it is single-use and lives one hour), then a
133
156
  * public read of the App's bot user for the commit e-mail. That second read is fail-soft: without
134
157
  * it the plane falls back to the App id (docs/capabilities/source-control.md).
@@ -159,25 +182,23 @@ export async function exchangeGithubManifestCode(opts: {
159
182
  .map(([k]) => k);
160
183
  if (missing.length > 0) throw new Error(`GitHub's reply lacked ${missing.join(", ")}`);
161
184
  const env: Record<string, string> = {
162
- OE_GITHUB_APP_ID: id!,
163
- OE_GITHUB_APP_SLUG: slug!,
164
- OE_GITHUB_APP_PRIVATE_KEY: pem!,
165
- OE_GITHUB_APP_CLIENT_ID: clientId!,
185
+ GITHUB_APP_PRIVATE_KEY: pem!,
166
186
  GITHUB_WEBHOOK_SECRET: webhookSecret!,
167
187
  };
168
- if (opts.variant === "full") {
169
- env.GITHUB_APP_ID = id!;
170
- env.GITHUB_APP_SLUG = slug!;
171
- env.GITHUB_APP_PRIVATE_KEY = pem!;
172
- }
188
+ let botUserId: string | undefined;
173
189
  try {
174
190
  const bot = await opts.fetchFn(`${API}/users/${encodeURIComponent(`${slug}[bot]`)}`, { headers: HEADERS });
175
191
  const botId = bot.ok ? str(((await bot.json()) as { id?: unknown }).id) : undefined;
176
- if (botId !== undefined && /^\d+$/.test(botId)) env.OE_GITHUB_BOT_USER_ID = botId;
192
+ if (botId !== undefined && /^\d+$/.test(botId)) botUserId = botId;
177
193
  } catch {
178
194
  // Fail-soft, see above.
179
195
  }
180
- return { env, appName: str(body.name) ?? slug!, installUrl: `https://github.com/apps/${encodeURIComponent(slug!)}/installations/new` };
196
+ return {
197
+ env,
198
+ settings: githubAppSettings({ variant: opts.variant, id: id!, slug: slug!, clientId: clientId!, ...(botUserId === undefined ? {} : { botUserId }) }),
199
+ appName: str(body.name) ?? slug!,
200
+ installUrl: `https://github.com/apps/${encodeURIComponent(slug!)}/installations/new`,
201
+ };
181
202
  }
182
203
 
183
204
  const isVariant = (v: string): v is GithubAppVariant => GITHUB_APP_VARIANTS.some((x) => x.name === v);
@@ -204,7 +225,8 @@ export const GITHUB_APP_CREATE_FLOW: SurfaceAppCreateFlow = {
204
225
  // App's one `installation` delivery. Before the redeploy it does not hold the App's webhook secret, so
205
226
  // it refuses that delivery with a 401 and never learns the installation.
206
227
  after: [
207
- "Push each written name to the deployment's environment, piped from .env.local and never printed (the setup skill's deployment-env step), then redeploy.",
228
+ "Commit the returned settings to agent/settings/settings.ts as `github: { appId, appSlug, appClientId, botUserId, conversation }` (public values, safe in Git). Keep any other github key the file already sets.",
229
+ "Store each written name in the deployment's credential store: call the ops tool secret_set with names [\"GITHUB_APP_PRIVATE_KEY\", \"GITHUB_WEBHOOK_SECRET\"] (they are operator secrets, OPE-2650), then redeploy with the settings change.",
208
230
  "Only after that redeploy, install the app on the repositories the agent works in: open the install link. Installing first sends the app's installation delivery to a deployment that does not hold the new webhook secret yet, and it refuses that delivery.",
209
231
  "Optional: on the app's settings page, under Display information, upload the project mark as its logo. The manifest has no logo field.",
210
232
  ],
@@ -23,6 +23,7 @@
23
23
 
24
24
  import {
25
25
  catchUpSender,
26
+ getSettings,
26
27
  resolveSurfaceReadToken,
27
28
  type NormalizedMessage,
28
29
  type PrFetchFn,
@@ -88,12 +89,16 @@ export interface GitHubCatchUpDeps {
88
89
  readonly selfLogin?: string;
89
90
  }
90
91
 
92
+ // The GitHub App's comment author is `<app-slug>[bot]`; the deployment states its own slug in the
93
+ // `github.appSlug` setting (OPE-2650), and an unset one degrades to "our posts look like a bot's",
94
+ // which the admission policy still admits under `wide`. A getter, so each read sees the registered
95
+ // settings rather than the value at module load.
91
96
  const liveDeps: GitHubCatchUpDeps = {
92
97
  fetchFn: liveGithubReadFetch,
93
- // The GitHub App's comment author is `<app-slug>[bot]`; the deployment states its own slug, and an
94
- // unset one degrades to "our posts look like a bot's", which the admission policy still admits
95
- // under `wide`.
96
- selfLogin: process.env.GITHUB_APP_SLUG ? `${process.env.GITHUB_APP_SLUG}[bot]` : undefined,
98
+ get selfLogin(): string | undefined {
99
+ const slug = getSettings().github.appSlug;
100
+ return slug ? `${slug}[bot]` : undefined;
101
+ },
97
102
  };
98
103
 
99
104
  /** The fetch, with its seams exposed so the contract test drives it with no network. */
@@ -0,0 +1,41 @@
1
+ // WHERE THE GITHUB BUNDLE'S SECRETS COME FROM (OPE-2650) — the App's private key and the webhook
2
+ // signing secret.
3
+ //
4
+ // Both are OPERATOR SECRETS: `oe secret set GITHUB_APP_PRIVATE_KEY`, `oe secret set
5
+ // GITHUB_WEBHOOK_SECRET`. Core reads them: the db credential store first, the env var of the same name
6
+ // as its warned on-ramp, each store lookup remembered for thirty seconds. The private key has ONE name
7
+ // for the scm plane and this bundle (core's `githubAppPrivateKey`); OPE-2650 folded the
8
+ // OE_GITHUB_APP_PRIVATE_KEY spelling into it.
9
+ //
10
+ // 🔴 EVE MUST BE HANDED BOTH. eve's `githubChannel` resolves `credentials.privateKey` and
11
+ // `credentials.webhookSecret`, and with neither it reads the GITHUB_APP_PRIVATE_KEY and
12
+ // GITHUB_WEBHOOK_SECRET env vars itself, so it would never see a stored value: the native route would
13
+ // 401 every delivery on a deployment that holds the secrets in the store only.
14
+ // {@link githubEveCredentials} is that hand-over. Functions, not values: the read is async, and a value
15
+ // read at module load would pin whatever was true at boot.
16
+
17
+ import { githubAppPrivateKey, operatorSecretOrUnset } from "@open-everywhere/core/agent/adapter-api.js";
18
+
19
+ /** The operator secret holding the webhook HMAC secret. */
20
+ const GITHUB_WEBHOOK_SECRET_NAME = "GITHUB_WEBHOOK_SECRET";
21
+
22
+ /** The webhook signing secret, or `undefined` when this deployment holds none (a fault reads as none,
23
+ * logged once — the route then fails closed). */
24
+ export async function githubWebhookSecret(): Promise<string | undefined> {
25
+ return await operatorSecretOrUnset(GITHUB_WEBHOOK_SECRET_NAME);
26
+ }
27
+
28
+ /** The `credentials` block both `githubChannel(...)` instances carry. Each arm throws eve's OWN message
29
+ * when nothing resolves, byte for byte. */
30
+ export const githubEveCredentials = {
31
+ privateKey: async (): Promise<string> => {
32
+ const key = await githubAppPrivateKey();
33
+ if (!key) throw new Error("githubChannel: GITHUB_APP_PRIVATE_KEY is required.");
34
+ return key;
35
+ },
36
+ webhookSecret: async (): Promise<string> => {
37
+ const secret = await githubWebhookSecret();
38
+ if (!secret) throw new Error("githubChannel: GITHUB_WEBHOOK_SECRET is required.");
39
+ return secret;
40
+ },
41
+ } as const;
@@ -286,7 +286,7 @@ async function liveMarkEngageSeen(org: string, key: string, atIso: string): Prom
286
286
  * Is this comment MACHINE-AUTHORED — a GitHub App, a bot account, or this deployment's own reply?
287
287
  *
288
288
  * 🔴 ONE PREDICATE, AND IT MUST FAIL CLOSED ON AN UNRESOLVED SLUG. `githubBotMentioned` needs the
289
- * app slug to recognise a mention at all, so before this seam a deployment with no `GITHUB_APP_SLUG`
289
+ * app slug to recognise a mention at all, so before this seam a deployment with no `github.appSlug`
290
290
  * simply never woke natively. A plain comment now reaches the seam, and without the slug the app
291
291
  * cannot tell its OWN reply from a person's — so "unknown" answers `true` and nothing is delivered,
292
292
  * the same posture `classifyGitHubAmbientComment` takes with its own no-slug drop.
@@ -6,10 +6,15 @@
6
6
  // dev-only `webhooks.allowUnverifiedGithub` hatch on. With neither, it 401s every delivery and no GitHub
7
7
  // routine can fire. This probe asks the same two questions the gate asks, and reads the secret's PRESENCE only.
8
8
 
9
+ // The secret is an operator secret since OPE-2650, so the presence read is async and asks the store
10
+ // first, the env var as its on-ramp — the same read the gate makes.
11
+
9
12
  import { getSettings } from "@open-everywhere/core/agent/adapter-api.js";
13
+ import { githubWebhookSecret } from "./github-credentials.js";
10
14
 
11
- /** True when the native GitHub route can admit a delivery into the routine fan-out. */
12
- export function githubEventsReach(env: NodeJS.ProcessEnv = process.env): boolean {
13
- const secret = env.GITHUB_WEBHOOK_SECRET;
14
- return (secret !== undefined && secret.length > 0) || getSettings().webhooks.allowUnverifiedGithub;
15
+ /** True when the native GitHub route can admit a delivery into the routine fan-out. `secret` is a test
16
+ * seam; the live default is the gate's own read. */
17
+ export async function githubEventsReach(secret: () => Promise<string | undefined> = githubWebhookSecret): Promise<boolean> {
18
+ const value = await secret();
19
+ return (value !== undefined && value.length > 0) || getSettings().webhooks.allowUnverifiedGithub;
15
20
  }
@@ -21,7 +21,9 @@ import {
21
21
  mintInstallationToken,
22
22
  CONVERSATION_WRITE_PERMISSIONS,
23
23
  loadGitHubAppConfig,
24
+ githubAppPrivateKey,
24
25
  githubAuthMode,
26
+ getSettings,
25
27
  resolveGitHubInstallationId,
26
28
  warmCredential,
27
29
  markNotApplied,
@@ -108,15 +110,16 @@ export function _resetGitHubInstallationTokenCache(): void {
108
110
  installationToken.reset();
109
111
  }
110
112
 
111
- /** GitHub App credentials for the unified reply arm: the scm plane's env pair preferred, the eve
112
- * channel's pair as fallback (same App in a normal deploy — two spellings, one identity). */
113
- function githubAppCreds(): { appId: string; privateKeyPem: string; installations: Record<string, string> } | null {
113
+ /** GitHub App credentials for the unified reply arm. The App id is the `github.appId` setting (the scm
114
+ * plane's client id preferred as the JWT issuer when set), and the private key has ONE home since
115
+ * OPE-2650: the operator secret GITHUB_APP_PRIVATE_KEY, for both the scm plane and this surface. */
116
+ async function githubAppCreds(): Promise<{ appId: string; privateKeyPem: string; installations: Record<string, string> } | null> {
114
117
  const scm = loadGitHubAppConfig();
115
- if (scm) return { appId: scm.clientId ?? scm.appId, privateKeyPem: scm.privateKeyPem, installations: scm.installations };
116
- const appId = process.env.GITHUB_APP_ID;
117
- const privateKeyPem = process.env.GITHUB_APP_PRIVATE_KEY;
118
- if (!appId || !privateKeyPem) return null;
119
- return { appId, privateKeyPem, installations: {} };
118
+ const appId = scm ? (scm.clientId ?? scm.appId) : getSettings().github.appId;
119
+ if (!appId) return null;
120
+ const privateKeyPem = await githubAppPrivateKey();
121
+ if (!privateKeyPem) return null;
122
+ return { appId, privateKeyPem, installations: scm?.installations ?? {} };
120
123
  }
121
124
 
122
125
  /** Exported for the reaction-feedback poll (channels/github-reaction-feedback.ts, OPE-1502), which
@@ -127,19 +130,19 @@ export async function installationTokenFor(origin: GitHubOrigin): Promise<string
127
130
  if (githubAuthMode() === "token") {
128
131
  throw markNotApplied(new Error("reply couldn't post to GitHub — this deployment signs in with a personal access token, and the GitHub conversation surface needs the GitHub App."));
129
132
  }
130
- const creds = githubAppCreds();
133
+ const creds = await githubAppCreds();
131
134
  if (!creds) {
132
135
  // DEFINITIVE (OPE-825): no App credentials ⇒ no request ever left this host.
133
- throw markNotApplied(new Error("reply couldn't post to GitHub — no App credentials (set OE_GITHUB_APP_* or GITHUB_APP_ID/GITHUB_APP_PRIVATE_KEY)."));
136
+ throw markNotApplied(new Error("reply couldn't post to GitHub — no App credentials (set the github.appId setting, and store the operator secret GITHUB_APP_PRIVATE_KEY)."));
134
137
  }
135
138
  // The wake's own (webhook-supplied) installation id first; then the SCM plane's composed map —
136
- // OE_GITHUB_INSTALLATIONS authoritative, `installation`-webhook-discovered installs augmenting it
139
+ // the `github.installations` setting authoritative, `installation`-webhook-discovered installs augmenting it
137
140
  // (scm/installations.ts), so an org that self-installed resolves without an env edit.
138
141
  const installationId = origin.installationId !== undefined ? String(origin.installationId) : await resolveGitHubInstallationId(origin.owner, creds.installations);
139
142
  if (!installationId) {
140
143
  // DEFINITIVE (OPE-825): unresolved before any comment request is built — nothing left this host.
141
144
  throw markNotApplied(new Error(
142
- `reply couldn't post to GitHub — no installation id for owner "${origin.owner}" (the wake carried none, OE_GITHUB_INSTALLATIONS has no entry, and no installation webhook has registered one).`,
145
+ `reply couldn't post to GitHub — no installation id for owner "${origin.owner}" (the wake carried none, the github.installations setting has no entry, and no installation webhook has registered one).`,
143
146
  ));
144
147
  }
145
148
  // Key by (installation, repo): the minted token is scoped to exactly [origin.repo], so a sibling
@@ -87,7 +87,7 @@
87
87
  //
88
88
  // ── IDENTITY
89
89
  //
90
- // "The agent's own comment" is `<GITHUB_APP_SLUG>[bot]` — the login GitHub gives an App's comments,
90
+ // "The agent's own comment" is `<github.appSlug>[bot]` — the login GitHub gives an App's comments,
91
91
  // and the same self-identity channels/github-supervisor.ts drops its own replies by. 🔴 IT FAILS
92
92
  // CLOSED: with no slug configured the arm polls nothing at all, because it cannot tell the agent's
93
93
  // comments from anybody else's and reading every comment's reactions would record strangers' reactions
@@ -421,7 +421,7 @@ function tokenFor(target: GitHubReactionPollTarget): Promise<string> {
421
421
  });
422
422
  }
423
423
 
424
- /** Production deps for the poll. `botLogin` is `<GITHUB_APP_SLUG>[bot]` — absent when the slug is
424
+ /** Production deps for the poll. `botLogin` is `<github.appSlug>[bot]` — absent when the slug is
425
425
  * unset, which is exactly the fail-closed case {@link routeGitHubReactionFeedback} refuses on. */
426
426
  export function liveGitHubReactionFeedbackDeps(appSlug: string | undefined, fetchFn: PrFetchFn = liveGithubReadFetch): GitHubReactionFeedbackDeps {
427
427
  const slug = appSlug?.trim();
@@ -69,7 +69,7 @@
69
69
  // 4. The INSTALLATION leg (installationDelivery below): the App's `installation` /
70
70
  // `installation_repositories` events register (or revoke) the owner→installation_id entry the
71
71
  // SCM plane mints tokens with, so a new org installing the App is self-service end-to-end
72
- // instead of needing an OE_GITHUB_INSTALLATIONS env edit + redeploy. The env map stays
72
+ // instead of needing a `github.installations` settings edit + redeploy. The settings map stays
73
73
  // AUTHORITATIVE; discovered installs only augment it (scm/installations.ts).
74
74
  //
75
75
  // Point the GitHub App webhook at POST /eve/v1/github (content-type application/json),
@@ -175,9 +175,52 @@ import {
175
175
  dispatchNativeWakeWithRetry,
176
176
  } from "@open-everywhere/core/agent/adapter-api.js";
177
177
  import { githubAuthMode, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
178
+ import { githubEveCredentials, githubWebhookSecret } from "./github-credentials.js";
178
179
 
179
180
  const ROUTE = "/eve/v1/github";
180
- const BOT_NAME = process.env.GITHUB_APP_SLUG;
181
+
182
+ /**
183
+ * The name the conversation surface answers to: the `github.appSlug` setting (was `GITHUB_APP_SLUG`,
184
+ * OPE-2650), or `undefined`. A function, read per call: a module-level read would run before the app
185
+ * registers its settings. `undefined` keeps the fail-closed loop guard — every ambient comment drops,
186
+ * and no @mention wakes.
187
+ *
188
+ * 🔴 THE SURFACE ARMS ON ITS OWN SETTING, `github.conversation` (OPE-2650). The source-control plane and
189
+ * this surface share ONE App id, ONE slug and ONE private key (the operator secret
190
+ * GITHUB_APP_PRIVATE_KEY). None of them says whether this App should start conversations: a
191
+ * `source-control` App holds all three too. So the arm is a reviewed line in Git. Off (shipped), a
192
+ * `source-control` App, or a token-mode deployment that moved to one and kept its repository webhook,
193
+ * starts no conversation from a comment. `surface_app_create` returns `github.conversation: true` for
194
+ * the `full` App only.
195
+ */
196
+ export function githubConversationBotName(): string | undefined {
197
+ const github = getSettings().github;
198
+ if (!github.conversation) return undefined;
199
+ return github.appSlug || undefined;
200
+ }
201
+ const botName = githubConversationBotName;
202
+
203
+ /**
204
+ * Which App eve's channel speaks as, for BOTH githubChannel instances below (OPE-2650). The slug and
205
+ * the App id come from settings, and the private key and the webhook secret come from the operator
206
+ * secret store (`githubEveCredentials`, ./github-credentials.ts). All are lazy fields.
207
+ *
208
+ * 🔴 A FIELD LEFT UNDEFINED FALLS BACK TO ENV INSIDE EVE (`GITHUB_APP_SLUG`, `GITHUB_APP_ID`,
209
+ * `GITHUB_APP_PRIVATE_KEY`, `GITHUB_WEBHOOK_SECRET`), so every one is always passed. eve caches a
210
+ * resolved slug per instance and treats "" as unset. The App id resolver throws when the setting is
211
+ * unset, which is eve's own "is required" refusal.
212
+ */
213
+ export const GITHUB_CHANNEL_APP = {
214
+ botName: (): string => botName() ?? "",
215
+ credentials: {
216
+ ...githubEveCredentials,
217
+ appId: (): string => {
218
+ const id = getSettings().github.appId;
219
+ if (id === "") throw new Error("githubChannel: the github.appId setting is required.");
220
+ return id;
221
+ },
222
+ },
223
+ } as const;
181
224
 
182
225
  function log(msg: string): void {
183
226
  console.log(`[open-everywhere:routines] ${msg}`);
@@ -679,7 +722,7 @@ async function ambientDelivery(body: string, eventType: string, send: GitHubSend
679
722
  // WINDOW-ADMISSION FLOOR = the operator VOUCH (identity/trust.ts — the SAME allowlist that gates
680
723
  // the shared memory), NOT the repo-private flag. `ev.repoPrivate` is now a logged SIGNAL only.
681
724
  const vouched = githubRepoVouched(ev.owner, ev.repo);
682
- const cls = classifyGitHubAmbientComment(ev.comment, BOT_NAME, { treatMentionAsDirect: singleWindow, vouched });
725
+ const cls = classifyGitHubAmbientComment(ev.comment, botName(), { treatMentionAsDirect: singleWindow, vouched });
683
726
  if (cls.addressing === "drop") {
684
727
  slog(`comment on ${surfaceKey}#${ev.issueNumber} dropped: ${cls.reason} (private=${ev.repoPrivate === true}, vouched=${vouched})`);
685
728
  return;
@@ -906,15 +949,16 @@ async function installationDelivery(body: string, eventType: string): Promise<vo
906
949
 
907
950
  // --- supervisor-aware events (delegating every non-supervisor turn to eve's defaults) --
908
951
 
909
- // A PRISTINE second githubChannel instance (same config axes that shape the default events:
910
- // none — api/credentials/progress all unset, matching the real channel below) is the source of
952
+ // A PRISTINE second githubChannel instance (same config axes that shape the default events: the
953
+ // shared GITHUB_CHANNEL_APP slug, App id and credentials, and api/progress unset, matching the real channel
954
+ // below) is the source of
911
955
  // eve's DEFAULT handlers. defineChannel compiles authored events into `adapter[<event>]`
912
956
  // entries of shape `(data, adapterCtx) => authored(data, enriched(adapterCtx), callbackCtx)`;
913
957
  // calling one from inside OUR authored handler (same ALS step scope) re-runs the enrichment
914
958
  // harmlessly and rebuilds the callback context — i.e. the default runs EXACTLY as if we had
915
959
  // never authored the key. This is how the #623 destructive-replacement axis is used safely:
916
960
  // the mention path's checkout/reply/error behaviour is eve's own code, not a replica.
917
- const pristine = githubChannel({ botName: BOT_NAME });
961
+ const pristine = githubChannel({ ...GITHUB_CHANNEL_APP });
918
962
  type AdapterEventFn = (data: unknown, adapterCtx: unknown) => unknown;
919
963
  const defaultEvent = (name: string): AdapterEventFn | undefined =>
920
964
  (pristine as unknown as { adapter?: Record<string, AdapterEventFn> }).adapter?.[name];
@@ -991,13 +1035,13 @@ function nativeGitHubAuditPartition(state: GitHubChannelState): ChatAuditPartiti
991
1035
  // can (a) keep the SAME mention semantics (githubBotMentioned mirrors eve's gate) and
992
1036
  // (b) enrich the dispatched auth with the repo owner/name/privacy + actor association the
993
1037
  // identity mapper needs — eve's own defaultGitHubAuth omits repo.private, which would peg
994
- // every surface to fail-closed private/external. botName + credentials fall back to env
995
- // (GITHUB_APP_SLUG / GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY / GITHUB_WEBHOOK_SECRET).
1038
+ // every surface to fail-closed private/external. botName and the App id come from settings,
1039
+ // and the private key and the webhook secret from the operator secret store (GITHUB_CHANNEL_APP).
996
1040
  // Exported for the provider integration test (drives the REAL ingress handler directly with a
997
1041
  // seeded over-budget scope). The channel below wires it as `onComment`.
998
1042
  export const githubOnComment = async (ctx: GitHubInboundContext, comment: GitHubComment): Promise<GitHubInboundResult> => {
999
1043
  // Same wake rule as eve's built-in gate (that helper isn't publicly exported).
1000
- const isMention = githubBotMentioned(comment.body, BOT_NAME, comment.author);
1044
+ const isMention = githubBotMentioned(comment.body, botName(), comment.author);
1001
1045
  // 🔴 THE §2.5 ENGAGED SEAM (OPE-1501) IS THE SECOND DOOR OF THIS GATE. Before it, a comment that
1002
1046
  // was not an @mention returned here and the native leg answered exactly once per mention. Now a
1003
1047
  // PLAIN comment in a container an @mention already ENGAGED is admitted too, and the seam's own
@@ -1016,7 +1060,7 @@ export const githubOnComment = async (ctx: GitHubInboundContext, comment: GitHub
1016
1060
  // The author gate, in the seam's own vocabulary. `githubBotMentioned` already refuses a
1017
1061
  // self-authored mention; a PLAIN comment reaches the seam with no such check, so an engaged
1018
1062
  // container would otherwise admit the app's own reply and loop on itself.
1019
- authorIsBot: githubMachineAuthored(comment.author?.login ?? ctx.sender.login, comment.author?.type ?? ctx.sender.type, BOT_NAME),
1063
+ authorIsBot: githubMachineAuthored(comment.author?.login ?? ctx.sender.login, comment.author?.type ?? ctx.sender.type, botName()),
1020
1064
  // 🔴 THE REACH FACT (see `githubEngagedFollowUpReach`): an INLINE review comment shares its
1021
1065
  // pull request's anchor, so the non-mention arm refuses one. A mention inside a review thread
1022
1066
  // still wakes — it never reaches that arm.
@@ -1166,7 +1210,7 @@ export const githubOnComment = async (ctx: GitHubInboundContext, comment: GitHub
1166
1210
  };
1167
1211
 
1168
1212
  const native: GitHubChannel = githubChannel({
1169
- botName: BOT_NAME,
1213
+ ...GITHUB_CHANNEL_APP,
1170
1214
  // 🔴 QUEUE, NEVER STEER (OPE-412) — eve's default `steer` cancels the turn in flight when a
1171
1215
  // message arrives. Same reasoning as the Slack channel; see slack.ts for the full note.
1172
1216
  turnPolicy: "queue",
@@ -1378,7 +1422,7 @@ const native: GitHubChannel = githubChannel({
1378
1422
  // delegate the request UNCHANGED to the native handler for the @mention/session behaviour.
1379
1423
  // The request body can only be read once, so we buffer it and hand the native handler a fresh
1380
1424
  // Request carrying the same body + headers (it re-verifies the signature itself over that
1381
- // body — same GITHUB_WEBHOOK_SECRET).
1425
+ // body — the same GITHUB_WEBHOOK_SECRET operator secret, handed to eve as `githubEveCredentials`).
1382
1426
  type HttpHandler = HttpRouteDefinition<GitHubChannelState>["handler"];
1383
1427
  function wrapRoute(inner: HttpHandler): HttpHandler {
1384
1428
  return async (req, args) => {
@@ -1387,7 +1431,7 @@ function wrapRoute(inner: HttpHandler): HttpHandler {
1387
1431
  // unauthenticated delivery is rejected for the whole route (the native handler would
1388
1432
  // 401 it too). The dev escape hatch keeps the fan-out testable without a secret.
1389
1433
  const rejection = gateGithubDelivery(
1390
- process.env.GITHUB_WEBHOOK_SECRET,
1434
+ await githubWebhookSecret(),
1391
1435
  body,
1392
1436
  req.headers.get("x-hub-signature-256"),
1393
1437
  getSettings().webhooks.allowUnverifiedGithub,
@@ -1441,7 +1485,7 @@ function wrapRoute(inner: HttpHandler): HttpHandler {
1441
1485
  const pollTarget = parseGitHubReactionPollTarget(body, eventType);
1442
1486
  if (pollTarget) {
1443
1487
  args.waitUntil(
1444
- routeGitHubReactionFeedback(pollTarget, liveGitHubReactionFeedbackDeps(BOT_NAME)).then(
1488
+ routeGitHubReactionFeedback(pollTarget, liveGitHubReactionFeedbackDeps(botName())).then(
1445
1489
  (outcomes) => {
1446
1490
  if (outcomes.length > 0) slog(`reaction feedback on ${pollTarget.owner}/${pollTarget.repo}#${pollTarget.number}: ${outcomes.length} recorded`);
1447
1491
  },
@@ -23,8 +23,9 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
23
23
  "",
24
24
  "This is the GitHub *conversational-surface* runbook. It is **distinct from the",
25
25
  "[source-control plane](../../capabilities/source-control.md)** (the agent's own coding loop —",
26
- "checkout/push/PR via the `OE_GITHUB_*` credentials): the two are orthogonal planes that can",
27
- "share one GitHub App but use **separate env var sets** (bare `GITHUB_*` here). For the concept",
26
+ "checkout/push/PR): the two are orthogonal planes, and since OPE-2650 they are ONE GitHub App. They",
27
+ "read the App's ids from the same `github.*` settings and sign with the one operator secret",
28
+ "`GITHUB_APP_PRIVATE_KEY`. The `github.conversation` setting arms this surface. For the concept",
28
29
  "of a *surface*, see [Surfaces](../../extending/surfaces.md). Like",
29
30
  "[Linear](./linear.md)/[Notion](./notion.md), GitHub is ticket/comment-based rather than",
30
31
  "chat-based, so it's out of scope for the chat [support matrix](./support-matrix.md).",
@@ -175,9 +176,9 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
175
176
  " `https://<your-deploy-url>/eve/v1/github`, a generated webhook secret, and the permissions and",
176
177
  " events below. Check the name and confirm. GitHub App names are unique across GitHub, so change",
177
178
  " the name there if GitHub says it is taken. GitHub then sends the App's id, private key and",
178
- " webhook secret back to the local page, which writes them into `.env.local`. You copy no key, and",
179
- " no value passes through the chat. `surface_app_create { \"surface\": \"github\", \"action\": \"status\" }`",
180
- " then lists the names written and the App's install link.",
179
+ " webhook secret back to the local page. The page writes only the secrets into `.env.local`. You",
180
+ " copy no key, and no secret passes through the chat. `surface_app_create { \"surface\": \"github\", \"action\": \"status\" }`",
181
+ " then lists the names written, the App's install link, and the App's public ids as `settings`.",
181
182
  "",
182
183
  " - `owner: \"<org login>\"` creates the App under an organization. Without it, the App belongs to",
183
184
  " your own account.",
@@ -225,20 +226,38 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
225
226
  " **By hand.** Without the ops server, create the App at GitHub → **Settings → Developer settings",
226
227
  " → GitHub Apps → New GitHub App**. Point the webhook at `https://<your-deploy-url>/eve/v1/github`,",
227
228
  " set a webhook secret, grant the permissions in the table and subscribe to its events. Then set the",
228
- " environment in step 2 yourself.",
229
+ " settings and the environment in step 2 yourself.",
229
230
  "",
230
231
  " On the App's settings page, under **Display information**, upload [`brand/png/mark-square-1024.png`](../../../brand/png/mark-square-1024.png), the project mark, as its logo. The manifest has no logo field. Skip this when you set [`identity.name`](../configuration-reference.md) and chose your own icon: with no name configured the agent presents as the project, so the project mark is its default avatar.",
231
- "2. **Configure the environment.** The full App writes every name below into `.env.local`, plus",
232
- " the `OE_GITHUB_*` source-control set (`OE_GITHUB_APP_ID`, `OE_GITHUB_APP_SLUG`,",
233
- " `OE_GITHUB_APP_PRIVATE_KEY`, `OE_GITHUB_APP_CLIENT_ID`) for the same App. The source-control",
234
- " App writes only the `OE_GITHUB_*` set and `GITHUB_WEBHOOK_SECRET`. `OE_GITHUB_BOT_USER_ID` is",
235
- " written only when GitHub's bot-user lookup answers. Push the names that `status` lists in",
236
- " `written`, piped from the file and never printed, then redeploy. By hand, set them yourself. The conversational surface uses the **bare** names — the",
237
- " `OE_GITHUB_*` set is the separate source-control plane, even when both point at one App:",
232
+ "2. **Commit the settings and store the secrets.** The App's public values are settings in Git",
233
+ " (OPE-2650). The `status` reply returns them as `settings`: `github.appId`, `github.appSlug`,",
234
+ " `github.appClientId`, `github.conversation` and, when GitHub's bot-user lookup answers,",
235
+ " `github.botUserId`. Commit them to your app's `agent/settings/settings.ts`. One App serves both",
236
+ " planes, so the source-control plane and this conversational surface read the same settings.",
237
+ " `github.conversation` arms this surface: the full App returns `true`, and a source-control App",
238
+ " returns `false`, so its comments start no conversation:",
239
+ "",
240
+ " ```ts",
241
+ " // agent/settings/settings.ts",
242
+ " export const SETTINGS_OVERRIDES = defineSettings({",
243
+ " github: {",
244
+ " appId: \"123456\", // the App's numeric id",
245
+ " appSlug: \"acme-agent\", // the App slug — the bot answers to @<slug>",
246
+ " appClientId: \"Iv1.…\", // the App's public OAuth client id",
247
+ " botUserId: \"987654\", // the bot user's id, for commit attribution",
248
+ " conversation: true, // the full App: @mentions and comments start sessions",
249
+ " },",
250
+ " });",
251
+ " ```",
252
+ "",
253
+ " **The private key and the webhook secret are operator secrets** (OPE-2650). The tool writes both",
254
+ " into `.env.local`. Store them with the ops tool `secret_set { names: [\"GITHUB_APP_PRIVATE_KEY\",",
255
+ " \"GITHUB_WEBHOOK_SECRET\"] }`, or `npx oe secret set <NAME>` by hand, then redeploy. One",
256
+ " private-key name serves both planes: OPE-2650 folded `OE_GITHUB_APP_PRIVATE_KEY` into",
257
+ " `GITHUB_APP_PRIVATE_KEY`.",
238
258
  "",
239
259
  " ```bash",
240
- " GITHUB_APP_ID=… # the App's numeric id",
241
- " GITHUB_APP_SLUG=… # the App slug — the bot answers to @<slug>",
260
+ " # Operator secrets: `npx oe secret set <NAME>`, value on stdin. An env var is only the on-ramp.",
242
261
  " GITHUB_APP_PRIVATE_KEY=… # PEM private key (mint installation tokens)",
243
262
  " GITHUB_WEBHOOK_SECRET=… # HMAC-verifies every delivery. REQUIRED — unset rejects all (fail closed)",
244
263
  " # DEV ONLY, and it is a SETTING not a variable: turn on `webhooks.allowUnverifiedGithub`",
@@ -258,9 +277,9 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
258
277
  "## Multi-org installs (self-registering)",
259
278
  "",
260
279
  "The source-control plane mints tokens per **owner**, so it needs an `owner → installation id`",
261
- "entry for every account the App is installed on. You can set that by hand with",
262
- "`OE_GITHUB_INSTALLATIONS` (a JSON map) or the single-owner `OE_GITHUB_OWNER` /",
263
- "`OE_GITHUB_INSTALLATION_ID` pair — and you no longer have to.",
280
+ "entry for every account the App is installed on. You can set that by hand with the",
281
+ "`github.installations` setting (an object such as `{ acme: \"4242\" }`, ids as numeric strings) —",
282
+ "and you no longer have to.",
264
283
  "",
265
284
  "Because GitHub delivers `installation` + `installation_repositories` to every App by default",
266
285
  "(no subscription exists to switch on), each install/uninstall registers",
@@ -268,11 +287,11 @@ export const PROVIDER_DOCS: readonly DocEntry[] = [
268
287
  "and a repo-scope change refreshes it. An org installing your App is then self-service end to end —",
269
288
  "no env edit, no redeploy.",
270
289
  "",
271
- "**`OE_GITHUB_INSTALLATIONS` stays authoritative.** The discovered entries are composed *under* the",
272
- "env map, so anything you pinned by hand always wins, a store outage degrades to exactly the",
273
- "env-configured set, and a webhook can never re-point an owner you configured yourself. (By the same",
274
- "rule, uninstalling an org you *also* pinned in env leaves the env entry in place — its mints then",
275
- "fail loudly at GitHub, which is the honest signal; remove it from the env map to finish the removal.)",
290
+ "**`github.installations` stays authoritative.** The discovered entries are composed *under* the",
291
+ "setting, so anything you pinned by hand always wins. A store outage degrades to exactly the",
292
+ "configured set, and a webhook can never re-point an owner you configured yourself. (By the same",
293
+ "rule, uninstalling an org you *also* pinned in the setting leaves its entry in place. Its mints then",
294
+ "fail loudly at GitHub, which is the honest signal. Remove it from the setting to finish the removal.)",
276
295
  "",
277
296
  "Install and uninstall both write an audit row — `surface.app_installed` / `surface.app_uninstalled`,",
278
297
  "with the owner as the workspace — so the install history is queryable over the control plane's",