@bongos/core 1.19.204 → 1.19.206

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.
@@ -1552,6 +1552,21 @@ module.exports = function buildTasksRouter() {
1552
1552
  }
1553
1553
  });
1554
1554
 
1555
+ // Plain-sentence publish-credential refusals (task 1003049 / gap-list W7). A
1556
+ // bare 'push_unconfigured' left fresh-instance owners with no idea what was
1557
+ // missing while every ship silently piled at 'confirmed' — the refusal must
1558
+ // say what to set up and where.
1559
+ const PUSH_UNCONFIGURED_MSG =
1560
+ 'This project has no publish credential, so the server cannot push or merge task branches and shipped work waits at confirmed. ' +
1561
+ "To fix it, install the project's GitHub App on the repo (the onboarding “Install the GitHub App” step) with GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY set in the server env, " +
1562
+ 'or set a fine-grained GITHUB_PUSH_TOKEN PAT there instead (ADR 0055).';
1563
+ const appNotInstalledMessage = (e) => {
1564
+ const repo = e && e.owner && e.repo ? `${e.owner}/${e.repo}` : 'the project repository';
1565
+ const where = e && e.install_url ? `at ${e.install_url}` : "from the App's page in GitHub settings (Install App)";
1566
+ return `The project's GitHub App is configured but not installed on ${repo}, so the server cannot push or merge task branches. ` +
1567
+ `Install it ${where}, then retry — no other setup is needed.`;
1568
+ };
1569
+
1555
1570
  // ==========================================================================
1556
1571
  // Server-mediated branch publish (ADR 0055 / task 1025)
1557
1572
  // --------------------------------------------------------------------------
@@ -1584,8 +1599,8 @@ module.exports = function buildTasksRouter() {
1584
1599
  // (or an Archon) may publish this task's branch.
1585
1600
  if (!(await gateTaskOwnership(req, res, id))) return;
1586
1601
  if (!githubPush.isConfigured()) {
1587
- return res.fail('push_unconfigured', { status: 503, message: 'Server-mediated push is not configured (no GitHub App nor GITHUB_PUSH_TOKEN on the server). ' +
1588
- 'Ask the operator to provision it; meanwhile ship from a machine with its own git push credentials.' });
1602
+ return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG +
1603
+ ' Meanwhile you can ship from a machine with its own git push credentials.' });
1589
1604
  }
1590
1605
  const { branch, head_sha: headSha, value_summary: valueSummary } = req.body;
1591
1606
  if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
@@ -1647,7 +1662,8 @@ module.exports = function buildTasksRouter() {
1647
1662
  if (e.code === 'BAD_BRANCH') return res.fail('bad_branch', 400);
1648
1663
  if (e.code === 'BAD_SHA') return res.fail('bad_head_sha', 400);
1649
1664
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
1650
- if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', 503);
1665
+ if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
1666
+ if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
1651
1667
  if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
1652
1668
  // task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
1653
1669
  // scrubbed stderr (github-push.defaultExec). Log both so the next push outage
@@ -1672,7 +1688,7 @@ module.exports = function buildTasksRouter() {
1672
1688
  const id = parseId(req, res, { code: 'bad_task_id' });
1673
1689
  if (id === null) return;
1674
1690
  if (!(await gateTaskOwnership(req, res, id))) return;
1675
- if (!githubPush.isConfigured()) return res.fail('push_unconfigured', 503);
1691
+ if (!githubPush.isConfigured()) return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
1676
1692
  const branch = req.query.branch;
1677
1693
  if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
1678
1694
  try {
@@ -1739,6 +1755,7 @@ module.exports = function buildTasksRouter() {
1739
1755
  return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
1740
1756
  } catch (e) {
1741
1757
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
1758
+ if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
1742
1759
  console.error('[gds] GET /tasks/:id/publish-status', e.code || e.message);
1743
1760
  return res.fail('status_failed', 502);
1744
1761
  }
@@ -44,6 +44,7 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
44
44
  { key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
45
45
  { key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
46
46
  { key: 'oauth-place', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
47
+ { key: 'app-install', kind: 'human', label: 'Install the GitHub App on the repo — the publish credential that lets shipped work land itself (task 1003049, ADR 0055)' },
47
48
  { key: 'first-sign-in', kind: 'human', label: 'First sign-in (seats the founding Archon)' },
48
49
  { key: 'verify', kind: 'auto', label: 'Verify /healthz auth_configured' },
49
50
  { key: 'brand', kind: 'auto', label: 'Brand the instance look' },
@@ -265,16 +265,21 @@ function githubAppName({ domain, brand, project } = {}) {
265
265
  // • callback_urls — where "Login with GitHub" on the INSTANCE redirects during real
266
266
  // sign-in. Both /api/gds/... (what auth.js actually builds) and the /api/bongos/...
267
267
  // alias are registered so sign-in works regardless of which prefix a build emits.
268
- // Minimal for sign-in: no repo permissions, no webhook (active:false). PURE.
268
+ // No webhook (active:false). PURE.
269
269
  function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothershipOrigin, brand }) {
270
270
  const instOrigin = `https://${instanceDomain}`;
271
271
  const redirectUrl = `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/callback`;
272
272
  return {
273
273
  name: githubAppName({ domain: instanceDomain, brand, project: instanceName || slug }),
274
274
  url: instOrigin,
275
- // Sign-in only ever calls GET /user with the user-to-server token — no repo/account
276
- // permissions are needed, so request none (least privilege).
277
- default_permissions: {},
275
+ // Sign-in itself needs no permissions (GET /user with a user-to-server token),
276
+ // but this App DOUBLES as the instance's ADR 0055 publish credential (task
277
+ // 1003049 / gap-list W7): once installed on the repo, the server mints
278
+ // installation tokens to push task branches, open/merge PRs, and read deploy
279
+ // runs — without them every ship strands at 'confirmed' behind manual archon
280
+ // merges. Exactly the ADR 0055 scopes, nothing more; the App still cannot
281
+ // push to branch-protected main nor bypass required checks.
282
+ default_permissions: { contents: 'write', pull_requests: 'write', actions: 'read' },
278
283
  default_events: [],
279
284
  // No webhook: active:false. A url is still supplied (GitHub validates the shape) but
280
285
  // is never called while inactive; point it at the mothership so it's a real host.
@@ -286,10 +291,15 @@ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothership
286
291
  ],
287
292
  // public: GitHub answers a PRIVATE App's web authorize URL for any non-owner
288
293
  // account with a bare 404 (before the instance is ever involved), so a private
289
- // App makes the project structurally single-builder on the web. The App requests
290
- // zero permissions, so public costs nothing (gap-list W6, task 1003048). Apps
291
- // minted before this fix stay private until flipped by hand — see
292
- // docs/recipes/ops-gotchas.md.
294
+ // App makes the project structurally single-builder on the web (gap-list W6,
295
+ // task 1003048). TRADE-OFF now that the App carries write scopes: public means
296
+ // any third party can install it on THEIR repos, and a leaked App private key's
297
+ // blast radius includes those installs — the server itself only ever uses its
298
+ // own repo's installation, but the credential could reach further if compromised.
299
+ // Accepted to keep W6; mitigated by the publish-reconciler's foreign-install
300
+ // watch (github-push.foreignInstallations), which warns whenever the App is
301
+ // installed anywhere beyond the repo owner. Apps minted before the W6 fix stay
302
+ // private until flipped by hand — see docs/recipes/ops-gotchas.md.
293
303
  public: true,
294
304
  };
295
305
  }
@@ -892,6 +892,10 @@ module.exports = function provisioningRoutes() {
892
892
  terminal: status === 'done' || status === 'error' || status === 'expired',
893
893
  domain: inst && inst.domain ? inst.domain : null,
894
894
  instance_status: inst ? inst.status : null,
895
+ // The App's public slug (task 1003049): lets the wizard render the one-click
896
+ // install link (github.com/apps/<slug>/installations/new) for the app-install
897
+ // step. Public info — the App's page is public by design (task 1003048).
898
+ app_slug: row.app_slug || null,
895
899
  });
896
900
  } catch (err) {
897
901
  console.error('[provisioning] GET /provisioning/github-app/status', err);
@@ -165,7 +165,7 @@ t('githubAppName falls back to brand-qualified project when there is no usable d
165
165
  assert.ok(long.length <= 34 && long.startsWith('Cloud Bongos - '), `capped + brand kept: ${long}`);
166
166
  });
167
167
 
168
- t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the INSTANCE, minimal perms, public, domain-named', () => {
168
+ t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the INSTANCE, ADR 0055 publish scopes, public, domain-named', () => {
169
169
  const m = P.buildGithubAppManifest({
170
170
  instanceName: 'mercury', slug: 'mercury', brand: 'Cloud Bongos',
171
171
  instanceDomain: 'mercury.example.com', mothershipOrigin: 'https://cloudbongos.com',
@@ -179,8 +179,10 @@ t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the IN
179
179
  'https://mercury.example.com/api/gds/auth/web/callback',
180
180
  'https://mercury.example.com/api/bongos/auth/web/callback',
181
181
  ]);
182
- // sign-in needs no repo/account permissions; no active webhook
183
- assert.deepEqual(m.default_permissions, {});
182
+ // The App doubles as the ADR 0055 publish credential (task 1003049): exactly the
183
+ // three publish scopes — Contents rw (push branches), PR rw (open/merge), Actions
184
+ // read (deploy probe) — and nothing more. No active webhook.
185
+ assert.deepEqual(m.default_permissions, { contents: 'write', pull_requests: 'write', actions: 'read' });
184
186
  assert.equal(m.hook_attributes.active, false);
185
187
  // public: a PRIVATE App answers the web authorize URL for any non-owner with a bare
186
188
  // GitHub 404, making the project single-builder on the web (gap-list W6, task 1003048)
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.204",
3
+ "version": "1.19.206",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.204",
9
+ "version": "1.19.206",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.204",
3
+ "version": "1.19.206",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -2768,7 +2768,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2768
2768
  var GH_ICON = '<svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
2769
2769
 
2770
2770
  var instanceId = null; /* set when a create lands / a resumed watch starts */
2771
- var live = { instStatus: null, manifest: null, manifestMsg: '' };
2771
+ var live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
2772
2772
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
2773
2773
  var lastStepsHtml = '';
2774
2774
  var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall'];
@@ -2861,6 +2861,21 @@ h1[tabindex="-1"]:focus{outline:none;}
2861
2861
  '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
2862
2862
  }
2863
2863
 
2864
+ /* app-install — the ADR 0055 publish credential (task 1003049): the sign-in App
2865
+ doubles as it, but only an INSTALLED App can mint push tokens; without this
2866
+ one click every finished task waits at confirmed for a manual merge. */
2867
+ function renderStepAppInstall() {
2868
+ if (live.manifest !== 'done') {
2869
+ return '<li class="slocked" data-onboard-step="app-install"><b>Install the GitHub App on your repo</b><p>Unlocks once GitHub sign-in above is wired — the same app, installed on your repo, is what lets finished work merge and ship itself.</p></li>';
2870
+ }
2871
+ var installUrl = live.appSlug ? 'https://github.com/apps/' + esc(live.appSlug) + '/installations/new' : null;
2872
+ return '<li data-onboard-step="app-install"><b>Install the GitHub App on your repo</b><p>One click: install the app you just approved on this project’s repo. Without it, finished work waits for a manual merge instead of shipping itself.</p>' +
2873
+ (installUrl
2874
+ ? '<a class="ghbtn" href="' + installUrl + '" target="_blank" rel="noopener">' + GH_ICON + 'Install the app on your repo</a>'
2875
+ : '<p>GitHub → Settings → Developer settings → GitHub Apps → your app → <strong>Install App</strong> → pick this project’s repo.</p>') +
2876
+ '</li>';
2877
+ }
2878
+
2864
2879
  function renderStepFirstSignIn() {
2865
2880
  var domain = resolvedDomain();
2866
2881
  if (!domain) {
@@ -2879,6 +2894,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2879
2894
  var STEP_RENDERERS = {
2880
2895
  'github-repo': renderStepGithubRepo,
2881
2896
  'oauth-app': renderStepOauthApp,
2897
+ 'app-install': renderStepAppInstall,
2882
2898
  'first-sign-in': renderStepFirstSignIn,
2883
2899
  'enter-hall': renderStepEnterHall
2884
2900
  };
@@ -3007,6 +3023,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3007
3023
  .then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
3008
3024
  .then(function (d) {
3009
3025
  if (myGen !== manifestGen) return;
3026
+ if (d.app_slug && d.app_slug !== live.appSlug) { live.appSlug = d.app_slug; renderSteps(); }
3010
3027
  var next = d.ready ? 'done' : (d.status || 'pending');
3011
3028
  if (next !== live.manifest) { live.manifest = next; renderSteps(); }
3012
3029
  if (!d.terminal) manifestPollTimer = setTimeout(tick, 4000);
@@ -3187,7 +3204,7 @@ h1[tabindex="-1"]:focus{outline:none;}
3187
3204
  stopManifestPoll();
3188
3205
  instanceId = null;
3189
3206
  watchPending = false; manifestPending = false; manifestState = null;
3190
- live = { instStatus: null, manifest: null, manifestMsg: '' };
3207
+ live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
3191
3208
  lastStepsHtml = '';
3192
3209
  try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
3193
3210
  state = { step: 1, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
@@ -9,9 +9,9 @@
9
9
  // provision → OAuth → verify → brand legs. Standalone model only (ADR 0108).
10
10
  //
11
11
  // It automates what it can (DNS token preflight, OAuth-cred validation, /healthz
12
- // verify) and renders the four irreducibly-human steps (create the GitHub repo, the
13
- // OAuth app + secret, own the domain, first sign-in) as precise, copy-paste-safe
14
- // prompts. It does NOT hold cloud tokens: the actual provision runs on the control
12
+ // verify) and renders the irreducibly-human steps (create the GitHub repo, the
13
+ // OAuth app + secret, install the App on the repo, own the domain, first sign-in)
14
+ // as precise, copy-paste-safe prompts. It does NOT hold cloud tokens: the actual provision runs on the control
15
15
  // plane (it prints the exact `provision.js` command), preserving the trust boundary.
16
16
 
17
17
  const readline = require('node:readline');
@@ -189,6 +189,14 @@ async function run() {
189
189
  log(' (validates + rewrites web.env via Node + restarts + probes /healthz auth_configured — never hand-edit web.env.)');
190
190
  await ask(rl, 'Press Enter once oauth-secret reports auth_configured=true…');
191
191
 
192
+ // The publish credential (task 1003049, ADR 0055): the one-click App carries the
193
+ // repo scopes, but only an INSTALLED App can mint push tokens — skip this click
194
+ // and every ship strands at 'confirmed' behind a manual merge.
195
+ log('\n Install the GitHub App on the repo — the publish credential that lets shipped work land itself:');
196
+ log(' github.com → Settings → Developer settings → GitHub Apps → your app → Install App → pick this project’s repo.');
197
+ log(' (On the manual OAuth-app path instead? Set a fine-grained GITHUB_PUSH_TOKEN in web.env — ADR 0055.)');
198
+ await ask(rl, 'Press Enter once the App is installed on the repo…');
199
+
192
200
  // Step 8/9 — first sign-in + verify
193
201
  log('\n⑦ First sign-in (seats the founding Archon):');
194
202
  log(` Open ${oauthAppInstructions(a.domain).homepageUrl} and sign in with GitHub once.`);
@@ -1021,7 +1021,7 @@ function provisionedEnvPrefix() {
1021
1021
  // the hall on this host + OAuth uses the real callback origin — without them the pack
1022
1022
  // stays at neutral localhost and the hall never loads (task 1972). No domain yet
1023
1023
  // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
1024
- function webEnvBody(inst, { clientId = '', clientSecret = '', federation = null } = {}) {
1024
+ function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null } = {}) {
1025
1025
  const prefix = provisionedEnvPrefix();
1026
1026
  const origin = inst.domain ? `https://${inst.domain}` : '';
1027
1027
  const originLines = origin
@@ -1077,6 +1077,19 @@ ${prefix}_HUB_ORIGIN=${federation.hubOrigin}
1077
1077
  ${prefix}_HUB_CLIENT_ID=${federation.clientId}
1078
1078
  ${prefix}_HUB_CLIENT_SECRET=${federation.clientSecret}
1079
1079
  ${prefix}_HUB_PUBLIC_KEY=${federation.hubPublicKey}
1080
+ `
1081
+ : '';
1082
+ // The publish credential (task 1003049, ADR 0055): the manifest-born App's id +
1083
+ // private key, read BARE (no prefix) by modules/lifecycle/github-push.js. The
1084
+ // pem is env-file-flattened to a single line with literal \n (the ADR 0055
1085
+ // rotation convention; normalizePem restores it). The installation id is NOT
1086
+ // written — the server discovers it from the repo once the owner installs the
1087
+ // App (the onboarding app-install step).
1088
+ const appLines = appId && appPem
1089
+ ? `# Publish credential (ADR 0055): the sign-in GitHub App doubles as it. The server
1090
+ # mints short-lived installation tokens once the App is INSTALLED on the repo.
1091
+ GITHUB_APP_ID=${appId}
1092
+ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
1080
1093
  `
1081
1094
  : '';
1082
1095
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
@@ -1084,7 +1097,7 @@ ${credComment}
1084
1097
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
1085
1098
  ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
1086
1099
  ${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
1087
- ${originLines}PORT=${inst.port}
1100
+ ${originLines}${appLines}PORT=${inst.port}
1088
1101
  PGDATABASE=${dbName(inst)}
1089
1102
  ${fedLines}`;
1090
1103
  }
@@ -1948,6 +1961,10 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
1948
1961
  clientSecret: String(data.client_secret),
1949
1962
  appId: data.id != null ? Number(data.id) : null,
1950
1963
  appSlug: data.slug || null,
1964
+ // The App private key (task 1003049): with the manifest now requesting the
1965
+ // ADR 0055 repo scopes, this pem + the app id ARE the instance's publish
1966
+ // credential — placed into web.env alongside the OAuth creds, never logged.
1967
+ appPem: data.pem ? String(data.pem) : null,
1951
1968
  };
1952
1969
  }
1953
1970
 
@@ -1973,7 +1990,7 @@ async function placeManifestCreds(inst, mrow, deps) {
1973
1990
  creds = await (deps.convertManifestCode || convertManifestCode)(mrow.github_code, {});
1974
1991
  } else {
1975
1992
  log(` [oauth-manifest] would POST api.github.com/app-manifests/<code>/conversions for ${inst.slug}`);
1976
- creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null };
1993
+ creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null, appPem: null };
1977
1994
  }
1978
1995
 
1979
1996
  // 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
@@ -1991,7 +2008,13 @@ async function placeManifestCreds(inst, mrow, deps) {
1991
2008
  // 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
1992
2009
  // writeFile logs only a byte count; the value is base64/temp-file placed).
1993
2010
  log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
1994
- boxWriteFile(webEnvPath(inst), webEnvBody(inst, { clientId: creds.clientId, clientSecret: creds.clientSecret }), { mode: 0o600, sudo: true });
2011
+ boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
2012
+ clientId: creds.clientId, clientSecret: creds.clientSecret,
2013
+ // The App id + private key double as the ADR 0055 publish credential (task
2014
+ // 1003049); older Apps (pre-permission manifests) return a pem too — placing
2015
+ // it is harmless and heals once the owner grants the permissions + installs.
2016
+ appId: creds.appId, appPem: creds.appPem,
2017
+ }), { mode: 0o600, sudo: true });
1995
2018
  boxExec(`${sudoP}systemctl restart ${inst.slug}`);
1996
2019
 
1997
2020
  // 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
package/src/module-api.js CHANGED
@@ -835,8 +835,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
835
835
  // landed since 1.19.202 with no explicit bump. run 32059705946. (task 1002620)
836
836
  // 1.19.204 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
837
837
  // landed since 1.19.203 with no explicit bump. run 32060607072. (task 1002620)
838
+ // 1.19.205 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
839
+ // landed since 1.19.204 with no explicit bump. run 32061149280. (task 1002620)
840
+ // 1.19.206 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
841
+ // landed since 1.19.205 with no explicit bump. run 32064728346. (task 1002620)
838
842
  // ---------------------------------------------------------------------------
839
- const CORE_VERSION = '1.19.204'; // CI auto-patch carrier (ADR 0161); see changelog above
843
+ const CORE_VERSION = '1.19.206'; // CI auto-patch carrier (ADR 0161); see changelog above
840
844
 
841
845
  // A namespaced logger so a module's log lines are attributable + consistent.
842
846
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/onboard.mjs CHANGED
@@ -15,12 +15,16 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
15
15
  assert.equal(keys[keys.length - 2], 'brand');
16
16
  // the irreducibly-human steps (must be UI-first prompts)
17
17
  const human = plan.filter((s) => s.kind === 'human').map((s) => s.key);
18
- assert.deepEqual(human, ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall']);
18
+ assert.deepEqual(human, ['github-repo', 'oauth-app', 'app-install', 'first-sign-in', 'enter-hall']);
19
19
  // the DNS preflight comes BEFORE provision (task 1980: before the address step)
20
20
  assert.ok(keys.indexOf('dns-preflight') < keys.indexOf('provision'), 'dns preflight precedes provision');
21
21
  // OAuth secret placement comes after the OAuth app + before first sign-in
22
22
  assert.ok(keys.indexOf('oauth-app') < keys.indexOf('oauth-place'), 'app before place');
23
23
  assert.ok(keys.indexOf('oauth-place') < keys.indexOf('first-sign-in'), 'place before sign-in');
24
+ // The publish-credential install (task 1003049) needs the App to exist first and
25
+ // must be prompted before the owner walks away into the hall.
26
+ assert.ok(keys.indexOf('oauth-place') < keys.indexOf('app-install'), 'the App exists before it can be installed');
27
+ assert.ok(keys.indexOf('app-install') < keys.indexOf('first-sign-in'), 'install prompted before sign-in');
24
28
  });
25
29
 
26
30
  test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
@@ -1044,11 +1044,27 @@ t('webEnvBody fills the OAuth cred lines when creds are given; blank placeholder
1044
1044
  const inst = { ...coTenant, domain: 'mercury.example.com' };
1045
1045
  const blank = P.webEnvBody(inst);
1046
1046
  assert.match(blank, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_ID=\\n`), 'blank id line by default');
1047
+ assert.doesNotMatch(blank, /GITHUB_APP_ID=/, 'no App credential lines without an App');
1047
1048
  const filled = P.webEnvBody(inst, { clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40) });
1048
1049
  assert.match(filled, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_ID=Iv1\\.abc123def456ghi7`));
1049
1050
  assert.match(filled, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_SECRET=s{40}`));
1050
1051
  });
1051
1052
 
1053
+ t('webEnvBody writes the BARE publish-credential vars, pem flattened to one env line (task 1003049)', () => {
1054
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1055
+ const body = P.webEnvBody(inst, {
1056
+ clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40),
1057
+ appId: 42, appPem: '-----BEGIN KEY-----\nAAA\nBBB\n-----END KEY-----\n',
1058
+ });
1059
+ // BARE names (github-push.js reads process.env.GITHUB_APP_ID, no instance prefix)
1060
+ assert.match(body, /^GITHUB_APP_ID=42$/m);
1061
+ assert.match(body, /^GITHUB_APP_PRIVATE_KEY=-----BEGIN KEY-----\\nAAA\\nBBB\\n-----END KEY-----\\n$/m);
1062
+ assert.doesNotMatch(body, /GITHUB_APP_INSTALLATION_ID/, 'installation id is discovered at runtime, never placed');
1063
+ // pem missing (older Apps) → no half-written credential
1064
+ const noPem = P.webEnvBody(inst, { clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40), appId: 42, appPem: null });
1065
+ assert.doesNotMatch(noPem, /GITHUB_APP_ID=/);
1066
+ });
1067
+
1052
1068
  await ta('convertManifestCode POSTs the unauthenticated conversion endpoint + maps the response', async () => {
1053
1069
  let calledUrl = '', calledInit = null;
1054
1070
  const fetchImpl = async (url, init) => {
@@ -1060,7 +1076,16 @@ await ta('convertManifestCode POSTs the unauthenticated conversion endpoint + ma
1060
1076
  assert.equal(calledInit.method, 'POST');
1061
1077
  assert.ok(calledInit.headers['User-Agent'], 'stamps a User-Agent (GitHub requires one)');
1062
1078
  assert.ok(!('Authorization' in calledInit.headers), 'unauthenticated — the code IS the auth');
1063
- assert.deepEqual(creds, { clientId: 'Iv1.abcdef0123456789', clientSecret: 'x'.repeat(40), appId: 42, appSlug: 'mercury-signin' });
1079
+ assert.deepEqual(creds, { clientId: 'Iv1.abcdef0123456789', clientSecret: 'x'.repeat(40), appId: 42, appSlug: 'mercury-signin', appPem: null });
1080
+ });
1081
+
1082
+ await ta('convertManifestCode maps the App private key (pem) — the ADR 0055 publish credential (task 1003049)', async () => {
1083
+ const pem = '-----BEGIN RSA PRIVATE KEY-----\nMIIkey\n-----END RSA PRIVATE KEY-----\n';
1084
+ const fetchImpl = async () => ({ ok: true, text: async () => JSON.stringify({
1085
+ id: 7, slug: 's', client_id: 'Iv1.abcdef0123456789', client_secret: 'x'.repeat(40), pem,
1086
+ }) });
1087
+ const creds = await P.convertManifestCode('C', { fetchImpl });
1088
+ assert.equal(creds.appPem, pem, 'the pem rides the creds to web.env placement');
1064
1089
  });
1065
1090
 
1066
1091
  await ta('convertManifestCode throws on a non-2xx or a creds-less response', async () => {
@@ -1075,7 +1100,7 @@ await ta('placeManifestCreds (--apply): converts, rewrites web.env with the cred
1075
1100
  const writes = []; const cmds = [];
1076
1101
  const deps = {
1077
1102
  apply: true, privileged: false, log: () => {},
1078
- convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-signin' }),
1103
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-signin', appPem: '-----BEGIN KEY-----\nAAA\n-----END KEY-----\n' }),
1079
1104
  exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: '{"ok":true,"auth_configured":true}' }; },
1080
1105
  writeFile: (p, body) => { writes.push({ p, body }); return { ok: true }; },
1081
1106
  };
@@ -1086,6 +1111,9 @@ await ta('placeManifestCreds (--apply): converts, rewrites web.env with the cred
1086
1111
  const env = writes.find((w) => /web\.env$/.test(w.p));
1087
1112
  assert.ok(env, 'web.env was written');
1088
1113
  assert.match(env.body, /_GITHUB_CLIENT_SECRET=z{40}/);
1114
+ // …and the publish credential (task 1003049): App id + one-line pem, bare names
1115
+ assert.match(env.body, /^GITHUB_APP_ID=9$/m, 'the App id is placed as the publish credential');
1116
+ assert.match(env.body, /^GITHUB_APP_PRIVATE_KEY=-----BEGIN KEY-----\\nAAA\\n-----END KEY-----\\n$/m, 'the pem is placed env-flattened');
1089
1117
  // the unit was restarted, and /healthz was probed
1090
1118
  assert.ok(cmds.some((c) => /systemctl restart mercury/.test(c)), 'restarts the unit');
1091
1119
  assert.ok(cmds.some((c) => /\/healthz/.test(c)), 'probes healthz');
@@ -476,7 +476,7 @@ async function withEnv(vars, fn) {
476
476
  const APP_ENV = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
477
477
  const NO_APP = { GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined };
478
478
 
479
- await t('appConfigured / isConfigured: true only with App (all 3 vars) or PAT', async () => {
479
+ await t('appConfigured / isConfigured: true with App (id + key; installation id optional) or PAT', async () => {
480
480
  await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
481
481
  assert.equal(gp.appConfigured(), false);
482
482
  assert.equal(gp.isConfigured(), false);
@@ -490,7 +490,11 @@ await t('appConfigured / isConfigured: true only with App (all 3 vars) or PAT',
490
490
  assert.equal(gp.isConfigured(), true, 'App alone configures it');
491
491
  });
492
492
  await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY, GITHUB_PUSH_TOKEN: undefined }, async () => {
493
- assert.equal(gp.appConfigured(), false, 'partial App config (no installation id) is NOT configured');
493
+ assert.equal(gp.appConfigured(), true, 'id + key ARE enough — the installation id is discovered from the repo (task 1003049)');
494
+ assert.equal(gp.isConfigured(), true);
495
+ });
496
+ await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: undefined }, async () => {
497
+ assert.equal(gp.appConfigured(), false, 'no private key → not configured');
494
498
  assert.equal(gp.isConfigured(), false);
495
499
  });
496
500
  });
@@ -548,6 +552,153 @@ await t('getInstallationToken: App configured but the exchange fails → APP_TOK
548
552
  });
549
553
  });
550
554
 
555
+ // task 1003049 — a manifest-born instance knows its App id + key at cred-placement
556
+ // time but never the installation id: that only exists after the owner's one-click
557
+ // install. The server derives it live instead of demanding a third env var.
558
+ const APP_ENV_NO_INST = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
559
+
560
+ await t('getInstallationToken: no installation id in env → discovers it from the repo, then mints (task 1003049)', async () => {
561
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
562
+ const urls = [];
563
+ const fetchImpl = async (url, opts = {}) => {
564
+ urls.push(url);
565
+ if (url.endsWith('/repos/someowner/somerepo/installation')) {
566
+ const authz = opts.headers.Authorization;
567
+ assert.ok(/^Bearer /.test(authz) && jwtSignatureVerifies(authz.slice(7), APP_PUBKEY), 'discovery authenticates with a valid App JWT');
568
+ return jsonRes(200, { id: 789 });
569
+ }
570
+ if (url.includes('/app/installations/789/access_tokens')) {
571
+ return jsonRes(201, { token: 'ghs_discovered', expires_at: '2999-01-01T00:00:00Z' });
572
+ }
573
+ throw new Error('unexpected ' + url);
574
+ };
575
+ const now = () => 1_700_000_000 * 1000;
576
+ const tok = await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
577
+ assert.equal(tok, 'ghs_discovered');
578
+ assert.ok(urls.some((u) => u.endsWith('/repos/someowner/somerepo/installation')), 'looked the installation up on the repo');
579
+ });
580
+ });
581
+
582
+ await t('getInstallationToken: env GITHUB_APP_INSTALLATION_ID still wins — no discovery call (the ADR 0055 v1 shape)', async () => {
583
+ await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: undefined }, async () => {
584
+ const fetchImpl = async (url) => {
585
+ if (url.endsWith('/installation')) throw new Error('must not discover when the env id is set');
586
+ return jsonRes(201, { token: 'ghs_env', expires_at: '2999-01-01T00:00:00Z' });
587
+ };
588
+ assert.equal(await gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }), 'ghs_env');
589
+ });
590
+ });
591
+
592
+ await t('getInstallationToken: App not installed on the repo → APP_NOT_INSTALLED carrying the install URL (task 1003049)', async () => {
593
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
594
+ const fetchImpl = async (url) => {
595
+ if (url.endsWith('/repos/someowner/somerepo/installation')) return jsonRes(404, { message: 'Not Found' });
596
+ if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/mercury-signin' });
597
+ throw new Error('unexpected ' + url);
598
+ };
599
+ await assert.rejects(
600
+ () => gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }),
601
+ (e) => e.code === 'APP_NOT_INSTALLED'
602
+ && e.owner === 'someowner' && e.repo === 'somerepo'
603
+ && e.install_url === 'https://github.com/apps/mercury-signin/installations/new',
604
+ 'names the repo + the one-click install URL so the refusal is self-serve'
605
+ );
606
+ });
607
+ });
608
+
609
+ await t('discoverInstallationId: APP_NOT_INSTALLED is negative-cached for a short TTL (no per-poll re-probe)', async () => {
610
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
611
+ let probes = 0;
612
+ const fetchImpl = async (url) => {
613
+ if (url.endsWith('/repos/someowner/somerepo/installation')) { probes++; return jsonRes(404, {}); }
614
+ if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/x' });
615
+ throw new Error('unexpected ' + url);
616
+ };
617
+ let nowMs = 1_700_000_000 * 1000;
618
+ const now = () => nowMs;
619
+ const call = () => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
620
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
621
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
622
+ assert.equal(probes, 1, 'the second poll inside the TTL reuses the cached refusal');
623
+ nowMs += 61_000;
624
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
625
+ assert.equal(probes, 2, 'after the TTL the state is re-probed (an install must be noticed)');
626
+ });
627
+ });
628
+
629
+ await t('getInstallationToken: a stale DISCOVERED id is dropped on exchange failure — next call re-discovers (no restart needed)', async () => {
630
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
631
+ let discoveries = 0, exchanges = 0;
632
+ const fetchImpl = async (url) => {
633
+ if (url.endsWith('/repos/someowner/somerepo/installation')) {
634
+ discoveries++;
635
+ return jsonRes(200, { id: discoveries === 1 ? 111 : 222 });
636
+ }
637
+ if (url.includes('/access_tokens')) {
638
+ exchanges++;
639
+ // the first (stale) id fails the exchange; the re-discovered one works
640
+ if (url.includes('/111/')) return jsonRes(404, { message: 'gone (uninstalled+reinstalled)' });
641
+ return jsonRes(201, { token: 'ghs_fresh', expires_at: '2999-01-01T00:00:00Z' });
642
+ }
643
+ throw new Error('unexpected ' + url);
644
+ };
645
+ const now = () => 1_700_000_000 * 1000;
646
+ await assert.rejects(
647
+ () => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }),
648
+ (e) => e.code === 'APP_TOKEN_FAILED'
649
+ );
650
+ assert.equal(await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }), 'ghs_fresh',
651
+ 'the stale id was dropped and re-discovery healed the exchange');
652
+ assert.equal(discoveries, 2);
653
+ assert.equal(exchanges, 2);
654
+ });
655
+ });
656
+
657
+ await t('foreignInstallations: installs beyond the repo owner are surfaced; own install is not; API miss → null (task 1003049 grade)', async () => {
658
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
659
+ const roster = [
660
+ { id: 1, account: { login: 'SomeOwner' } }, // own (case-insensitive)
661
+ { id: 2, account: { login: 'stranger' } },
662
+ { id: 3, account: { login: 'another-org' } },
663
+ ];
664
+ const fetchImpl = async (url) => {
665
+ if (url.includes('/app/installations?')) return jsonRes(200, roster);
666
+ throw new Error('unexpected ' + url);
667
+ };
668
+ const foreign = await gp.foreignInstallations({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO });
669
+ assert.deepEqual(foreign.map((f) => f.account).sort(), ['another-org', 'stranger']);
670
+ const miss = await gp.foreignInstallations({ fetchImpl: async () => jsonRes(500, {}), now: () => 1_700_000_000 * 1000, repoInfo: REPO });
671
+ assert.equal(miss, null, 'a watch that cannot tell says null, never a fake all-clear');
672
+ });
673
+ await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
674
+ assert.equal(await gp.foreignInstallations({ fetchImpl: async () => { throw new Error('no'); } }), null,
675
+ 'unconfigured App → null');
676
+ });
677
+ });
678
+
679
+ await t('GET /tasks/:id/publish-status with no credential → a plain-sentence 503, not a bare code (task 1003049)', async () => {
680
+ await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
681
+ const router = buildTasksRouter();
682
+ const layer = findRouteLayer(router, '/tasks/:id/publish-status', 'get');
683
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
684
+ const out = { failCode: null, failOpts: null };
685
+ const res = {
686
+ status() { return this; },
687
+ json() { return this; },
688
+ fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
689
+ };
690
+ // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
691
+ const req = { params: { id: '77' }, query: { branch: 'claude/x' }, builder: { id: 95, rank: 'archon' } };
692
+ await handler(req, res);
693
+ assert.equal(out.failCode, 'push_unconfigured');
694
+ assert.equal(out.failOpts && out.failOpts.status, 503);
695
+ const msg = String((out.failOpts && out.failOpts.message) || '');
696
+ assert.match(msg, /publish credential/i, 'says WHAT is missing in a sentence');
697
+ assert.match(msg, /GitHub App/, 'says what to set up');
698
+ assert.match(msg, /GITHUB_PUSH_TOKEN/, 'names the PAT alternative');
699
+ });
700
+ });
701
+
551
702
  await t('resolveToken precedence: deps.token > App installation token > PAT > null', async () => {
552
703
  await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: 'pat' }, async () => {
553
704
  assert.equal(