@bongos/core 1.19.709 → 1.19.711

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 (35) hide show
  1. package/.bongos-core.json +74 -34
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +16 -0
  4. package/clients/bongos-client/index.cjs +16 -0
  5. package/clients/bongos-client/index.d.ts +24 -0
  6. package/clients/bongos-client/index.mjs +16 -0
  7. package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +2 -0
  8. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +2 -0
  9. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +56 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +506 -3
  12. package/docs/api-reference.md +14 -2
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/ops-gotchas.md +14 -0
  15. package/modules/agents/lib/answer-hold.js +91 -0
  16. package/modules/agents/lib/authoring.js +155 -0
  17. package/modules/agents/lib/fire-budget.js +109 -0
  18. package/modules/agents/lib/gate.js +64 -0
  19. package/modules/agents/routes/agents.js +494 -0
  20. package/modules/agents/spawn.js +12 -0
  21. package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -0
  22. package/modules/government/catalog.js +10 -0
  23. package/modules/government/migrations/government_013_agent_atoms.sql +66 -0
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/agent-invoke.js +5 -27
  27. package/scripts/gds/provision-repo.js +181 -0
  28. package/scripts/gds/provision-units.js +33 -5
  29. package/scripts/gds/provision.js +35 -3
  30. package/src/bongos/pool.js +14 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/agents_authoring.mjs +306 -0
  33. package/tests/agents_routes.mjs +76 -15
  34. package/tests/agents_write_routes.mjs +461 -0
  35. package/tests/provision.mjs +227 -0
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.709",
3
+ "version": "1.19.711",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.709",
9
+ "version": "1.19.711",
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.709",
3
+ "version": "1.19.711",
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",
@@ -446,33 +446,11 @@ function parseArgv(argv = []) {
446
446
  return { name, question, outArg, asJson, error: null };
447
447
  }
448
448
 
449
- /**
450
- * The gate. Decides whether this definition may be invoked on demand, and gives
451
- * the reason in the same breath — rule 2 needs the reason as much as the verdict,
452
- * because the reason is the whole content of the ledger row a refusal writes.
453
- *
454
- * PURE: takes the registry row, returns `{ decision, reason }`.
455
- */
456
- function gateFor(row) {
457
- if (!row) return { decision: 'no-go', reason: 'no such agent in the registry' };
458
- if (row.trigger_type !== 'on-demand') {
459
- return {
460
- decision: 'no-go',
461
- reason: `trigger_type is '${row.trigger_type}', not 'on-demand' — this agent is dispatched by an event, not asked a question`,
462
- };
463
- }
464
- // Checked before `enabled` even though the schema's flagged_not_armed CHECK
465
- // makes a flagged row necessarily disabled: the two states have the same
466
- // verdict and completely different fixes, and the reason is what the caller
467
- // acts on.
468
- if (isNonEmptyString(row.scope_violation)) {
469
- return { decision: 'no-go', reason: `disarmed by the scope wall: ${row.scope_violation}` };
470
- }
471
- if (row.enabled !== true) {
472
- return { decision: 'no-go', reason: 'the definition is present in the registry but not enabled' };
473
- }
474
- return { decision: 'go', reason: null };
475
- }
449
+ // THE GATE lives in the module, not here: the same question is asked by
450
+ // POST /agents/:name/invoke on the other side of the trust boundary, and a gate
451
+ // with two implementations has two behaviours the day one is edited. Required
452
+ // directly from a script the way agents-sync requires the validator.
453
+ const { gateFor } = require('../../modules/agents/lib/gate.js');
476
454
 
477
455
  // ---- the chain -------------------------------------------------------------
478
456
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
+ const crypto = require('node:crypto');
13
14
  const fs = require('node:fs');
14
15
  const os = require('node:os');
15
16
  const path = require('node:path');
@@ -111,6 +112,177 @@ function standaloneMigrateCmd(inst, { privileged = false } = {}) {
111
112
  // transfer from primea"): its bespoke pull-deploy regenerated nothing.
112
113
  function standaloneRegenDocsCmd() { return `node ${CORE_PKG_DIR}/scripts/gds/regen-instance-docs.js`; }
113
114
 
115
+ // ── Per-instance identity: its OWN unix account + its OWN password-authed PG role ──
116
+ // (task 1003369, audit ref B3 of the 2026-08-29 security audit.)
117
+ //
118
+ // THE HOLE THIS CLOSES. Every instance service used to run `User=lars/Group=lars`, and
119
+ // co-tenant is the DEFAULT shape — so on a shared box every instance ran as the SAME
120
+ // unix user. A unix user may read /proc/<pid>/environ of its own processes, which means
121
+ // any instance could read every SIBLING's environment: GITHUB_APP_PRIVATE_KEY, the
122
+ // <PREFIX>_HUB_CLIENT_SECRET, every OAuth secret web.env carries. The DB half was the
123
+ // same shape one layer down: pool.js connected by PASSWORDLESS peer auth, so any code
124
+ // running as lars could open a pool against any sibling database — including the control
125
+ // plane's own `builders` and `builder_sessions`. Neither was a bug in a route; it was the
126
+ // identity every instance shared. Giving each instance its own unix account and its own
127
+ // password-authed role is what makes the boundary real.
128
+ //
129
+ // NAME SHAPE. A unix account name is capped at 32 chars (utmp) and must match
130
+ // [a-z_][a-z0-9_-]*; a slug is 2–40 of [a-z0-9-] (isValidSlug). So the name cannot be
131
+ // the slug verbatim, and it must not be the BARE slug either — that would let a project
132
+ // named `postgres` or `lars` claim an existing account. Prefixed + length-folded:
133
+ // short slug → bongos-<slug>
134
+ // long slug → bongos-<slug truncated>-<8 hex of sha256(slug)> (still <= 32)
135
+ // The hash suffix keeps two slugs that share a 16-char prefix from folding onto one
136
+ // account — which would hand each the other's identity, the very thing being fixed.
137
+ const INSTANCE_USER_PREFIX = 'bongos-';
138
+ const INSTANCE_USER_MAX = 32; // utmp's cap; useradd enforces it
139
+
140
+ // The unix account an instance's service (and only that instance's service) runs as.
141
+ // Deterministic — the runner derives it fresh every run rather than storing it, so a
142
+ // re-provision can never address a different account than the one it created. PURE.
143
+ function instanceUser(inst) {
144
+ const slug = String((inst && inst.slug) || '');
145
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) {
146
+ throw new Error(`instanceUser: refusing to derive an account name from an invalid slug ${JSON.stringify(slug)}`);
147
+ }
148
+ const whole = INSTANCE_USER_PREFIX + slug;
149
+ if (whole.length <= INSTANCE_USER_MAX) return whole;
150
+ const digest = crypto.createHash('sha256').update(slug).digest('hex').slice(0, 8);
151
+ // Budget: prefix + head + '-' + 8 hex === INSTANCE_USER_MAX. Trailing dashes are
152
+ // stripped so the fold can never emit `--` (which useradd's NAME_REGEX rejects).
153
+ const head = slug.slice(0, INSTANCE_USER_MAX - INSTANCE_USER_PREFIX.length - 9).replace(/-+$/, '');
154
+ return `${INSTANCE_USER_PREFIX}${head}-${digest}`;
155
+ }
156
+
157
+ // The instance account's HOME — and the ONE directory the hardened unit leaves
158
+ // writable. It is deliberately NOT under /home: the unit sets ProtectHome=read-only
159
+ // (so one instance cannot read the operator's or a sibling's home), and the server DOES
160
+ // write inside its config home at runtime — src/bongos/secret-box.js provisions
161
+ // builder-secret.key under ~/.config/<configDir> on first boot. A /home account would
162
+ // make that write EROFS and the instance would come up unable to hold a secret. PURE.
163
+ function instanceStateDir(inst) { return path.posix.join('/var/lib', instanceUser(inst)); }
164
+
165
+ // The instance's OWN Postgres login role. Same name as the unix account so an operator
166
+ // reading `ps` and `pg_stat_activity` sees one identity, not two — a dash is legal in a
167
+ // quoted PG identifier, and every SQL site below quotes it. PURE.
168
+ function instanceDbRole(inst) { return instanceUser(inst); }
169
+
170
+ // A fresh password for that role. base64url ([A-Za-z0-9_-]) on purpose: it needs no
171
+ // escaping in the env file that carries it, in the dollar-quoted SQL literal that sets
172
+ // it, or in a connection URL — the three places it travels. 32 bytes ~ 256 bits.
173
+ // Generated per provision run, not stored: every run ALTERs the role and rewrites
174
+ // web.env in the same pass, so the credential rotates on its own.
175
+ function generateDbPassword() { return crypto.randomBytes(32).toString('base64url'); }
176
+
177
+ // Create the instance's unix account if it is not already there, with its state dir.
178
+ // IDEMPOTENT by construction — `id -u` short-circuits an existing account, and the
179
+ // `install -d` re-asserts ownership and mode on every run, so a re-provision repairs a
180
+ // hand-edited box instead of failing on it. A SYSTEM account with /usr/sbin/nologin:
181
+ // nothing should ever log in as an instance.
182
+ function ensureInstanceUserCmd(inst, { privileged = false } = {}) {
183
+ const user = instanceUser(inst), dir = instanceStateDir(inst), sudo = privileged ? 'sudo ' : '';
184
+ return `id -u ${user} >/dev/null 2>&1 || ${sudo}useradd --system --shell /usr/sbin/nologin --home-dir ${dir} ${user}; ` +
185
+ `${sudo}install -d -o ${user} -g ${user} -m 0700 ${dir}`;
186
+ }
187
+
188
+ // Let the instance account READ the checkout it runs from. The co-tenant shape's
189
+ // WorkingDirectory is this control-plane checkout and the standalone shape's is
190
+ // /srv/<base>/<slug>; both are owned by the app user, and a fresh system account is in
191
+ // neither the owner nor the group, so without this the unit fails at exec with EACCES.
192
+ // Group membership grants exactly the read the app user's group already has — it does
193
+ // NOT re-open the two vectors this task closes: /proc/<pid>/environ stays readable only
194
+ // by the process's own uid, and the DB now demands a password no sibling holds.
195
+ function grantInstanceRepoReadCmd(inst, { privileged = false } = {}) {
196
+ return `${privileged ? 'sudo ' : ''}usermod -aG ${APP_USER} ${instanceUser(inst)}`;
197
+ }
198
+
199
+ // Create-or-rotate the instance's PG login role and hand it its OWN database — and
200
+ // nothing else. Three statements, one psql, ON_ERROR_STOP:
201
+ // 1. CREATE ROLE ... LOGIN PASSWORD, or ALTER ... PASSWORD when it already exists (the
202
+ // re-provision path — this is what rotates the credential).
203
+ // 2. ALTER DATABASE ... OWNER TO — the role owns its own DB, so the app's DDL needs no
204
+ // superuser, and in PG15+ it inherits the public schema through pg_database_owner.
205
+ // 3. REVOKE CONNECT ... FROM PUBLIC — the half that actually isolates. Postgres grants
206
+ // CONNECT on every database to PUBLIC by default, so per-role passwords ALONE would
207
+ // still leave a sibling one peer-auth hop from any other instance's data.
208
+ // NOT a superuser and NOT CREATEDB/CREATEROLE: an instance that is compromised gets its
209
+ // own data and no path to anyone else's.
210
+ //
211
+ // Runs against `postgres`, not the instance DB: ALTER DATABASE ... OWNER cannot run from
212
+ // inside the database it renames the owner of.
213
+ //
214
+ // QUOTING, which is load-bearing in a way that is easy to undo by accident. Every literal
215
+ // is DOLLAR-QUOTED and every identifier goes through format(%I), so the rendered command
216
+ // contains no single quote and NO DOUBLE QUOTE. It has to survive three executors
217
+ // unchanged — inline /bin/sh, `bash -s` over SSH, and, the strict one, `- [ bash, -lc,
218
+ // "..." ]` inside the dedicated droplet's cloud-init, which is a YAML DOUBLE-QUOTED
219
+ // scalar. A plain "role" identifier reads fine in psql and silently truncates the YAML.
220
+ // seedFirstVersionCmd dollar-quotes for the same reason; a test asserts the absence.
221
+ // `peerOnly` is the DEDICATED shape's mode, and it exists because of where that
222
+ // command TRAVELS (task 1003369). A dedicated droplet's steps ride DigitalOcean
223
+ // cloud-init user-data, which DO RETAINS and serves back through its API, its console
224
+ // and the droplet's own metadata endpoint — so a password embedded there is a second,
225
+ // permanent copy of the credential in a place this task cannot lock down, which is
226
+ // strictly worse than what it protects against. A dedicated droplet is SINGLE-TENANT:
227
+ // there are no siblings on it, so the co-tenancy vector B3 describes does not exist,
228
+ // and the role can peer-auth to its own unix account. The role, its DB ownership and
229
+ // the PUBLIC revoke are still created — only the password is omitted, deliberately.
230
+ function dbRoleCmd(inst, { password = null, peerOnly = false, privileged = false } = {}) {
231
+ const role = instanceDbRole(inst), db = dbName(inst);
232
+ if (peerOnly) {
233
+ if (password) throw new Error('dbRoleCmd: peerOnly takes no password — it exists to keep one out of cloud-init user-data');
234
+ } else if (!/^[A-Za-z0-9_-]+$/.test(String(password || ''))) {
235
+ throw new Error('dbRoleCmd: password must be a non-empty base64url string (generateDbPassword)');
236
+ }
237
+ // Two shapes of the same upsert. peerOnly omits the PASSWORD clause entirely rather
238
+ // than passing an empty one — `WITH LOGIN` leaves any existing password untouched.
239
+ const login = peerOnly
240
+ ? { clause: 'WITH LOGIN', args: '' }
241
+ : { clause: 'WITH LOGIN PASSWORD %L', args: `, $p$${password}$p$` };
242
+ const sql =
243
+ `DO $do$ BEGIN ` +
244
+ `IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $r$${role}$r$) THEN ` +
245
+ `EXECUTE format($f$ALTER ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
246
+ `ELSE ` +
247
+ `EXECUTE format($f$CREATE ROLE %I ${login.clause}$f$, $r$${role}$r$${login.args}); ` +
248
+ `END IF; ` +
249
+ `EXECUTE format($f$ALTER DATABASE %I OWNER TO %I$f$, $d$${db}$d$, $r$${role}$r$); ` +
250
+ `EXECUTE format($f$REVOKE CONNECT ON DATABASE %I FROM PUBLIC$f$, $d$${db}$d$); ` +
251
+ `END $do$;`;
252
+ return asPostgres(`psql -v ON_ERROR_STOP=1 -d postgres -c '${sql}'`, privileged);
253
+ }
254
+
255
+ // What the runner LOGS in place of dbRoleCmd, whose SQL embeds the password. Lives here,
256
+ // beside the command, so the two cannot drift: a `shown` that outlived the secret it hides
257
+ // would be a silent re-leak. Names every effect the real command has. PURE.
258
+ function dbRoleCmdShown(inst) {
259
+ return `psql -d postgres -c '<upsert role ${instanceDbRole(inst)} + own ${dbName(inst)} + revoke PUBLIC connect>' (password redacted)`;
260
+ }
261
+
262
+ // Hand the instance role the objects the migrate just created. This is NOT belt-and-braces:
263
+ // the privileged migrate runs as `postgres`, so every table and sequence it creates is
264
+ // OWNED BY postgres. Owning the DATABASE does not carry ownership of objects inside it, so
265
+ // without these grants the instance would authenticate perfectly and then fail its first
266
+ // SELECT. The ALTER DEFAULT PRIVILEGES pair is the same statement aimed forward: it applies
267
+ // to objects the CURRENT role creates later, which is exactly what the next migrate does —
268
+ // so a schema migration shipped six months from now needs no second visit here.
269
+ //
270
+ // Runs against the instance DB (schema grants are per-database), and AFTER the migrate.
271
+ function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
272
+ const role = instanceDbRole(inst), db = dbName(inst);
273
+ // Same quoting rule as dbRoleCmd: format(%I), never a double-quoted identifier.
274
+ const g = (stmt) => `EXECUTE format($f$${stmt} %I$f$, $r$${role}$r$); `;
275
+ const sql =
276
+ `DO $do$ BEGIN ` +
277
+ g('GRANT ALL ON SCHEMA public TO') +
278
+ g('GRANT ALL ON ALL TABLES IN SCHEMA public TO') +
279
+ g('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO') +
280
+ g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO') +
281
+ g('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO') +
282
+ `END $do$;`;
283
+ return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${db} -c '${sql}'`, privileged);
284
+ }
285
+
114
286
  // ── STANDALONE scaffold (ADR 0108 / 0125, task 2055) ──────────────────────────
115
287
  // The MISSING first leg of the greenfield "one guided flow": provision.js used to
116
288
  // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
@@ -831,13 +1003,22 @@ module.exports = {
831
1003
  buildCorePinRefreshCommit,
832
1004
  dbCreateCmd,
833
1005
  dbName,
1006
+ dbRoleCmd,
1007
+ dbRoleCmdShown,
1008
+ dbRoleGrantsCmd,
834
1009
  deployKeyPath,
835
1010
  deployKeyTitle,
1011
+ ensureInstanceUserCmd,
836
1012
  ensurePrivateRepoAccess,
1013
+ generateDbPassword,
1014
+ grantInstanceRepoReadCmd,
837
1015
  installedCorePackDir,
838
1016
  installedCoreTarball,
1017
+ instanceDbRole,
839
1018
  instanceInitSpec,
840
1019
  instanceRepoRemote,
1020
+ instanceStateDir,
1021
+ instanceUser,
841
1022
  migrateCmd,
842
1023
  onboardMode,
843
1024
  parseTargetRef,
@@ -11,10 +11,11 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
  const { CONFIG, REPO_ROOT } = require('./provision-config.js');
14
- const { APP_USER, CORE_PKG_DIR, dbName, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
14
+ const { APP_USER, CORE_PKG_DIR, dbName, instanceDbRole, instanceStateDir, instanceUser, parseTargetRef, standaloneRoot } = require('./provision-repo.js');
15
15
 
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
18
+ const user = instanceUser(inst), stateDir = instanceStateDir(inst);
18
19
  const standalone = inst.hosting_shape === 'standalone';
19
20
  const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
20
21
  const execStart = standalone
@@ -28,10 +29,21 @@ Requires=postgresql.service
28
29
 
29
30
  [Service]
30
31
  Type=simple
31
- User=lars
32
- Group=lars
32
+ # This instance's OWN unix account, never the shared app user (task 1003369, audit B3).
33
+ # Co-tenant is the default shape, so a shared User= let every instance read every
34
+ # sibling's /proc/<pid>/environ — GITHUB_APP_PRIVATE_KEY, the hub client secret, every
35
+ # OAuth cred in the EnvironmentFile below. A distinct uid is what closes that: /proc
36
+ # environ is readable only by the process's own uid.
37
+ User=${user}
38
+ Group=${user}
33
39
  WorkingDirectory=${workDir}
40
+ # Read by PID 1 as root BEFORE the privilege drop, so 0600 root-owned stays correct —
41
+ # the instance account itself never needs read on its own secrets file.
34
42
  EnvironmentFile=${envFile}
43
+ # HOME is the state dir, NOT /home: the server writes inside its config home on first
44
+ # boot (secret-box.js provisions builder-secret.key there) and ProtectHome below makes
45
+ # /home read-only. Without this the instance comes up unable to hold a secret.
46
+ Environment=HOME=${stateDir}
35
47
  Environment=PGDATABASE=${dbName(inst)}
36
48
  Environment=PORT=${inst.port}
37
49
  ExecStart=${execStart}
@@ -40,6 +52,15 @@ RestartSec=3
40
52
  StandardOutput=journal
41
53
  StandardError=journal
42
54
  NoNewPrivileges=true
55
+ # The same hardening the sibling backup unit already carries. ReadWritePaths is the
56
+ # whole allow-list under ProtectSystem=strict: the state dir, plus the instance's own
57
+ # config/ (the modules toggle writes config/modules.json at runtime). The checkout the
58
+ # service RUNS is deliberately not writable by it. The leading dash on config/ keeps a
59
+ # shape that has none from failing to mount rather than failing to start.
60
+ ProtectSystem=strict
61
+ ProtectHome=read-only
62
+ PrivateTmp=true
63
+ ReadWritePaths=${stateDir} -${workDir}/config
43
64
 
44
65
  [Install]
45
66
  WantedBy=multi-user.target
@@ -168,7 +189,7 @@ function provisionedEnvPrefix() {
168
189
  // the hall on this host + OAuth uses the real callback origin — without them the pack
169
190
  // stays at neutral localhost and the hall never loads (task 1972). No domain yet
170
191
  // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
171
- function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null } = {}) {
192
+ function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null, ownerLogin = null, dbPassword = null } = {}) {
172
193
  const prefix = provisionedEnvPrefix(), { settingsEnvLines } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
173
194
  const origin = inst.domain ? `https://${inst.domain}` : '';
174
195
  const originLines = origin
@@ -239,13 +260,20 @@ GITHUB_APP_ID=${appId}
239
260
  GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
240
261
  `
241
262
  : '';
263
+ // The per-instance DB credential (task 1003369, audit B3). Present only once the
264
+ // runner has created the role — a first dry-run and any legacy caller omit it, and the
265
+ // instance then falls back to the peer auth it used before, so this can never brick a
266
+ // box mid-migration. src/bongos/pool.js reads both names explicitly.
267
+ const dbLines = dbPassword
268
+ ? `PGUSER=${instanceDbRole(inst)}\nPGPASSWORD=${dbPassword}\n`
269
+ : '';
242
270
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
243
271
  ${credComment}
244
272
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
245
273
  ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
246
274
  ${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
247
275
  PGDATABASE=${dbName(inst)}
248
- ${fedLines}${settingsEnvLines(inst, prefix)}`;
276
+ ${dbLines}${fedLines}${settingsEnvLines(inst, prefix)}`;
249
277
  }
250
278
  function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
251
279
  return path.posix.join(etcBase, inst.slug, 'web.env');
@@ -50,7 +50,7 @@ const cp = require('node:child_process');
50
50
  const crypto = require('node:crypto');
51
51
 
52
52
  const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
53
- const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
53
+ const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
54
54
  const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
 
@@ -232,6 +232,9 @@ async function provisionInstance(inst, deps) {
232
232
  const boxExec = standalone ? controlExec : exec;
233
233
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
234
234
  const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
235
+ // One fresh per-instance DB password per run (task 1003369, see provision-repo.js): step
236
+ // 2b sets it on the role, step 3 writes it to web.env, so the two cannot disagree.
237
+ const dbPassword = generateDbPassword();
235
238
  log(`provision ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
236
239
 
237
240
  // Idempotency: an already-active instance is a no-op success (re-drained intent).
@@ -374,6 +377,15 @@ async function provisionInstance(inst, deps) {
374
377
  else log(' [code] would refresh the committed @bongos/core pin if it trails the running core (task 1003050)');
375
378
  boxExec(standaloneInstallCmd(), { cwd: root });
376
379
  }
380
+ // ── step 1c: the instance's OWN unix account (task 1003369, audit B3) ─────
381
+ // Idempotent; the group-add is what lets a fresh system account READ the checkout it runs
382
+ // from — without it the unit fails at exec with EACCES. Rationale: provision-repo.js.
383
+ if (local) {
384
+ log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
385
+ boxExec(ensureInstanceUserCmd(inst, { privileged }));
386
+ boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
387
+ } else { log(' (instance unix account created via cloud-init on the droplet)'); }
388
+
377
389
  log(' [db] create + GDS-only migrate');
378
390
  if (local) {
379
391
  boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
@@ -395,9 +407,21 @@ async function provisionInstance(inst, deps) {
395
407
  }
396
408
  } else { log(' (db create + migrate + first-version seed run via cloud-init on the droplet)'); }
397
409
 
410
+ // ── step 2b: the instance's OWN password-authed PG role (task 1003369, audit B3) ──
411
+ // AFTER the migrate: it runs as `postgres`, which therefore OWNS the tables it creates.
412
+ if (local) {
413
+ log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
414
+ // `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
415
+ // on BOTH the dry-run and success paths — the exact leak this task exists to prevent
416
+ // (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
417
+ // call site also suppresses stderr). The GRANTS carry no secret and log in full.
418
+ boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
419
+ boxExec(dbRoleGrantsCmd(inst, { privileged }));
420
+ } else { log(' (per-instance DB role created via cloud-init on the droplet)'); }
421
+
398
422
  // ── step 3: config + per-instance secrets ─────────────────────────────────
399
423
  log(' [config] per-instance web.env');
400
- if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db) }), { mode: 0o600, sudo: true });
424
+ if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
401
425
  else log(' (baked into cloud-init)');
402
426
 
403
427
  // ── step 4: systemd unit + enable ─────────────────────────────────────────
@@ -570,6 +594,10 @@ function dedicatedUserData(inst) {
570
594
  // enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
571
595
  const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
572
596
  const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
597
+ // task 1003369: the same role the local leg creates at step 2b, same position around the
598
+ // migrate, but PASSWORD-LESS (peerOnly) — DO retains user-data and serves it back, and a
599
+ // dedicated droplet is single-tenant. Whole argument on dbRoleCmd.
600
+ const roleLines = ` - [ bash, -lc, "${dbRoleCmd(i, { peerOnly: true })}" ]\n - [ bash, -lc, "${dbRoleGrantsCmd(i)}" ]\n`;
573
601
  return `#cloud-config
574
602
  write_files:
575
603
  - path: ${backupServicePath(i)}
@@ -581,10 +609,12 @@ write_files:
581
609
  permissions: '0644'
582
610
  content: ${timerB64}
583
611
  runcmd:
612
+ - [ bash, -lc, "${ensureInstanceUserCmd(i)}" ]
613
+ - [ bash, -lc, "${grantInstanceRepoReadCmd(i)}" ]
584
614
  - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
585
615
  - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
586
616
  - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
587
- - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
617
+ ${roleLines} - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
588
618
  - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
589
619
  `;
590
620
  }
@@ -1441,6 +1471,8 @@ module.exports = {
1441
1471
  CONFIG, MAX_INTENT_ATTEMPTS, provisionerBotEmail,
1442
1472
  // pure step generators (tested directly)
1443
1473
  dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
1474
+ // per-instance unix account + password-authed PG role (task 1003369, audit B3)
1475
+ instanceUser, instanceStateDir, instanceDbRole, generateDbPassword, ensureInstanceUserCmd, grantInstanceRepoReadCmd, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd,
1444
1476
  backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
1445
1477
  standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1446
1478
  // standalone scaffold (ADR 0108 / 0125, task 2055; consumer-core fix task 1002275)
@@ -45,6 +45,20 @@ if (process.env.DATABASE_URL) {
45
45
  // Peer auth via the local Unix socket on the droplet; PG* env overrides for other environments.
46
46
  host: process.env.PGHOST || '/var/run/postgresql',
47
47
  database: process.env.PGDATABASE || instanceDbName(),
48
+ // The per-instance DB credential (task 1003369, audit B3). A provisioned instance's
49
+ // web.env carries PGUSER/PGPASSWORD naming a role that owns its OWN database and
50
+ // has CONNECT on no other — so a co-tenant box no longer lets one instance open a
51
+ // pool against a sibling's data, or against the control plane's own builders and
52
+ // builder_sessions, simply by running as the same unix user.
53
+ //
54
+ // Named EXPLICITLY rather than left to pg's env defaults, which would resolve the
55
+ // same two vars invisibly. The whole defect this closes was a connection whose
56
+ // identity nothing in the code stated, so the identity is stated here, and a test
57
+ // can assert it. Absent (self-host, dev, an instance provisioned before this landed)
58
+ // the keys are omitted entirely and pg falls back to exactly the peer auth it used
59
+ // before — this cannot brick a box whose role has not been created yet.
60
+ ...(process.env.PGUSER ? { user: process.env.PGUSER } : {}),
61
+ ...(process.env.PGPASSWORD ? { password: process.env.PGPASSWORD } : {}),
48
62
  });
49
63
  }
50
64
 
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.709'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.711'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');