@bongos/core 1.19.355 → 1.19.357

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.
@@ -42,7 +42,11 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
42
42
  // 'domain-attach' (task 1002692, migration provisioning_005): re-assert the
43
43
  // domain-dependent standup legs for an already-ACTIVE instance that was just
44
44
  // given its address via POST /provisioning/instances/:id/domain.
45
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach'];
45
+ // 'settings-apply' (task 1003140, migration provisioning_010): push a changed
46
+ // per-project setting to an already-ACTIVE instance — patch its web.env settings
47
+ // lines in place + restart, since the branding pack is memoized for the process
48
+ // lifetime (task 1003139 built only the standup leg).
49
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply'];
46
50
  // Every state a teardown can be asked for — i.e. everything except the one
47
51
  // state where there is provably nothing left to release. DERIVED from
48
52
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -69,6 +73,17 @@ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 't
69
73
  // 'requested'/'error' only record it — the (re)provision run wires the domain
70
74
  // itself. Mid-flight or torn-down states refuse truthfully.
71
75
  const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
76
+ // The ONE state a settings push is meaningful from (task 1003140). A pre-active
77
+ // instance needs no push — standup writes the same values from the same
78
+ // effectiveSettings() row — and a torn-down one has nothing to restart. The
79
+ // provision intent cannot stand in for it: PROVISION_ENQUEUE_FROM deliberately
80
+ // excludes 'active' (task 1002691).
81
+ const SETTINGS_APPLY_FROM = new Set(['active']);
82
+ // The hosting shapes the runner has a write path to. A 'dedicated' instance is
83
+ // its own droplet, stood up by cloud-init — the runner has no post-hoc exec path
84
+ // to it (the placeManifestCreds refusal) — so the route refuses the push HONESTLY
85
+ // rather than queue one that never lands.
86
+ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'standalone']);
72
87
 
73
88
  // Per-project settings (task 1003040, goal 1000058): the known keys + their
74
89
  // vocabularies, enforced at the API layer — the jsonb `settings` column
@@ -244,31 +259,39 @@ function publicGithubApp(manifestRow) {
244
259
  return { status: String(manifestRow.status), app_slug: manifestRow.app_slug || null };
245
260
  }
246
261
 
247
- // The instance's EFFECTIVE settings: the defaults overlaid with the stored,
248
- // on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
249
- // its default rather than leaking into the read surface. PURE.
250
- // The web.env lines that carry this instance's settings, as the runner writes them
251
- // at standup (task 1003139). PURE (row + prefix in, string out) and owned HERE, next
252
- // to SETTINGS_VOCAB, rather than in provision.js — the vocabulary and the thing that
262
+ // The env variables that carry this instance's settings, as the instance reads
263
+ // them: { <PREFIX>_PLATFORM_VISIBILITY, <PREFIX>_JOINABILITY } (task 1003139 /
264
+ // 1003140). PURE (row + prefix in, object out) and owned HERE, next to
265
+ // SETTINGS_VOCAB, rather than in provision.js — the vocabulary and the thing that
253
266
  // transmits it must not be able to drift. Values come from effectiveSettings, so a
254
267
  // row with no stored settings emits today's behavior EXPLICITLY rather than nothing:
255
- // an instance must never have to infer a policy from an absent variable.
256
- //
257
- // This is the standup leg only. Changing a setting on an ALREADY-RUNNING instance
258
- // needs a push (re-write web.env + restart, since the branding pack is memoized for
259
- // the process lifetime) — that is the 'settings-apply' intent, still to be built.
260
- function settingsEnvLines(row, prefix) {
268
+ // an instance must never have to infer a policy from an absent variable. BOTH legs
269
+ // derive from this one function — the standup write (settingsEnvLines → webEnvBody)
270
+ // and the live push (the runner's settings-apply leg patches exactly these lines).
271
+ function settingsEnvVars(row, prefix) {
261
272
  const s = effectiveSettings(row);
273
+ return Object.fromEntries(Object.keys(SETTINGS_VOCAB).map((key) => [`${prefix}_${key.toUpperCase()}`, s[key]]));
274
+ }
275
+
276
+ // The web.env block the runner writes at STANDUP (task 1003139): a comment that
277
+ // tells the truth about where the values come from, then the settingsEnvVars
278
+ // lines. Changing one on an ALREADY-RUNNING instance is the 'settings-apply'
279
+ // intent (task 1003140): the runner re-writes these lines in place and restarts
280
+ // the service, because the branding pack is memoized for the process lifetime.
281
+ function settingsEnvLines(row, prefix) {
282
+ const lines = Object.entries(settingsEnvVars(row, prefix)).map(([k, v]) => `${k}=${v}`).join('\n');
262
283
  return `# Per-project policy the platform decides and this instance obeys (task
263
284
  # 1003139). Written at standup from the platform's provisioning_instances.settings;
264
- # src/branding.js ENV_OVERRIDES maps these onto project.*. Changing one on a RUNNING
265
- # instance still needs a push (re-write + restart) — not built yet, so today a change
266
- # takes effect on the next provision run.
267
- ${prefix}_PLATFORM_VISIBILITY=${s.platform_visibility}
268
- ${prefix}_JOINABILITY=${s.joinability}
285
+ # src/branding.js ENV_OVERRIDES maps these onto project.*. When the owner changes
286
+ # one, the platform's 'settings-apply' run (task 1003140) re-writes these lines in
287
+ # place and restarts this service — the pack is read once per process.
288
+ ${lines}
269
289
  `;
270
290
  }
271
291
 
292
+ // The instance's EFFECTIVE settings: the defaults overlaid with the stored,
293
+ // on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
294
+ // its default rather than leaking into the read surface. PURE.
272
295
  function effectiveSettings(row) {
273
296
  const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
274
297
  ? row.settings : {};
@@ -607,7 +630,9 @@ async function enqueueIntent(db, instanceId, action, requestedBy) {
607
630
  // blind enqueue collided with such an intent and the API falsely said "teardown
608
631
  // already queued" — the instance leaked). A pending provision is flipped to 'error'
609
632
  // (the state CHECK has no 'superseded'; last_error says why) so the runner never
610
- // stands it up. A RUNNING provision is mid-flight infra work and is never yanked —
633
+ // stands it up. A PENDING settings push is superseded the same way (task 1003140):
634
+ // a push into a project about to be torn down is dead work, and letting it hold the
635
+ // delete hostage would repeat 1002691. A RUNNING intent is mid-flight and never yanked —
611
636
  // the caller gets { conflict: 'open_intent' } with the blocking intent so the route
612
637
  // can answer truthfully. Each statement is atomic and the index stays the arbiter:
613
638
  // any interleaving with the runner or a concurrent enqueue lands on one of these
@@ -617,7 +642,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
617
642
  `UPDATE provisioning_intents
618
643
  SET state = 'error', last_error = 'superseded: teardown requested',
619
644
  updated_at = now(), resolved_at = now()
620
- WHERE instance_id = $1 AND action = 'provision' AND state = 'pending'
645
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply') AND state = 'pending'
621
646
  RETURNING id`,
622
647
  [instanceId]
623
648
  );
@@ -627,6 +652,22 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
627
652
  return { intent, created: false, superseded: false, conflict: 'open_intent' };
628
653
  }
629
654
 
655
+ // Queue a settings push for an ACTIVE instance (task 1003140). The runner reads
656
+ // the row's CURRENT settings when it runs, so a change that lands while an
657
+ // identical intent is still PENDING simply rides that intent — idempotent, and
658
+ // truthfully "already queued". Anything else holding the open slot — other work,
659
+ // or a settings push already RUNNING (which may have read the row before this
660
+ // write) — is reported as { conflict: 'open_intent' } so the route can say so
661
+ // instead of claiming a push that will not carry the change. The state gate and
662
+ // the shape gate are the ROUTE's job (SETTINGS_APPLY_FROM / _SHAPES); this only
663
+ // arbitrates the open slot, like enqueueTeardown.
664
+ async function enqueueSettingsApply(db, instanceId, requestedBy) {
665
+ const { intent, created } = await enqueueIntent(db, instanceId, 'settings-apply', requestedBy);
666
+ if (created) return { intent, created: true };
667
+ if (intent.action === 'settings-apply' && intent.state === 'pending') return { intent, created: false };
668
+ return { intent, created: false, conflict: 'open_intent' };
669
+ }
670
+
630
671
  // The instance's currently-open (pending|running) intent, if any.
631
672
  async function getOpenIntent(db, instanceId) {
632
673
  const { rows } = await db.query(
@@ -817,8 +858,9 @@ async function costLedger(db, { limit = 500 } = {}) {
817
858
  module.exports = {
818
859
  // constants
819
860
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
820
- settingsEnvLines,
821
- PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
861
+ settingsEnvLines, settingsEnvVars,
862
+ PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
863
+ TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
822
864
  SETTINGS_VOCAB, DEFAULT_SETTINGS,
823
865
  // pure
824
866
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
@@ -831,7 +873,7 @@ module.exports = {
831
873
  // data access — verification overlay (task 1002696)
832
874
  listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
833
875
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
834
- enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
876
+ enqueueIntent, enqueueTeardown, enqueueSettingsApply, getOpenIntent, claimNextIntent, resolveIntent,
835
877
  recordEvent, instanceStateForSlug, costLedger,
836
878
  // data access — GitHub App Manifest attempts (task 2080)
837
879
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
@@ -659,6 +659,17 @@ module.exports = function provisioningRoutes() {
659
659
  // Body: any subset of the known keys; strict validation refuses unknown keys
660
660
  // and off-vocabulary values. rank: any authenticated builder (own resource;
661
661
  // archon may change any — the override is audited via the event's actor).
662
+ // The platform's row is updated first; then, for an ACTIVE instance on a shape
663
+ // the runner can reach, a 'settings-apply' intent is enqueued (task 1003140):
664
+ // the control-plane runner patches the instance's web.env in place and
665
+ // RESTARTS it, because the branding pack is memoized for the process lifetime.
666
+ // A save that changes nothing neither writes nor restarts. The response is a
667
+ // request receipt — `saved` is what the platform now stores, and `push`
668
+ // { queued, restart, reachable, message } says whether a restart was queued
669
+ // and when the value takes effect — never a claim the instance already runs
670
+ // it; a client reads that back from the instance's own public GET /instance
671
+ // manifest (brand.project). 409 intent_conflict (details.recorded: true) when
672
+ // the open slot is held by other work — the change IS saved.
662
673
  router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
663
674
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
664
675
  if (!id) return;
@@ -677,13 +688,70 @@ module.exports = function provisioningRoutes() {
677
688
  try {
678
689
  const inst = await provisioning.getInstanceById(pool, id);
679
690
  if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
680
- const updated = await provisioning.updateInstanceSettings(pool, id, patch);
681
- await provisioning.recordEvent(pool, {
682
- instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
683
- detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
684
- actor: String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : 'api:archon',
685
- }).catch(() => {});
686
- res.json({ ok: true, settings: provisioning.effectiveSettings(updated) });
691
+ // The archon override carries WHICH archon (the force-teardown convention) on
692
+ // both the event and the intent it may enqueue below.
693
+ const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
694
+ // A save that changes nothing writes nothing and restarts nothing — a
695
+ // whole-form save or a retried request must not bounce a live project. The
696
+ // one exception is the owner re-kick (the domain route's F19 shape): the same
697
+ // values re-sent while a retired push left its error_note re-queue the push.
698
+ const before = provisioning.effectiveSettings(inst);
699
+ const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
700
+ const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
701
+ if (changed) {
702
+ await provisioning.recordEvent(pool, {
703
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
704
+ detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
705
+ actor,
706
+ }).catch(() => {});
707
+ }
708
+ // The push (task 1003140). The row is now the platform's truth, but a
709
+ // RUNNING instance reads its settings from web.env through a pack memoized
710
+ // for the process lifetime — so an ACTIVE instance on a reachable shape gets
711
+ // a 'settings-apply' intent (web.env patched in place + RESTART, drained by
712
+ // the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
713
+ // the restart is acceptable and that the surface must SAY so plainly. Every
714
+ // other state stores the value and says when it takes effect. One receipt
715
+ // shape on every branch — `restart` = a restart is what applies it,
716
+ // `reachable` = the platform can perform that restart — and state-only
717
+ // sentences throughout (the F16 rule): no wall-clock promises.
718
+ const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
719
+ const receipt = (fields) => ({ reachable, ...fields });
720
+ let push;
721
+ if (!changed && !inst.error_note) {
722
+ push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
723
+ } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
724
+ const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
725
+ push = receipt({ queued: false, restart: false, message: offline
726
+ ? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
727
+ : inst.status === 'error'
728
+ ? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
729
+ : 'Saved. It takes effect when the project finishes standing up.' });
730
+ } else if (!reachable) {
731
+ // A dedicated droplet has no runner write path — refuse the push honestly
732
+ // rather than queue one that never lands (the runner refuses it too).
733
+ push = receipt({ queued: false, restart: true, message:
734
+ 'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' });
735
+ } else {
736
+ let r = null;
737
+ try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
738
+ catch (err) {
739
+ // The value IS saved — say so; a 500 here would deny a durable write.
740
+ log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
741
+ push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
742
+ }
743
+ if (r && r.conflict) {
744
+ return res.fail('intent_conflict', { status: 409,
745
+ message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
746
+ details: { action: r.intent.action, state: r.intent.state, recorded: true } });
747
+ }
748
+ if (r) {
749
+ push = receipt({ queued: r.created, restart: true, message: r.created
750
+ ? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
751
+ : 'Saved. A restart is already queued for this project and will carry this change.' });
752
+ }
753
+ }
754
+ res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
687
755
  } catch (err) {
688
756
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
689
757
  res.fail('settings_update_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.355",
3
+ "version": "1.19.357",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.355",
9
+ "version": "1.19.357",
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.355",
3
+ "version": "1.19.357",
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",
@@ -3407,7 +3407,8 @@ summary{min-height:24px;padding:3px 0;}
3407
3407
  var PROG_FLOWS = {
3408
3408
  standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
3409
3409
  revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
3410
- teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
3410
+ teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
3411
+ settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
3411
3412
  };
3412
3413
  /* No live-region attributes here: this strip is destroyed and rebuilt on
3413
3414
  every 5s tick, and a live region that is itself replaced never announces.
@@ -3459,6 +3460,13 @@ summary{min-height:24px;padding:3px 0;}
3459
3460
  setProgress(progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown'));
3460
3461
  return false;
3461
3462
  }
3463
+ /* task 1003140: a settings change restarts the project — the panel says so, by state */
3464
+ if (oi.action === 'settings-apply') {
3465
+ setProgress(oi.state === 'running'
3466
+ ? progressMarkup(1, 1, 'Applying a settings change — the project restarts to pick it up.', '', 'settings')
3467
+ : progressMarkup(0, 0, 'A settings change is queued — this panel updates when it starts.', '', 'settings'));
3468
+ return false;
3469
+ }
3462
3470
  }
3463
3471
  if (s === 'requested') {
3464
3472
  setProgress(progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup'));
@@ -3897,7 +3905,8 @@ summary{min-height:24px;padding:3px 0;}
3897
3905
  standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
3898
3906
  revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
3899
3907
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
3900
- address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' }
3908
+ address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
3909
+ settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
3901
3910
  };
3902
3911
  function stripMarkup(flow, stageNow, stagesDone, note) {
3903
3912
  var f = FLOWS[flow];
@@ -3930,6 +3939,10 @@ summary{min-height:24px;padding:3px 0;}
3930
3939
  var a = inst.open_intent.action;
3931
3940
  if (a === 'teardown') return stripMarkup('teardown', 0, 0, 'A teardown is queued — this card updates when it starts.');
3932
3941
  if (a === 'domain-attach') return stripMarkup('address', 1, 1, 'Setting up DNS and the secure connection' + (inst.domain ? ' for <strong>' + esc(inst.domain) + '</strong>' : '') + '.');
3942
+ /* task 1003140: a settings change is pushed by restarting the project — say so, plainly */
3943
+ if (a === 'settings-apply') return inst.open_intent.state === 'running'
3944
+ ? stripMarkup('settings', 1, 1, 'Applying a settings change — the project restarts to pick it up.')
3945
+ : stripMarkup('settings', 0, 0, 'A settings change is queued — this card updates when it starts.');
3933
3946
  if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
3934
3947
  return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
3935
3948
  }
@@ -237,6 +237,60 @@ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
237
237
  return path.posix.join(etcBase, inst.slug, 'web.env');
238
238
  }
239
239
 
240
+ // The settings env vars the runner pushes to a RUNNING instance (task 1003140):
241
+ // the module's settingsEnvVars under the prefix the instance actually resolves —
242
+ // the same pair webEnvBody writes at standup, so standup and push cannot drift.
243
+ function settingsEnvVarsFor(inst) {
244
+ const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
245
+ return settingsEnvVars(inst, provisionedEnvPrefix());
246
+ }
247
+
248
+ // The instance's own public manifest — the settings read-back ADR 0190 names
249
+ // (GET /api/gds/instance → brand.project). Sibling of provision-net's healthzCmd:
250
+ // same loopback + timeout, a different question ("did you READ it?").
251
+ function instanceManifestCmd(inst) {
252
+ return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/gds/instance`;
253
+ }
254
+
255
+ // Did the restarted instance CONSUME the pushed settings? Compares each key of
256
+ // effectiveSettings(inst) against the manifest's brand.project (camelCase — the
257
+ // src/branding.js ENV_OVERRIDES targets). true = every key matches; false = it
258
+ // answers with a different value; null = unknown — the manifest carries no
259
+ // project block (a core that predates task 1003139) or did not parse. PURE.
260
+ function settingsConsumed(stdout, inst) {
261
+ const { effectiveSettings } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
262
+ let j;
263
+ try { j = JSON.parse(String(stdout || '').trim()); } catch { return null; }
264
+ const project = j && j.brand && j.brand.project;
265
+ if (!project || typeof project !== 'object') return null;
266
+ const camel = (k) => k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
267
+ return Object.entries(effectiveSettings(inst)).every(([k, v]) => project[camel(k)] === v);
268
+ }
269
+
270
+ // Upsert KEY=VALUE lines into an env-file body IN PLACE (task 1003140): an
271
+ // existing `KEY=` line is replaced where it stands, a missing one is appended at
272
+ // the end — every other line (creds, origins, the federation block, comments) is
273
+ // untouched. This is how a setting reaches a RUNNING instance: the full-template
274
+ // re-write webEnvBody produces would BLANK the OAuth creds placeManifestCreds
275
+ // placed (this leg has no way to know them), so a live file is patched, never
276
+ // regenerated. The oauth-secret.js fillOAuthEnv shape. PURE; keys are matched at
277
+ // line start, exactly (a KEY that is a prefix of another never collides).
278
+ function upsertEnvVars(body, vars) {
279
+ const esc = (k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
280
+ let text = String(body == null ? '' : body);
281
+ const missing = [];
282
+ for (const [key, value] of Object.entries(vars || {})) {
283
+ const re = new RegExp(`^${esc(key)}=.*$`, 'm');
284
+ if (re.test(text)) text = text.replace(re, () => `${key}=${value}`);
285
+ else missing.push(`${key}=${value}`);
286
+ }
287
+ if (missing.length) {
288
+ if (text.length && !text.endsWith('\n')) text += '\n';
289
+ text += `${missing.join('\n')}\n`;
290
+ }
291
+ return text;
292
+ }
293
+
240
294
  // ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
241
295
  // (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
242
296
  // registration is a direct in-process call against the hub pool — no HTTP hop.
@@ -254,9 +308,13 @@ module.exports = {
254
308
  backupTimer,
255
309
  backupTimerPath,
256
310
  backupUnitName,
311
+ instanceManifestCmd,
257
312
  provisionedEnvPrefix,
258
313
  serviceUnit,
259
314
  serviceUnitPath,
315
+ settingsConsumed,
316
+ settingsEnvVarsFor,
317
+ upsertEnvVars,
260
318
  webEnvBody,
261
319
  webEnvPath,
262
320
  };
@@ -51,11 +51,14 @@ 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
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');
54
- const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath } = require('./provision-units.js');
54
+ const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
55
55
  const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
56
56
 
57
57
  function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
58
- return function run(cmd, { allowFail = false, cwd, env } = {}) {
58
+ // `shown` (task 1003140): what to LOG in place of the command text — for a command
59
+ // that carries a secret (a remote file placement embeds the base64 body), so the
60
+ // journal records "write <path> (N bytes)" and never the payload.
61
+ return function run(cmd, { allowFail = false, cwd, env, shown: shownAs } = {}) {
59
62
  // Extra env is passed to the child (not interpolated into the command), so
60
63
  // values like a hostname/IP never need shell quoting. It's shown in the dry-run
61
64
  // plan for transparency — DNS env (BOX_HOSTNAME/BOX_IP/CLOUDFLARE_ZONE) is not
@@ -71,28 +74,37 @@ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = nul
71
74
  const wd = cwd || remoteCwd;
72
75
  const script = `${wd ? `cd ${wd} && ` : ''}${envPrefix}${cmd}`;
73
76
  const sshCmd = `ssh ${sshOpts} ${remoteHost} bash -s`;
74
- const shown = `[ssh ${remoteHost}] ${script}`;
77
+ const shown = `[ssh ${remoteHost}] ${shownAs || script}`;
75
78
  if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
76
79
  try {
77
80
  const stdout = cp.execSync(sshCmd, { input: script, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }).toString();
78
81
  log(` ✓ ${shown}`);
79
82
  return { ok: true, stdout };
80
83
  } catch (e) {
81
- if (allowFail) { log(` ~ ${shown} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
84
+ // The script rides STDIN, so e.message names only the ssh hop — but a
85
+ // `shown` call site is secret-bearing by declaration: log the exit status
86
+ // alone, never stderr (a remote shell may echo the failing line back).
87
+ const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
88
+ if (allowFail) { log(` ~ ${shown} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
82
89
  log(` ✖ ${shown}`);
83
90
  throw e;
84
91
  }
85
92
  }
86
93
  // Local step (control plane / today's behavior). cwd defaults to REPO_ROOT.
87
- const shown = `${envPrefix}${cmd}`;
94
+ const shown = shownAs || `${envPrefix}${cmd}`;
95
+ const label = shownAs || cmd;
88
96
  if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
89
97
  try {
90
98
  const stdout = cp.execSync(cmd, { cwd: cwd || REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env }).toString();
91
- log(` ✓ ${cmd}`);
99
+ log(` ✓ ${label}`);
92
100
  return { ok: true, stdout };
93
101
  } catch (e) {
94
- if (allowFail) { log(` ~ ${cmd} (non-fatal: ${(e.stderr || e.message || '').toString().trim().slice(0, 120)})`); return { ok: true, stdout: '', softFailed: true }; }
95
- log(` ✖ ${cmd}`);
102
+ // With `shown` set the command text is considered secret-bearing, so the
103
+ // non-fatal line carries only the exit status — never e.message, whose
104
+ // "Command failed: <cmd>" would echo the command back into the journal.
105
+ const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
106
+ if (allowFail) { log(` ~ ${label} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
107
+ log(` ✖ ${label}`);
96
108
  throw e;
97
109
  }
98
110
  };
@@ -121,12 +133,15 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
121
133
  ];
122
134
  if (mode) steps.push(`${sp}chmod ${mode.toString(8)} ${p}`);
123
135
  const cmd = steps.join(' && ');
136
+ // The command text embeds the (base64) body — a web.env carries the OAuth
137
+ // secret and the App private key — so the exec logs THIS instead (task 1003140).
138
+ const shown = `write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''})`;
124
139
  if (!apply) {
125
140
  log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) on ${remoteHost}`);
126
- if (exec) exec(cmd);
141
+ if (exec) exec(cmd, { shown });
127
142
  return { dryRun: true };
128
143
  }
129
- exec(cmd);
144
+ exec(cmd, { shown });
130
145
  log(` ✓ wrote ${p} (on ${remoteHost})`);
131
146
  return { ok: true };
132
147
  }
@@ -139,7 +154,8 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
139
154
  if (exec) exec(installCmd);
140
155
  return { dryRun: true };
141
156
  }
142
- fs.writeFileSync(tmp, body);
157
+ // Owner-only while staged: the destination is 0600 and the body may carry secrets.
158
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
143
159
  try { exec(installCmd); } finally { fs.rmSync(tmp, { force: true }); }
144
160
  log(` ✓ wrote ${p} (via sudo install)`);
145
161
  return { ok: true };
@@ -642,6 +658,116 @@ async function domainAttachInstance(inst, deps) {
642
658
  return { ok: true };
643
659
  }
644
660
 
661
+ // ---------------------------------------------------------------------------
662
+ // Push a changed per-project setting to an ALREADY-ACTIVE instance (task
663
+ // 1003140 — the live-push half of the platform→instance settings channel; task
664
+ // 1003139 built the standup half). The branding pack is memoized + deep-frozen
665
+ // for the process lifetime, so a change reaches a running instance only by
666
+ // re-writing its web.env and RESTARTING the service: the domain-attach shape
667
+ // minus the DNS legs. The settings lines are patched IN PLACE (upsertEnvVars) —
668
+ // a template re-write via webEnvBody would blank the OAuth creds that
669
+ // placeManifestCreds placed, which this leg has no way to know. EVERY expected
670
+ // failure — an unreadable file, a refused write, a restart that exits non-zero —
671
+ // RETURNS { ok:false } instead of throwing: the drain loop's catch flips the
672
+ // instance to 'error', which must never happen to a healthy ACTIVE instance over
673
+ // a settings push (ADR 0190).
674
+ // ---------------------------------------------------------------------------
675
+ async function settingsApplyInstance(inst, deps) {
676
+ const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
677
+ // Same box routing as domainAttachInstance: a standalone runs control-plane-local.
678
+ const standalone = inst.hosting_shape === 'standalone';
679
+ const boxExec = standalone ? (deps.controlExec || exec) : exec;
680
+ const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
681
+ const sudoP = privileged ? 'sudo ' : '';
682
+ const vars = settingsEnvVarsFor(inst);
683
+ const shown = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(' ');
684
+ log(`settings-apply ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}): ${shown}`);
685
+
686
+ // Pre-active states get these values at standup from the same row; post-active
687
+ // ones have nothing to restart. A clean no-op (not an error) so a raced
688
+ // teardown never burns the intent's retry budget.
689
+ if (inst.status !== 'active') {
690
+ log(` instance is '${inst.status}' — nothing to push now (standup writes the same values)`);
691
+ return { ok: true, noop: true };
692
+ }
693
+ // A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
694
+ // exec path to it (the placeManifestCreds refusal) — say so rather than report a
695
+ // push that never landed. The route refuses to enqueue for this shape too.
696
+ if (inst.hosting_shape === 'dedicated') {
697
+ return { ok: false, error: `settings push is not automated for a dedicated droplet — set ${shown} in its ${webEnvPath(inst)} and restart the ${inst.slug} service there` };
698
+ }
699
+
700
+ // Read the file as it stands so ONLY the settings lines change. Dry-run runs no
701
+ // command, so it has no file to patch — it PLANS the patch instead of writing a
702
+ // synthetic two-line body (which would read as the exact overwrite this leg
703
+ // exists to avoid). An unreadable or empty file is refused, never "patched":
704
+ // writing two lines over a missing PORT/PGDATABASE would take the instance down.
705
+ const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
706
+ const current = String((read && read.stdout) || '');
707
+ if (!apply) {
708
+ log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
709
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
710
+ boxExec(healthzCmd(inst), { allowFail: true });
711
+ log(` ✓ would restart ${inst.slug} with the new settings (dry-run — nothing ran)`);
712
+ return { ok: true, healthy: null, consumed: null };
713
+ }
714
+ if (!read || read.softFailed || !current.trim()) {
715
+ return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
716
+ }
717
+ try {
718
+ log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
719
+ boxWriteFile(webEnvPath(inst), upsertEnvVars(current, vars), { mode: 0o600, sudo: true });
720
+ log(' [service] restart so the memoized branding pack re-reads them');
721
+ boxExec(`${sudoP}systemctl restart ${inst.slug}`);
722
+ } catch (e) {
723
+ // ok:false, never a throw: the retry re-reads the file (the upsert is
724
+ // idempotent) and restarts again; the instance's status is left alone.
725
+ return { ok: false, error: `could not apply the settings on ${inst.slug}: ${(e && e.message) || e}` };
726
+ }
727
+
728
+ // Verify it came back. PATIENT (the F23 lesson — the manifest probe's budget):
729
+ // the child must boot before it can answer, and a false "down" here is not
730
+ // cosmetic — it arms the owner's re-kick paths. Silence after the WHOLE budget
731
+ // is a verdict on any shape and is recorded immediately (provisionInstance step
732
+ // 7's rule); a loopback PASS proves the process, not the address — the
733
+ // domain-scoped sweep earns that claim.
734
+ log(' [healthz] verify the instance answers after the restart');
735
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
736
+ let healthy = false;
737
+ for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
738
+ const res = boxExec(healthzCmd(inst), { allowFail: true });
739
+ if (res && !res.softFailed) { healthy = true; break; }
740
+ if (i < MANIFEST_VERIFY_TRIES - 1) await sleep(MANIFEST_VERIFY_INTERVAL_MS);
741
+ }
742
+ // Answering is not the same as having READ the new values: a core that predates
743
+ // task 1003139 restarts fine and ignores them. Ask the instance's own manifest
744
+ // (the read-back ADR 0190 names) and record what it says — never a success it
745
+ // did not earn. null = the manifest carries no project block (unknown).
746
+ let consumed = null;
747
+ if (healthy) {
748
+ const m = boxExec(instanceManifestCmd(inst), { allowFail: true });
749
+ consumed = settingsConsumed(m && m.stdout, inst);
750
+ }
751
+ const outcome = !healthy ? 'restarted, but not answering /healthz yet'
752
+ : consumed === true ? 'restarted and reads the new values'
753
+ : consumed === false ? 'restarted, but still reports the OLD values — its core may predate the settings channel'
754
+ : 'restarted; its core does not publish its settings yet (pin predates task 1003139), so whether it reads them is unknown';
755
+ const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
756
+ if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
757
+ // Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
758
+ // from an earlier retired push must not outlive its own recovery.
759
+ if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
760
+ await provisioning.recordEvent(db, {
761
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
762
+ detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
763
+ actor: 'runner',
764
+ }).catch(() => {});
765
+ log(healthy && consumed !== false
766
+ ? ` ✓ ${inst.slug} ${outcome}`
767
+ : ` ⚠ ${inst.slug} ${outcome}${healthy ? '' : ' — its card will read "unreachable"'}`);
768
+ return { ok: true, healthy, consumed };
769
+ }
770
+
645
771
  // ---------------------------------------------------------------------------
646
772
  // Teardown one instance (reverse the runbook; idempotent).
647
773
  // ---------------------------------------------------------------------------
@@ -922,6 +1048,7 @@ async function cmdRunIntents(deps) {
922
1048
  if (intent.action === 'provision') result = await provisionInstance(inst, deps);
923
1049
  else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
924
1050
  else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
1051
+ else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
925
1052
  else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
926
1053
  if (result && result.ok === false) {
927
1054
  if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
@@ -998,7 +1125,8 @@ Usage:
998
1125
 
999
1126
  Commands:
1000
1127
  run-intents drain provisioning_intents (the systemd-timer entry point):
1001
- execute each pending provision/teardown intent
1128
+ execute each pending provision / teardown / domain-attach /
1129
+ settings-apply intent
1002
1130
  provision <slug> provision one instance by slug (composes the standup runbook)
1003
1131
  teardown <slug> tear down one instance by slug
1004
1132
  reconcile re-enqueue instances stuck mid-flight with no open intent
@@ -1108,6 +1236,8 @@ module.exports = {
1108
1236
  // executors + orchestration (injectable deps for tests)
1109
1237
  makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
1110
1238
  domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
1239
+ // live settings push (task 1003140) + the in-place env patch + read-back it rides on
1240
+ settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
1111
1241
  // GitHub App Manifest flow (task 2080, ADR 0133)
1112
1242
  convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1113
1243
  // cross-site identity federation (ADR 0141 §4, task #1002109)