@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.
- package/.bongos-core.json +74 -34
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +24 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/adr/0128-provisioning-runner-privilege-and-db-auth-model.md +2 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +2 -0
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +56 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +506 -3
- package/docs/api-reference.md +14 -2
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/ops-gotchas.md +14 -0
- package/modules/agents/lib/answer-hold.js +91 -0
- package/modules/agents/lib/authoring.js +155 -0
- package/modules/agents/lib/fire-budget.js +109 -0
- package/modules/agents/lib/gate.js +64 -0
- package/modules/agents/routes/agents.js +494 -0
- package/modules/agents/spawn.js +12 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +16 -0
- package/modules/government/catalog.js +10 -0
- package/modules/government/migrations/government_013_agent_atoms.sql +66 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/agent-invoke.js +5 -27
- package/scripts/gds/provision-repo.js +181 -0
- package/scripts/gds/provision-units.js +33 -5
- package/scripts/gds/provision.js +35 -3
- package/src/bongos/pool.js +14 -0
- package/src/module-api.js +1 -1
- package/tests/agents_authoring.mjs +306 -0
- package/tests/agents_routes.mjs +76 -15
- package/tests/agents_write_routes.mjs +461 -0
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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');
|
package/scripts/gds/provision.js
CHANGED
|
@@ -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)
|
package/src/bongos/pool.js
CHANGED
|
@@ -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.
|
|
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');
|