@open-everywhere/provider-github 0.7.0 → 0.8.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.
Files changed (56) hide show
  1. package/agent/app-manifest.ts +237 -0
  2. package/agent/channels/github-catch-up.ts +2 -2
  3. package/agent/channels/github-event-reach.ts +15 -0
  4. package/agent/channels/github-installations.ts +4 -2
  5. package/agent/channels/github-post-arm.ts +6 -0
  6. package/agent/channels/github-reaction-scope.ts +1 -1
  7. package/agent/channels/github-read.ts +11 -8
  8. package/agent/channels/github-supervisor.ts +2 -1
  9. package/agent/channels/github.ts +13 -2
  10. package/agent/docs-generated.ts +392 -2
  11. package/agent/ops.ts +7 -2
  12. package/agent/register.ts +6 -0
  13. package/agent/surface-parity.ts +1 -0
  14. package/dist/agent/app-manifest.d.ts +41 -0
  15. package/dist/agent/app-manifest.d.ts.map +1 -0
  16. package/dist/agent/app-manifest.js +197 -0
  17. package/dist/agent/app-manifest.js.map +1 -0
  18. package/dist/agent/channels/github-catch-up.js +2 -2
  19. package/dist/agent/channels/github-catch-up.js.map +1 -1
  20. package/dist/agent/channels/github-event-reach.d.ts +3 -0
  21. package/dist/agent/channels/github-event-reach.d.ts.map +1 -0
  22. package/dist/agent/channels/github-event-reach.js +14 -0
  23. package/dist/agent/channels/github-event-reach.js.map +1 -0
  24. package/dist/agent/channels/github-installations.d.ts +2 -1
  25. package/dist/agent/channels/github-installations.d.ts.map +1 -1
  26. package/dist/agent/channels/github-installations.js.map +1 -1
  27. package/dist/agent/channels/github-post-arm.d.ts.map +1 -1
  28. package/dist/agent/channels/github-post-arm.js +6 -1
  29. package/dist/agent/channels/github-post-arm.js.map +1 -1
  30. package/dist/agent/channels/github-reaction-scope.d.ts +0 -7
  31. package/dist/agent/channels/github-reaction-scope.d.ts.map +1 -1
  32. package/dist/agent/channels/github-reaction-scope.js +1 -1
  33. package/dist/agent/channels/github-reaction-scope.js.map +1 -1
  34. package/dist/agent/channels/github-read.d.ts.map +1 -1
  35. package/dist/agent/channels/github-read.js +11 -8
  36. package/dist/agent/channels/github-read.js.map +1 -1
  37. package/dist/agent/channels/github-supervisor.d.ts +2 -1
  38. package/dist/agent/channels/github-supervisor.d.ts.map +1 -1
  39. package/dist/agent/channels/github-supervisor.js.map +1 -1
  40. package/dist/agent/channels/github.d.ts.map +1 -1
  41. package/dist/agent/channels/github.js +13 -2
  42. package/dist/agent/channels/github.js.map +1 -1
  43. package/dist/agent/docs-generated.d.ts.map +1 -1
  44. package/dist/agent/docs-generated.js +392 -2
  45. package/dist/agent/docs-generated.js.map +1 -1
  46. package/dist/agent/ops.d.ts +1 -1
  47. package/dist/agent/ops.d.ts.map +1 -1
  48. package/dist/agent/ops.js +7 -2
  49. package/dist/agent/ops.js.map +1 -1
  50. package/dist/agent/register.d.ts.map +1 -1
  51. package/dist/agent/register.js +6 -1
  52. package/dist/agent/register.js.map +1 -1
  53. package/dist/agent/surface-parity.d.ts.map +1 -1
  54. package/dist/agent/surface-parity.js +1 -0
  55. package/dist/agent/surface-parity.js.map +1 -1
  56. package/package.json +11 -2
@@ -0,0 +1,237 @@
1
+ // THE GITHUB APP MANIFEST (OPE-2529) — one-click GitHub App creation.
2
+ //
3
+ // Creating a GitHub App by hand means picking permissions, events and a webhook URL, then copying
4
+ // a key and a secret back. GitHub's App MANIFEST FLOW does all of it from one document: the person
5
+ // confirms a pre-filled App on github.com, and GitHub redirects with a one-time code that one API
6
+ // call turns into the App's id, private key and webhook secret
7
+ // (https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest).
8
+ //
9
+ // 🔴 THE DOCUMENT IS DERIVED, NEVER HAND-KEPT. Two lists decide what the App needs, and both are
10
+ // code this repository already runs on:
11
+ // • the PERMISSIONS are the union of every job set the source-control plane mints
12
+ // (core `scmAppPermissionFloor`, over scm/mint.ts), plus the read permission each subscribed
13
+ // event needs. A token can only narrow below the App, so a family missing here is a 403 later.
14
+ // • the EVENTS are this bundle's own event inventory (provider-events.ts), minus the rows no
15
+ // subscription can fire (`routing: "none"`). GitHub refuses a manifest whose events lack their
16
+ // permission, so EVENT_PERMISSION below must name a row for every event the inventory routes.
17
+ // The doc's permission table (docs/deploy/surfaces/github.md) is rendered from the same builder by
18
+ // scripts/github-app-manifest-docs.ts, and `lint:github-app-manifest-docs` fails when they differ.
19
+ //
20
+ // The core half (the local page, the state check, the env write) is ops/surface-app-create.ts.
21
+
22
+ import {
23
+ agentDisplayName,
24
+ scmAppPermissionFloor,
25
+ unionPermissions,
26
+ SCM_MINT_JOB_SETS,
27
+ type PermissionGrade,
28
+ type SurfaceAppCreateFlow,
29
+ type SurfaceAppCreateResult,
30
+ } from "@open-everywhere/core/agent/adapter-api-ops.js";
31
+ import { GITHUB_PROVIDER_EVENTS } from "./provider-events.js";
32
+
33
+ /** GitHub's longest App name. */
34
+ export const GITHUB_APP_NAME_MAX = 34;
35
+
36
+ /** The webhook route every GitHub delivery lands on (channels/github.ts). */
37
+ const GITHUB_WEBHOOK_PATH = "/eve/v1/github";
38
+
39
+ /**
40
+ * The repository permission each event needs before GitHub lets an App subscribe to it, from
41
+ * GitHub's "Webhook events and payloads" page (each event names its "Availability" permission).
42
+ * Read is enough for a delivery: the job sets above carry the write grades.
43
+ */
44
+ export const EVENT_PERMISSION: Readonly<Record<string, string>> = {
45
+ issues: "issues",
46
+ issue_comment: "issues",
47
+ pull_request: "pull_requests",
48
+ pull_request_review: "pull_requests",
49
+ pull_request_review_comment: "pull_requests",
50
+ push: "contents",
51
+ release: "contents",
52
+ check_run: "checks",
53
+ check_suite: "checks",
54
+ workflow_run: "actions",
55
+ workflow_job: "actions",
56
+ };
57
+
58
+ /** The two shapes of App. The full App is the default. */
59
+ export type GithubAppVariant = "full" | "source-control";
60
+
61
+ const GITHUB_APP_VARIANTS: readonly { readonly name: GithubAppVariant; readonly summary: string }[] = [
62
+ {
63
+ name: "full",
64
+ summary: "source control plus the GitHub conversational surface: the agent answers @mentions on issues and pull requests, and routines fire on repository events",
65
+ },
66
+ {
67
+ name: "source-control",
68
+ summary: "source control only: checkout, pull requests and ticket actions, with no @mention replies and no event subscriptions",
69
+ },
70
+ ];
71
+
72
+ /** The events the App subscribes to: every inventory row a routine or a mention can act on. */
73
+ export function githubAppEvents(variant: GithubAppVariant): string[] {
74
+ if (variant === "source-control") return [];
75
+ return GITHUB_PROVIDER_EVENTS.events
76
+ .filter((e) => e.routing !== "none" && EVENT_PERMISSION[e.event] !== undefined)
77
+ .map((e) => e.event)
78
+ .sort();
79
+ }
80
+
81
+ /** The App's permissions: the mint floor, plus read on every family a subscribed event needs. */
82
+ export function githubAppPermissions(variant: GithubAppVariant): Record<string, PermissionGrade> {
83
+ const eventFamilies = Object.fromEntries(githubAppEvents(variant).map((e) => [EVENT_PERMISSION[e]!, "read"]));
84
+ return unionPermissions([scmAppPermissionFloor(), eventFamilies]);
85
+ }
86
+
87
+ /** The manifest GitHub reads. `redirectUrl` is the local catch page core opens. */
88
+ export function githubAppManifest(opts: {
89
+ readonly host: string;
90
+ readonly variant: GithubAppVariant;
91
+ readonly redirectUrl: string;
92
+ readonly appName?: string;
93
+ }): Record<string, unknown> {
94
+ // The configured `identity.name`, else the shipped display name (identity/bot-name.ts).
95
+ const name = opts.appName ?? agentDisplayName();
96
+ return {
97
+ name: name.slice(0, GITHUB_APP_NAME_MAX),
98
+ url: `https://${opts.host}`,
99
+ description: `${name}, an agent that works in this organization's repositories.`,
100
+ // The webhook is ON in both shapes: GitHub sends `installation` to every App with an active
101
+ // hook, and that delivery is how each installing org registers itself (no env edit).
102
+ hook_attributes: { url: `https://${opts.host}${GITHUB_WEBHOOK_PATH}`, active: true },
103
+ redirect_url: opts.redirectUrl,
104
+ // Private: only the account that owns the App can install it. A deployment is one org's agent.
105
+ public: false,
106
+ default_permissions: githubAppPermissions(opts.variant),
107
+ default_events: githubAppEvents(opts.variant),
108
+ };
109
+ }
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. */
113
+ 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
+ }
117
+
118
+ interface Conversion {
119
+ readonly id?: unknown;
120
+ readonly slug?: unknown;
121
+ readonly name?: unknown;
122
+ readonly pem?: unknown;
123
+ readonly webhook_secret?: unknown;
124
+ readonly client_id?: unknown;
125
+ }
126
+
127
+ const API = "https://api.github.com";
128
+ const HEADERS = { accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28" };
129
+
130
+ /**
131
+ * Turn the redirect's code into this deployment's env. One POST to GitHub's conversion endpoint
132
+ * (it needs no credential: the code is the proof, and it is single-use and lives one hour), then a
133
+ * public read of the App's bot user for the commit e-mail. That second read is fail-soft: without
134
+ * it the plane falls back to the App id (docs/capabilities/source-control.md).
135
+ */
136
+ export async function exchangeGithubManifestCode(opts: {
137
+ readonly code: string;
138
+ readonly variant: GithubAppVariant;
139
+ readonly fetchFn: typeof fetch;
140
+ }): Promise<SurfaceAppCreateResult> {
141
+ const res = await opts.fetchFn(`${API}/app-manifests/${encodeURIComponent(opts.code)}/conversions`, { method: "POST", headers: HEADERS });
142
+ // The status only: a refusal body can echo request detail, and this message reaches the page.
143
+ if (!res.ok) throw new Error(`GitHub refused the code (HTTP ${res.status}). The code is single-use and lasts one hour.`);
144
+ // A fixed message on a body that is not JSON: the parser's own error quotes part of the body.
145
+ let body: Conversion;
146
+ try {
147
+ body = (await res.json()) as Conversion;
148
+ } catch {
149
+ throw new Error(`GitHub's reply was not JSON (HTTP ${res.status}).`);
150
+ }
151
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : typeof v === "number" ? String(v) : undefined);
152
+ const id = str(body.id);
153
+ const slug = str(body.slug);
154
+ const pem = str(body.pem);
155
+ const webhookSecret = str(body.webhook_secret);
156
+ const clientId = str(body.client_id);
157
+ const missing = Object.entries({ id, slug, pem, webhook_secret: webhookSecret, client_id: clientId })
158
+ .filter(([, v]) => v === undefined)
159
+ .map(([k]) => k);
160
+ if (missing.length > 0) throw new Error(`GitHub's reply lacked ${missing.join(", ")}`);
161
+ 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!,
166
+ GITHUB_WEBHOOK_SECRET: webhookSecret!,
167
+ };
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
+ }
173
+ try {
174
+ const bot = await opts.fetchFn(`${API}/users/${encodeURIComponent(`${slug}[bot]`)}`, { headers: HEADERS });
175
+ 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;
177
+ } catch {
178
+ // Fail-soft, see above.
179
+ }
180
+ return { env, appName: str(body.name) ?? slug!, installUrl: `https://github.com/apps/${encodeURIComponent(slug!)}/installations/new` };
181
+ }
182
+
183
+ const isVariant = (v: string): v is GithubAppVariant => GITHUB_APP_VARIANTS.some((x) => x.name === v);
184
+
185
+ /** The flow core's `surface_app_create` drives. */
186
+ export const GITHUB_APP_CREATE_FLOW: SurfaceAppCreateFlow = {
187
+ vendor: "GitHub",
188
+ variants: GITHUB_APP_VARIANTS,
189
+ appNameMax: GITHUB_APP_NAME_MAX,
190
+ // A GitHub login: letters, digits and single hyphens, at most 39 characters.
191
+ ownerProblem: (owner) =>
192
+ /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/.test(owner) ? undefined : "it is not a GitHub organization login",
193
+ createUrl: ({ owner, state }) =>
194
+ owner === undefined
195
+ ? `https://github.com/settings/apps/new?state=${encodeURIComponent(state)}`
196
+ : `https://github.com/organizations/${encodeURIComponent(owner)}/settings/apps/new?state=${encodeURIComponent(state)}`,
197
+ manifestField: "manifest",
198
+ manifest: ({ host, variant, redirectUrl, appName }) =>
199
+ githubAppManifest({ host, variant: isVariant(variant) ? variant : "full", redirectUrl, ...(appName === undefined ? {} : { appName }) }),
200
+ envNames: (variant) => githubAppEnvNames(isVariant(variant) ? variant : "full"),
201
+ codeProblem: (code) => (/^[A-Za-z0-9_-]{1,128}$/.test(code) ? undefined : "the redirect carried no usable code"),
202
+ exchange: ({ code, variant, fetchFn }) => exchangeGithubManifestCode({ code, variant: isVariant(variant) ? variant : "full", fetchFn }),
203
+ // 🔴 PUSH AND REDEPLOY BEFORE THE INSTALL (OPE-2544). The deployment learns the installation from the
204
+ // App's one `installation` delivery. Before the redeploy it does not hold the App's webhook secret, so
205
+ // it refuses that delivery with a 401 and never learns the installation.
206
+ 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.",
208
+ "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
+ "Optional: on the app's settings page, under Display information, upload the project mark as its logo. The manifest has no logo field.",
210
+ ],
211
+ };
212
+
213
+ // ── THE DOC TABLE ───────────────────────────────────────────────────────────────────────────────
214
+
215
+ /** The permission and event tables for docs/deploy/surfaces/github.md, rendered from the manifest. */
216
+ export function renderGithubAppManifestDocs(): string {
217
+ const full = githubAppPermissions("full");
218
+ const floor = scmAppPermissionFloor();
219
+ const events = githubAppEvents("full");
220
+ const label = (family: string): string => family.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
221
+ const rows = Object.entries(full).map(([family, grade]) => {
222
+ const jobs = Object.entries(SCM_MINT_JOB_SETS)
223
+ .filter(([, set]) => (set as Record<string, string | undefined>)[family] !== undefined)
224
+ .map(([job]) => job);
225
+ const byEvent = events.filter((e) => EVENT_PERMISSION[e] === family);
226
+ const why = [...jobs, ...(byEvent.length > 0 ? [`the ${byEvent.map((e) => `\`${e}\``).join(", ")} event${byEvent.length > 1 ? "s" : ""}`] : [])].join("; ");
227
+ const scope = floor[family] === undefined ? "full App only" : "both";
228
+ return `| ${label(family)} | ${grade === "write" ? "Read and write" : "Read-only"} | ${scope} | ${why} |`;
229
+ });
230
+ return [
231
+ "| Repository permission | Access | Shape | Why |",
232
+ "| --- | --- | --- | --- |",
233
+ ...rows,
234
+ "",
235
+ `Events (full App only): ${events.map((e) => `\`${e}\``).join(", ")}. GitHub sends \`installation\` and \`installation_repositories\` to every App with an active webhook, so neither is listed.`,
236
+ ].join("\n");
237
+ }
@@ -23,7 +23,7 @@
23
23
 
24
24
  import {
25
25
  catchUpSender,
26
- resolveConversationReadToken,
26
+ resolveSurfaceReadToken,
27
27
  type NormalizedMessage,
28
28
  type PrFetchFn,
29
29
  type SeatCatchUpFetch,
@@ -103,7 +103,7 @@ export async function fetchGitHubCatchUpWindow(
103
103
  ): Promise<readonly NormalizedMessage[]> {
104
104
  const target = parseGithubContainer(req.container);
105
105
  if (!target) return []; // not a conversation key ⇒ nothing addressable to read
106
- const token = await (deps.resolveToken ?? resolveConversationReadToken)(target.repoFull);
106
+ const token = await (deps.resolveToken ?? resolveSurfaceReadToken)(target.repoFull);
107
107
  const since = new Date(req.sinceMs).toISOString();
108
108
  const url = `${API_BASE}/repos/${target.repoFull}/issues/${target.number}/comments?per_page=${CATCH_UP_PAGE_LIMIT}&since=${encodeURIComponent(since)}`;
109
109
  const raw = await getJson<readonly RawTimelineComment[]>(deps.fetchFn, url, token, "issues.listComments");
@@ -0,0 +1,15 @@
1
+ // DO GITHUB'S EVENTS REACH THIS DEPLOYMENT? (OPE-2547) — GitHub's probe for core's routines/event-reach.ts.
2
+ //
3
+ // GitHub's event normalizer registers from core on every deployment, so core cannot tell from it whether a
4
+ // delivery ever arrives. The native route (`POST /eve/v1/github`, ./github.ts `wrapRoute`) admits a delivery
5
+ // into the routine fan-out only through `gateGithubDelivery`: with `GITHUB_WEBHOOK_SECRET` set, or with the
6
+ // dev-only `webhooks.allowUnverifiedGithub` hatch on. With neither, it 401s every delivery and no GitHub
7
+ // routine can fire. This probe asks the same two questions the gate asks, and reads the secret's PRESENCE only.
8
+
9
+ import { getSettings } from "@open-everywhere/core/agent/adapter-api.js";
10
+
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
+ }
@@ -22,6 +22,8 @@
22
22
  // action namespace (the same one slack-front/teams-front's wake_failed/wake_dropped rows use),
23
23
  // not a github-only verb — an operator asking "who installed us, and when?" wants one query.
24
24
 
25
+ import type { AuditDetail, AuditDetailBuilder } from "@open-everywhere/core/agent/adapter-api.js";
26
+
25
27
  /** One installation delivery, normalized. `effect` is the whole decision; everything else is the
26
28
  * row/audit payload. */
27
29
  export interface GitHubInstallationEvent {
@@ -111,7 +113,7 @@ export interface GitHubInstallationDeps {
111
113
  updatedAt: string;
112
114
  }) => Promise<void>;
113
115
  readonly forget: (owner: string) => Promise<boolean>;
114
- readonly audit: (action: string, detail: Record<string, unknown>, workspace: string) => Promise<void>;
116
+ readonly audit: (action: string, detail: AuditDetail, workspace: string) => Promise<void>;
115
117
  readonly now?: () => string;
116
118
  readonly log?: (msg: string) => void;
117
119
  }
@@ -125,7 +127,7 @@ export interface GitHubInstallationDeps {
125
127
  */
126
128
  export async function handleGitHubInstallationDelivery(ev: GitHubInstallationEvent, deps: GitHubInstallationDeps): Promise<void> {
127
129
  const at = deps.now ? deps.now() : new Date().toISOString();
128
- const detail: Record<string, unknown> = {
130
+ const detail: AuditDetailBuilder = {
129
131
  action: ev.action,
130
132
  installationId: ev.installationId,
131
133
  ...(ev.accountType ? { accountType: ev.accountType } : {}),
@@ -21,6 +21,7 @@ import {
21
21
  mintInstallationToken,
22
22
  CONVERSATION_WRITE_PERMISSIONS,
23
23
  loadGitHubAppConfig,
24
+ githubAuthMode,
24
25
  resolveGitHubInstallationId,
25
26
  warmCredential,
26
27
  markNotApplied,
@@ -121,6 +122,11 @@ function githubAppCreds(): { appId: string; privateKeyPem: string; installations
121
122
  /** Exported for the reaction-feedback poll (channels/github-reaction-feedback.ts, OPE-1502), which
122
123
  * reads under the same App identity every other GitHub call in this bundle uses. */
123
124
  export async function installationTokenFor(origin: GitHubOrigin): Promise<string> {
125
+ // ONE CREDENTIAL PER HOST (OPE-2530): a deployment that signs in with a personal access token never
126
+ // uses the App, so the conversation surface posts nothing, even when App secrets are still set.
127
+ if (githubAuthMode() === "token") {
128
+ 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
+ }
124
130
  const creds = githubAppCreds();
125
131
  if (!creds) {
126
132
  // DEFINITIVE (OPE-825): no App credentials ⇒ no request ever left this host.
@@ -87,7 +87,7 @@ import { githubConversationKey } from "./github-identity.js";
87
87
  * and up to `GITHUB_REACTION_POLL_COMMENT_BUDGET` reaction reads), so this bounds a repository's
88
88
  * worst hour to ~780 GETs on the installation token the reply path shares. The everyday cost is far
89
89
  * below that: most polls read three. */
90
- export const GITHUB_REACTION_POLL_HOURLY_BUDGET = 60;
90
+ const GITHUB_REACTION_POLL_HOURLY_BUDGET = 60;
91
91
 
92
92
  /** The `task_result` retention every consumer of that table shares. See the header: it is coupled,
93
93
  * not tunable. Six hours, the same window `model/turn-state.ts` and the three other consumers use. */
@@ -6,8 +6,9 @@
6
6
  // were not: with no adapter registered, a GitHub seat got "reading a github channel is not
7
7
  // supported yet" (core channels/channel-read.ts `resolveReadAdapter`).
8
8
  // • THE WORKER. `read_work_channel` / `read_work_thread` run the NEUTRAL engine over the same
9
- // adapter, so GitHub could not hold a work read either. It now does, under its own grant — the
10
- // access bundle's `repos:` list (access/surface-work-grant.ts).
9
+ // adapter, so GitHub could not hold a work read either. It now does. A delegated worker reads
10
+ // its own origin conversation with no grant (OPE-2491); any other session needs GitHub's own
11
+ // grant, the access bundle's `repos:` list (access/surface-work-grant.ts).
11
12
  //
12
13
  // 🔴 THE CONTAINER IS THE ISSUE-OR-PR CONVERSATION, NOT THE REPO. The GitHub SURFACE grain is the
13
14
  // repo (github-identity.ts `bindGitHub`: workspace = owner, surface = repo), because a repo is the
@@ -47,8 +48,10 @@
47
48
  // "api", so the repo name alone would admit another owner's repo of the same name. The owner is
48
49
  // compared against THIS turn's own binding, which is the container fence in its strictest form.
49
50
  //
50
- // THE CREDENTIAL IS THE BROKERED CONVERSATION-READ TOKEN — `resolveConversationReadToken` (core
51
- // scm/pr-status.ts), the App installation token for the repo, falling back to `GITHUB_TOKEN`.
51
+ // THE CREDENTIAL IS THE BROKERED CONVERSATION-READ TOKEN — `resolveSurfaceReadToken` (core
52
+ // scm/pr-status.ts), the App installation token for the repo. APP ONLY (OPE-2530): before it, this read
53
+ // fell back to the `GITHUB_TOKEN` personal access token; now a deployment that signs in with one refuses
54
+ // here, because the surface exists only through the App's webhook.
52
55
  // GitHub holds ONE credential family, so the chat read and the work read present the same token;
53
56
  // access/surface-work-grant.ts's header carries the full argument for why that is the shipped seam
54
57
  // and not an oversight.
@@ -56,7 +59,7 @@
56
59
  // 🔴 IT IS NOT `resolveReadToken` (OPE-1019). That resolver mints `{contents, pull_requests,
57
60
  // metadata}` for the PR verbs, and a token with no `issues` family is refused 403 on `/issues/{n}`
58
61
  // when the number names a PLAIN ISSUE — measured live on deploy #118, where both read drives went
59
- // red while the reply POST on the same two issues returned 201. `resolveConversationReadToken`
62
+ // red while the reply POST on the same two issues returned 201. `resolveSurfaceReadToken`
60
63
  // mints `{issues, pull_requests, metadata}`, which is what an endpoint that serves both
61
64
  // conversation kinds needs. See scm/mint.ts for the full measurement.
62
65
  //
@@ -70,7 +73,7 @@ import {
70
73
  assertSurfaceAllowed,
71
74
  bindFrom,
72
75
  prApiHeaders,
73
- resolveConversationReadToken,
76
+ resolveSurfaceReadToken,
74
77
  withRetryAfterLoose,
75
78
  ChannelScopeError,
76
79
  type AdapterReadFetch,
@@ -419,7 +422,7 @@ export const githubReadAdapter = {
419
422
  ): Promise<AdapterReadFetch> {
420
423
  const target = await admitContainer(ctx, channel, "github_read_channel");
421
424
  const effective = Math.min(limit ?? GITHUB_CHANNEL_DEFAULT_LIMIT, GITHUB_MAX_PAGE);
422
- const token = await resolveConversationReadToken(target.repoFull);
425
+ const token = await resolveSurfaceReadToken(target.repoFull);
423
426
  // OPE-1299 — one deadline for the whole read, read fresh per call. See READ_WALK_BUDGET_MS.
424
427
  const deadline = (walk.now ?? Date.now)() + READ_WALK_BUDGET_MS;
425
428
  const issue = await fetchIssue(fetchFn, token, target, walkRetry(deadline, walk));
@@ -451,7 +454,7 @@ export const githubReadAdapter = {
451
454
  walk: ReadWalkSeam = {},
452
455
  ): Promise<AdapterReadFetch> {
453
456
  const target = await admitContainer(ctx, channel, "github_read_thread");
454
- const token = await resolveConversationReadToken(target.repoFull);
457
+ const token = await resolveSurfaceReadToken(target.repoFull);
455
458
  // OPE-1299 — the walk this budget exists for: up to MAX_REVIEW_PAGES calls under one deadline.
456
459
  const deadline = (walk.now ?? Date.now)() + READ_WALK_BUDGET_MS;
457
460
  const { comments, mightBeTruncated } = await fetchReviewComments(fetchFn, token, target, () => walkRetry(deadline, walk));
@@ -58,6 +58,7 @@ import {
58
58
  type SessionTailWakeRow,
59
59
  } from "@open-everywhere/core/agent/adapter-api.js";
60
60
  import { githubBotMentioned, type OpenEverywhereGitHubAuth } from "./github-identity.js";
61
+ import type { AuditDetail } from "@open-everywhere/core/agent/adapter-api.js";
61
62
 
62
63
  /** The catch-up's REACHBACK FLOOR (hours): how far back a seat that has been quiet for a long time
63
64
  * reads. 7 days, the same floor every other surface's bootstrap window uses, so one number governs
@@ -371,7 +372,7 @@ export function githubDispatchAuditDetail(input: {
371
372
  readonly wakeId?: string;
372
373
  readonly repoPrivate: boolean;
373
374
  readonly vouched: boolean;
374
- }): Readonly<Record<string, unknown>> {
375
+ }): AuditDetail {
375
376
  return {
376
377
  addressing: input.addressing,
377
378
  issueNumber: input.issueNumber,
@@ -85,6 +85,7 @@
85
85
  // one repo supervisor session. Still needs a live opted-in repo + App install + Tommy for
86
86
  // provisioning.
87
87
 
88
+ import { auditErrorDetail } from "@open-everywhere/core/agent/adapter-api.js";
88
89
  import type { HttpRouteDefinition, RouteHandlerArgs } from "eve/channels";
89
90
  import { githubChannel, type GitHubChannel, type GitHubChannelState, type GitHubInboundContext, type GitHubComment, type GitHubInboundResult } from "eve/channels/github";
90
91
  import { buildGitHubAuth, githubAuthorFacts, githubBotMentioned } from "./github-identity.js";
@@ -173,7 +174,7 @@ import {
173
174
  OE_ADDRESSED_ATTR,
174
175
  dispatchNativeWakeWithRetry,
175
176
  } from "@open-everywhere/core/agent/adapter-api.js";
176
- import { withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
177
+ import { githubAuthMode, withEndsTurnFinalChannel } from "@open-everywhere/core/agent/adapter-api.js";
177
178
 
178
179
  const ROUTE = "/eve/v1/github";
179
180
  const BOT_NAME = process.env.GITHUB_APP_SLUG;
@@ -613,7 +614,7 @@ export function liveGitHubSupervisorDeps(send: GitHubSend, ev: GitHubAmbientEven
613
614
  await auditSupervisorFailure(
614
615
  { org: deploymentOrg(), platform: "github", workspace: ev.owner, surface: surfaceKey },
615
616
  "surface.wake_failed",
616
- { message: String(err instanceof Error ? err.message : err).slice(0, 500), wakeId: window.wakeId },
617
+ { message: auditErrorDetail(err).error, wakeId: window.wakeId },
617
618
  );
618
619
  throw err;
619
620
  }
@@ -1401,6 +1402,16 @@ function wrapRoute(inner: HttpHandler): HttpHandler {
1401
1402
  // can drop a REDELIVERY (GitHub retries on any non-2xx) without re-firing every matched event
1402
1403
  // routine. Passed only AFTER the signature gate above, never before it (OPE-139).
1403
1404
  args.waitUntil(fanOutGitHubDelivery(body, eventType, args.to, req.headers.get("x-github-delivery")));
1405
+ // ─── TOKEN MODE: THE CONVERSATION SURFACE IS OFF (OPE-2530) ──────────────────────────────────
1406
+ // A deployment that signs in with a personal access token never uses the App, and the surface
1407
+ // (the @mention session, the ambient seat, the stop and reaction legs, installation discovery)
1408
+ // runs on the App. So only the event-routine fan-out above runs: it posts to chat, needs no GitHub
1409
+ // credential, and a plain repository webhook can feed it. Nothing below runs, eve's native channel
1410
+ // included, so no turn starts here and no App token is minted even when App secrets are still set.
1411
+ if (githubAuthMode() === "token") {
1412
+ log(`${eventType || "delivery"}: routine fan-out only — the GitHub conversation surface is off while this deployment signs in with a personal access token`);
1413
+ return new Response("ok", { status: 200 });
1414
+ }
1404
1415
  // ─── THE STOP LEG (OPE-898) ───────────────────────────────────────────────────────────────
1405
1416
  // GitHub sends no reaction webhook, so the stop affordance on this surface is a bare `stop`
1406
1417
  // comment read HERE, in the ingress — before any session is minted. See channels/github-stop.ts