@bongos/core 1.19.354 → 1.19.356

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.
@@ -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.354",
3
+ "version": "1.19.356",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.354",
9
+ "version": "1.19.356",
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.354",
3
+ "version": "1.19.356",
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)
@@ -169,7 +169,8 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
169
169
  const tagline = (brand.copy && brand.copy.tagline) || '';
170
170
  const cur = (brand.currency && brand.currency.label) || 'credits';
171
171
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
172
- return String(html == null ? '' : html)
172
+ const hall = hallOrigin || origin || '';
173
+ return dropEmptyHrefLinks(String(html == null ? '' : html), { publicOrigin: origin || '', buildersOrigin: hall, sourceRepoUrl: sourceRepo || '' })
173
174
  .split('{{worldName}}').join(esc(world))
174
175
  .split('{{productName}}').join(esc(product))
175
176
  .split('{{Currency}}').join(esc(Cur))
@@ -178,10 +179,23 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
178
179
  .split('{{publicOrigin}}').join(esc(origin || ''))
179
180
  // a pack that names no hall of its own resolves to its OWN apex — never to
180
181
  // whichever instance authored the file
181
- .split('{{buildersOrigin}}').join(esc(hallOrigin || origin || ''))
182
+ .split('{{buildersOrigin}}').join(esc(hall))
182
183
  .split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''))
183
184
  .split('{{instanceBase}}').join(esc(instanceBase || ''));
184
185
  }
186
+ // A link whose href is a BARE origin token that resolves empty is dropped whole,
187
+ // server-side (task 1003242; the branding contract): a pack that names no domains
188
+ // would otherwise stamp `href=""` — a dead hall pill on every page carrying one,
189
+ // and only index.html scrubs those client-side. Exact-token hrefs only: a token
190
+ // inside a longer URL still stamps (an empty prefix leaves a relative path).
191
+ function dropEmptyHrefLinks(html, values) {
192
+ let out = html;
193
+ for (const [token, value] of Object.entries(values)) {
194
+ if (value) continue;
195
+ out = out.replace(new RegExp(`<a\\b[^>]*\\bhref="\\{\\{${token}\\}\\}"[^>]*>[\\s\\S]*?<\\/a>`, 'g'), '');
196
+ }
197
+ return out;
198
+ }
185
199
  // Every token lands in HTML — as element text, or inside a double-quoted
186
200
  // attribute (href/content). Escaping is defence in depth, not a live fix: the
187
201
  // only source is config/branding.json, which is committed, code-reviewed and
package/src/branding.js CHANGED
@@ -204,6 +204,17 @@ function clientBranding(b = branding()) {
204
204
  },
205
205
  rankLabels: b.rankLabels || {},
206
206
  reward: clientReward(),
207
+ // Per-project policy the platform pushes (tasks 1003139 / 1003140). PUBLIC by
208
+ // nature — a gated project's gate says "sign in", an invite-only one's door
209
+ // says so — and the READ-BACK a manage page needs: a settings PATCH on the
210
+ // platform is only a request until this instance restarts with the value, so
211
+ // the honest place to learn what it RESOLVED from its environment is its own
212
+ // GET /instance manifest. Resolved, not enforced: enforcement lands with
213
+ // tasks 1003043 (visibility) and 1003044 (joinability).
214
+ project: {
215
+ platformVisibility: b.project?.platformVisibility || 'public',
216
+ joinability: b.project?.joinability || 'apply',
217
+ },
207
218
  };
208
219
  }
209
220
 
package/src/module-api.js CHANGED
@@ -1138,8 +1138,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1138
1138
  // landed since 1.19.352 with no explicit bump. run 32851392269. (task 1002620)
1139
1139
  // 1.19.354 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1140
1140
  // landed since 1.19.353 with no explicit bump. run 32852045058. (task 1002620)
1141
+ // 1.19.355 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1142
+ // landed since 1.19.354 with no explicit bump. run 32853063015. (task 1002620)
1143
+ // 1.19.356 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1144
+ // landed since 1.19.355 with no explicit bump. run 32861072779. (task 1002620)
1141
1145
  // ---------------------------------------------------------------------------
1142
- const CORE_VERSION = '1.19.354'; // CI auto-patch carrier (ADR 0161); see changelog above
1146
+ const CORE_VERSION = '1.19.356'; // CI auto-patch carrier (ADR 0161); see changelog above
1143
1147
 
1144
1148
  // A namespaced logger so a module's log lines are attributable + consistent.
1145
1149
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -47,6 +47,14 @@ test('R57: clientBranding exposes theme.ui (web), NOT theme.palette (server-only
47
47
  assert.ok(c.theme.colors && c.theme.favicon, 'the WEB theme is what ships');
48
48
  });
49
49
 
50
+ test('clientBranding exposes project policy — the settings read-back (task 1003140), defaulting to today\'s behavior', () => {
51
+ assert.deepEqual(clientBranding({}).project, { platformVisibility: 'public', joinability: 'apply' });
52
+ assert.deepEqual(
53
+ clientBranding({ project: { platformVisibility: 'gated', joinability: 'invite_only' } }).project,
54
+ { platformVisibility: 'gated', joinability: 'invite_only' },
55
+ 'what the instance actually resolved (env override included) is what it publishes');
56
+ });
57
+
50
58
  test('clientBranding is a SMALL allow-list — no server-only field leaks to the browser', () => {
51
59
  const projected = clientBranding({
52
60
  ...OTB_ISH,
@@ -59,7 +67,10 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
59
67
  // reward POLICY, so a builder on a cost-plus-only instance isn't shown a per-task
60
68
  // estimate that will never pay. The allow-list was not updated with it, so this guard
61
69
  // had been failing since that landed (task 1002470).
62
- assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'rankLabels', 'reward', 'theme']);
70
+ // `project` joined the allow-list in task 1003140: the per-project policy is public
71
+ // by nature and is the settings read-back a manage page needs (see clientBranding).
72
+ assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
73
+ assert.deepEqual(Object.keys(projected.project).sort(), ['joinability', 'platformVisibility']);
63
74
  for (const leak of ['domains', 'repo', 'db', 'envPrefix', 'llmProjectName']) {
64
75
  assert.equal(projected[leak], undefined, `${leak} must not reach the client`);
65
76
  }
@@ -19,6 +19,8 @@ const BRAND_OTB = {
19
19
  theme: { colors: {}, fonts: { display: '', body: '', links: [] }, favicon: '', backdrop: '' },
20
20
  copy: { tagline: 'Straight off the boats', hallName: 'Builders’ Hall', notFound: 'Page not found.' },
21
21
  rankLabels: { xenos: 'Xenos', thetes: 'Thetes', metic: 'Metic', archon: 'Archon' },
22
+ // the per-project policy the platform pushes (task 1003140) — the settings read-back
23
+ project: { platformVisibility: 'gated', joinability: 'invite_only' },
22
24
  };
23
25
  const BRAND_VANILLA = {
24
26
  identity: { productName: 'Cloud Bongos', worldName: 'Cloud Bongos', company: { name: '' } },
@@ -45,6 +47,12 @@ test('manifest: schema id + headline name + embedded brand', () => {
45
47
  assert.deepEqual(m.modules, MODULES_OTB);
46
48
  });
47
49
 
50
+ test('manifest: brand.project rides the manifest — the settings read-back a manage page needs (task 1003140 / ADR 0190)', () => {
51
+ const m = buildInstanceManifest({ branding: BRAND_OTB, modules: MODULES_OTB, rankTier: RANK_TIER, repo: REPO, authConfigured: true, openEnrollment: false, devBoxEnabled: true });
52
+ assert.deepEqual(m.brand.project, { platformVisibility: 'gated', joinability: 'invite_only' },
53
+ 'a client reads what the instance RESOLVED from here, never from the platform PATCH response');
54
+ });
55
+
48
56
  test('manifest: rank ladder ordered entry→top, labelled from brand', () => {
49
57
  const m = buildInstanceManifest({ branding: BRAND_OTB, modules: MODULES_OTB, rankTier: RANK_TIER, repo: REPO, authConfigured: true, openEnrollment: false, devBoxEnabled: true });
50
58
  assert.deepEqual(m.ranks.map((r) => r.key), ['xenos', 'thetes', 'metic', 'archon']);
@@ -142,6 +142,24 @@ test('an unstamped link is removed, not left dangling', () => {
142
142
  assert.ok(out.length > 0);
143
143
  });
144
144
 
145
+ test('a pack with NO domains drops the hall pill server-side rather than stamping href="" (task 1003242)', () => {
146
+ // the hub has no client scrubber, so an empty token must never reach it as a dead link
147
+ const HUB = fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.html'), 'utf8');
148
+ assert.match(HUB, /data-link-hall href="\{\{buildersOrigin\}\}"/, 'the hub pill is token-stamped');
149
+ const none = applyBrandTokens(HUB, BRAND, '', '');
150
+ assert.doesNotMatch(none, /href=""/, 'no empty href survives the stamp');
151
+ assert.doesNotMatch(none, /data-link-hall/, 'the pill element is omitted whole, not left dead');
152
+ assert.doesNotMatch(none, /\{\{[a-zA-Z]+\}\}/, 'every other token still stamps');
153
+ const apexOnly = applyBrandTokens(HUB, BRAND, 'https://spike-harbor.example', '');
154
+ assert.match(apexOnly, /data-link-hall href="https:\/\/spike-harbor\.example"/, 'an apex alone keeps the pill, pointed at the apex');
155
+ const both = applyBrandTokens(HUB, BRAND, 'https://spike-harbor.example', 'https://builders.spike-harbor.example');
156
+ assert.match(both, /data-link-hall href="https:\/\/builders\.spike-harbor\.example"/);
157
+ // the landing page too — its client-side scrubber is no longer the only guard
158
+ assert.doesNotMatch(applyBrandTokens(HTML, BRAND, '', ''), /<a\b[^>]*href=""/);
159
+ // a token INSIDE a longer href is a relative path once stamped empty, not a dead link — left alone
160
+ assert.equal(applyBrandTokens('<a href="{{publicOrigin}}/docs">Docs</a>', BRAND, '', ''), '<a href="/docs">Docs</a>');
161
+ });
162
+
145
163
  // ── the branding cascade + portability ─────────────────────────────────────
146
164
 
147
165
  test('the page declares no :root — a page-local one defeats re-branding (C1)', () => {