@bongos/core 1.19.313 → 1.19.315

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.313",
6
- "core_contract": "1.19.313",
7
- "source_commit": "f0f640457ac94cf62a4704d54abe9aa58632e183",
5
+ "core_version": "1.19.315",
6
+ "core_contract": "1.19.315",
7
+ "source_commit": "fa29b8da2f1429be8b46153157348ad0893bb702",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-23T21:45:16.664Z",
9
+ "built_at": "2026-08-23T21:55:22.014Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 323,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1766,
20
- "tree_sha256": "92e06651a3b977ed15aece09dd42e0fa33db37b85170d195d0c1f8a11840144b",
20
+ "tree_sha256": "194e6d23ae1dc848c9e95e48b8383a33a401686fe98169758b9084884818966b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1672,7 +1672,7 @@
1672
1672
  {
1673
1673
  "path": "docs/recipes/core-release-pipeline.md",
1674
1674
  "mode": "0000644",
1675
- "sha256": "b42d8b4620355bef0e27480b2ddfaa07140e322de988839f0e102f1a1545ac47"
1675
+ "sha256": "15517dbf462ce85e7d161b4e173c90376a19ecad3d0cf57a728f066533697bb2"
1676
1676
  },
1677
1677
  {
1678
1678
  "path": "docs/recipes/devbox-app-release.md",
@@ -1797,7 +1797,7 @@
1797
1797
  {
1798
1798
  "path": "docs/recipes/upgrading-the-core.md",
1799
1799
  "mode": "0000644",
1800
- "sha256": "732f3b01b7d2e6cc238dd5f48d5ed774f6c2c27ecc737710047758bf85f2d9e5"
1800
+ "sha256": "82a8a5b17d465b9048ce645e2bc13acaa492f766726cc79981fff1352a92d1b0"
1801
1801
  },
1802
1802
  {
1803
1803
  "path": "docs/recipes/windows-builders.md",
@@ -4582,12 +4582,12 @@
4582
4582
  {
4583
4583
  "path": "package-lock.json",
4584
4584
  "mode": "0000644",
4585
- "sha256": "3e04f2907dd0a7d2cefbde8ae36e307e28fc703febd443d15fd2f204425d534d"
4585
+ "sha256": "501134bb188ffffca60077b5b43b4e068e5713ff2c35002380cddad4a86076b4"
4586
4586
  },
4587
4587
  {
4588
4588
  "path": "package.json",
4589
4589
  "mode": "0000644",
4590
- "sha256": "81d11724ce5ac12446cf9d80adebe66eef5c8e5dc9082fc12ab493dbff60746a"
4590
+ "sha256": "4e8358d26d8d6cdd6c5378b0860ebc58671a75d40ad790bb896000e91f9bd0c7"
4591
4591
  },
4592
4592
  {
4593
4593
  "path": "public-docs/index.html",
@@ -5317,7 +5317,7 @@
5317
5317
  {
5318
5318
  "path": "scripts/gds/go-live.js",
5319
5319
  "mode": "0000644",
5320
- "sha256": "05734e21763cc0b0614275efa76b7b68afabee8be93f3f2d797400d6a97e86c1"
5320
+ "sha256": "fa7803b178d64eda47d4f05032353582f7000359b13caea7d018112f03c9906a"
5321
5321
  },
5322
5322
  {
5323
5323
  "path": "scripts/gds/goal.js",
@@ -5477,7 +5477,7 @@
5477
5477
  {
5478
5478
  "path": "scripts/gds/npmrc.js",
5479
5479
  "mode": "0000644",
5480
- "sha256": "9dfd27e75def392a73d7de7fd23d462afc97b17a8199acfe6563a80692570026"
5480
+ "sha256": "1beffeed111e8528cfa0a629d5ef81c766023de5c6820a81659a390070868228"
5481
5481
  },
5482
5482
  {
5483
5483
  "path": "scripts/gds/oauth-secret.js",
@@ -5877,7 +5877,7 @@
5877
5877
  {
5878
5878
  "path": "scripts/gds/upgrade.js",
5879
5879
  "mode": "0000644",
5880
- "sha256": "04ee659f61c79a1dbc2302f047d1d7870352d0892c2df974f6f000184794d3f8"
5880
+ "sha256": "80856db9974df9b3451decf55385eeb78bcba61e523ca09a2ea070deb48991ab"
5881
5881
  },
5882
5882
  {
5883
5883
  "path": "scripts/gds/validate-design.js",
@@ -6257,7 +6257,7 @@
6257
6257
  {
6258
6258
  "path": "src/module-api.js",
6259
6259
  "mode": "0000644",
6260
- "sha256": "b62609c76a5f302081de9ddec668656fd4d498dd10c545e9f108c4bf764e0ef8"
6260
+ "sha256": "ce9693c479994dbf2f113c783048cd9b3dca8c3cdbc14c655ffdc0f893227eb3"
6261
6261
  },
6262
6262
  {
6263
6263
  "path": "src/module-loader/catalog.js",
@@ -7182,7 +7182,7 @@
7182
7182
  {
7183
7183
  "path": "tests/go_live.mjs",
7184
7184
  "mode": "0000644",
7185
- "sha256": "90c8cbfd8bf0b446261f9fd837b9b08b1035210f1cbf1f7ff81998ec535c32f1"
7185
+ "sha256": "360712fee43b00b410e4b3b8ca27e801ae41966ac0c5ce0a4679fa7943aa554b"
7186
7186
  },
7187
7187
  {
7188
7188
  "path": "tests/goal_abuse_matrix.mjs",
@@ -7977,7 +7977,7 @@
7977
7977
  {
7978
7978
  "path": "tests/npmrc.mjs",
7979
7979
  "mode": "0000644",
7980
- "sha256": "87cbb8a5fd896f36197957be56c9107f672a49b2ae8afc3ba69fe00bde11c3ca"
7980
+ "sha256": "1aaa68ee1a4c4a58563cef86aac44cfa0e046ad7376d3a2108bf6dd455580942"
7981
7981
  },
7982
7982
  {
7983
7983
  "path": "tests/oauth_secret.mjs",
@@ -188,6 +188,11 @@ The permission classifier blocks the agent from running a mutating `sudo -u lars
188
188
  What each piece is for — each guards a failure that actually happened:
189
189
 
190
190
  - **Token extraction runs as lars** (the whole command is inside `sudo -u lars bash -c`). Reading `/home/lars/.npmrc` as `jaxri` is `Permission denied`, which yields an **empty** `NPM_TOKEN`, which sends npm to the **public** registry, which 404s on the private `@bongos/core` — and the upgrade rolls back cleanly, having taught you nothing. The `[ -n "$NPM_TOKEN" ] || exit` guard aborts instead of half-running.
191
+ - **Never put the token in the command line — `sudo` logs the full argv (task 1003218).** The shape above is safe *because* the token is read INSIDE the quoted script, so only the `grep` reaches argv. The tempting variant is not:
192
+ ```
193
+ sudo -u lars -H env NPM_TOKEN="<redacted>" … bongos upgrade … # ← LEAKS
194
+ ```
195
+ The shell expands `$NPM_TOKEN` before `sudo` ever runs, so the real token lands in the system journal — which is persistent on this box. That variant put the live token into 26 journal lines between 2026-08-17 and 2026-08-23. If you need to hand a secret to a privileged child, use `sudo --preserve-env=NPM_TOKEN …`, which passes it through the environment where nothing logs it. Passing "by reference" in the script text is NOT enough; what matters is what argv looks like after expansion.
191
196
  - **`--to <VER>` is required** — `bongos upgrade` refuses to guess a target.
192
197
  - **`--service` + `--health-url` buy the automatic rollback.** Point the health URL at `http://127.0.0.1:3002/healthz`, **not the public URL** — a pre-launch `basic_auth` on the apex makes a healthy boot look failed and triggers a needless rollback.
193
198
  - **`--commit-pin` commits AND pushes the pin.** The instance is a pull-deploy repo: the next sync runs `git reset --hard origin/main`, which reverts an uncommitted pin, and `npm ci` then reinstalls the old core. Nothing errors. This is exactly how live sat 13 releases behind (task 1002712). The same applies to any tracked instance-config edit (`config/branding.json`): commit + push or the next deploy tick undoes it. A commit that fails to *push* saves nothing — deploy.sh resets to the remote.
@@ -116,6 +116,8 @@ Each subscribed instance: read its installed core → ask the registry (`npm vie
116
116
 
117
117
  ## Gotchas
118
118
 
119
+ - **`--registry` no longer needs a hand-exported `NPM_TOKEN`** (task 1002754). The instance `.npmrc` reads `_authToken=<redacted>, and npm gives that per-project key precedence — so with the env var unset it expanded EMPTY and **masked a perfectly valid literal token in `~/.npmrc`** (npm went anonymous; the private core 404'd; the error blamed a "missing" credential the box actually held). `bongos upgrade --registry` now resolves the credential itself: env `NPM_TOKEN` wins, else the **user-level `~/.npmrc`'s literal `//registry.npmjs.org/:_authToken=…`** is injected into the install's environment (forward install and rollback reinstall both). A pin bump on a box whose `~/.npmrc` authenticates therefore succeeds with **no exported env var**. Resolver: `resolveNpmToken()` in [`scripts/gds/npmrc.js`](../../scripts/gds/npmrc.js).
120
+ - **A green `publish` run is not evidence of a publish.** The publish-on-merge lane succeeds on its no-op paths too (cascade terminator, armed-gate). Read the run's **job summary**, which now states the verdict explicitly: `PUBLISHED @bongos/core@<v>` vs `NO-OP — nothing was published this run`.
119
121
  - **`PGDATABASE` is load-bearing on a co-tenant droplet.** `migrate.sh` defaults `PGDATABASE` to `example` — **production**. Always set `PGDATABASE=<instance-db>` explicitly, or you migrate the wrong database.
120
122
  - **The health check polls the restart** (fixed in task [#1984](https://example.com/builders#/task/1984)). The service binds its port ~1s after `systemctl restart`, so `--health-url` now polls up to ~30s for a 2xx before reporting a warning — an immediate `fetch failed` no longer means a failed upgrade. (Pre-1984 cores fetched once and spuriously reported `fetch failed`; there, confirm with `is-active` + a manual `curl`.)
121
123
  - **The ledger auto-inserts via `PGDATABASE` too** (fixed in task [#1984](https://example.com/builders#/task/1984)). A consumer on `PGDATABASE` + peer auth now gets its `core_upgrades` row written automatically — the channel delegates the insert to `psql -d "$PGDATABASE"`, exactly like `migrate.sh`, so no manual `psql INSERT` is needed. (`DATABASE_URL` still works, via the `pg` driver.) Pre-1984 cores only auto-wrote with `DATABASE_URL`; on those, record the row manually.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.313",
3
+ "version": "1.19.315",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.313",
9
+ "version": "1.19.315",
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.313",
3
+ "version": "1.19.315",
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",
@@ -177,22 +177,36 @@ function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
177
177
  // `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
178
178
  // a string safe to embed because every component was allowlist-validated.
179
179
  //
180
- // `forward` names variables to pass through BY REFERENCE (`K="$K"`), for values that
181
- // exist in the calling root shell but must not appear in this script's text. That is
182
- // how NPM_TOKEN reaches the upgrade: sudo's env_reset drops it, and an explicit
183
- // `env K=V` list that omits it drops it too — so `npm install` 404s the PRIVATE
184
- // package and the upgrade auto-rolls-back, which reads as "the release is broken"
185
- // when it is only "the token never arrived". Sourcing the env file in the OUTER shell
186
- // is necessary and NOT sufficient; this is the second half of that trap.
180
+ // `forward` names variables that exist in the calling root shell and must reach the
181
+ // child — NPM_TOKEN above all. sudo's env_reset drops it, and an explicit `env K=V`
182
+ // list that omits it drops it too, so `npm install` 404s the PRIVATE package and the
183
+ // upgrade auto-rolls-back, which reads as "the release is broken" when it is only
184
+ // "the token never arrived". Sourcing the env file in the OUTER shell is necessary
185
+ // and NOT sufficient.
186
+ //
187
+ // A FORWARDED VARIABLE IS A SECRET, SO IT MUST NOT ENTER argv (task 1003218).
188
+ // `env K="$K"` keeps the value out of the script TEXT, but the shell EXPANDS it
189
+ // before sudo runs — and sudo logs the full argv. That is how 26 lines of
190
+ // `COMMAND=... NPM_TOKEN=npm_<real token> ...` ended up in the droplet's PERSISTENT
191
+ // journal between 2026-08-17 and 2026-08-23. Passing by reference is NOT the same as
192
+ // keeping a secret out of argv; only `--preserve-env` does that, handing the variable
193
+ // over through the environment where nothing logs it. Verified side by side on the
194
+ // box: both forms reach the child, only this one leaves no value in the journal.
195
+ //
196
+ // The plain `env K=V` list is still used for NON-secret values (PGDATABASE, HOME) —
197
+ // those are configuration, and seeing them in a log is useful rather than harmful.
187
198
  function asOwner(target, cmd, { forward = [] } = {}) {
188
199
  const envPairs = Object.entries(target.env || {}).map(([k, v]) => `${k}=${v}`);
189
- for (const k of forward) {
190
- if (!SAFE_ENV_KEY_RE.test(k)) continue; // never interpolate an unvetted name
191
- envPairs.push(`${k}="$${k}"`); // by reference — the VALUE is never written here
200
+ const preserve = forward.filter((k) => SAFE_ENV_KEY_RE.test(k)); // never interpolate an unvetted name
201
+ if (!target.runAs) {
202
+ // No privilege drop: the child already inherits our environment, so a forwarded
203
+ // secret needs no mention at all.
204
+ const joined = envPairs.join(' ');
205
+ return `${joined ? `env ${joined} ` : ''}${cmd}`.trim();
192
206
  }
193
- const joined = envPairs.join(' ');
194
- const prefix = target.runAs ? `sudo -u ${target.runAs} -H env ${joined} HOME=/home/${target.runAs}` : (joined ? `env ${joined}` : '');
195
- return `${prefix} ${cmd}`.trim();
207
+ const keep = preserve.length ? `--preserve-env=${preserve.join(',')} ` : '';
208
+ const joined = [...envPairs, `HOME=/home/${target.runAs}`].join(' ');
209
+ return `sudo ${keep}-u ${target.runAs} -H env ${joined} ${cmd}`.trim();
196
210
  }
197
211
 
198
212
  // POSIX single-quote a value for embedding in a shell command.
@@ -48,6 +48,37 @@ function hasNpmToken(env = process.env) {
48
48
  return !!String(env[TOKEN_ENV] || '').trim();
49
49
  }
50
50
 
51
+ // resolveNpmToken (task 1002754) — the install-time credential resolver that removes
52
+ // the hand-exported-NPM_TOKEN step from deploys.
53
+ //
54
+ // THE TRAP. The committed instance `.npmrc` reads `_authToken=${NPM_TOKEN}`; npm gives
55
+ // a per-project key precedence over the user-level one, so with NPM_TOKEN unset the
56
+ // expansion is EMPTY and it MASKS a perfectly valid literal token in ~/.npmrc — npm
57
+ // goes anonymous and the private core 404s. Every deploy then silently depended on the
58
+ // operator remembering `export NPM_TOKEN=…` (the ADR-0162 shape: a human-gated step in
59
+ // the ship path). npm's config syntax cannot express "env var, else fall through", so
60
+ // the fix is to RESOLVE the credential here and feed it to the child install's env —
61
+ // the committed file stays secret-free and unchanged.
62
+ //
63
+ // Order: env NPM_TOKEN wins (explicit operator intent), else the user-level ~/.npmrc's
64
+ // literal `//<host>/:_authToken=<value>` (ignoring env-fed `${…}` values — those are
65
+ // the trap, not a credential). Returns { token, source: 'env'|'user-npmrc'|null }.
66
+ function resolveNpmToken({ env = process.env, homedir, fsImpl = fs, host = REGISTRY_HOST } = {}) {
67
+ const fromEnv = String(env[TOKEN_ENV] || '').trim();
68
+ if (fromEnv) return { token: fromEnv, source: 'env' };
69
+ const home = homedir || (env.HOME || env.USERPROFILE || '');
70
+ if (home) {
71
+ try {
72
+ const raw = fsImpl.readFileSync(path.join(home, '.npmrc'), 'utf8');
73
+ const re = new RegExp(`^\\s*//${host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/:_authToken\\s*=\\s*(.+?)\\s*$`, 'm');
74
+ const m = re.exec(raw);
75
+ const val = m && m[1].replace(/^["']|["']$/g, '');
76
+ if (val && !val.includes('${')) return { token: val, source: 'user-npmrc' };
77
+ } catch { /* no user npmrc, unreadable — fall through to null */ }
78
+ }
79
+ return { token: null, source: null };
80
+ }
81
+
51
82
  // Has the operator opted the standalone scaffold into the registry path? OFF by
52
83
  // default — the vendored tarball stays the default until the registry path is proven
53
84
  // end-to-end (ADR 0134). '1'/'true'/'yes'/'on' (case-insensitive) → true.
@@ -76,7 +107,7 @@ function writeNpmrc(dir, { fsImpl = fs, dryRun = false, overwrite = true, log =
76
107
 
77
108
  module.exports = {
78
109
  REGISTRY_HOST, CORE_SCOPE, TOKEN_ENV, REQUEST_ENV,
79
- npmrcContent, hasNpmToken, registryCoreRequested, writeNpmrc,
110
+ npmrcContent, hasNpmToken, resolveNpmToken, registryCoreRequested, writeNpmrc,
80
111
  };
81
112
 
82
113
  // Tiny CLI: `node scripts/gds/npmrc.js [--print | --write <dir>]` — print the `.npmrc`
@@ -30,7 +30,7 @@ const fs = require('node:fs');
30
30
  const path = require('node:path');
31
31
  const { spawnSync } = require('node:child_process');
32
32
  const { arg, hasFlag } = require('./cli-lib');
33
- const { writeNpmrc, hasNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc
33
+ const { writeNpmrc, hasNpmToken, resolveNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc; resolveNpmToken task 1002754
34
34
 
35
35
  const CORE_PKG = '@bongos/core';
36
36
  const ARTIFACT = 'bongos-core';
@@ -284,10 +284,14 @@ function reportModulePreflight(mods, targetVersion, { log = console.log, err = c
284
284
  return lines;
285
285
  }
286
286
 
287
- function npmInstall(instanceDir, run = spawnSync) {
287
+ function npmInstall(instanceDir, run = spawnSync, extraEnv = null) {
288
288
  // `npm install` (NOT `npm ci`): the pin just changed, so the lockfile must be regenerated from the
289
289
  // new tarball; `npm ci` would refuse on the package.json↔lock divergence.
290
- const r = run('npm', ['install', '--no-audit', '--no-fund'], { cwd: instanceDir, stdio: 'inherit' });
290
+ // extraEnv (task 1002754): the registry path injects a resolved NPM_TOKEN here so the
291
+ // instance `.npmrc`'s `${NPM_TOKEN}` expansion never shadows a valid user-level token.
292
+ const opts = { cwd: instanceDir, stdio: 'inherit' };
293
+ if (extraEnv) opts.env = { ...process.env, ...extraEnv };
294
+ const r = run('npm', ['install', '--no-audit', '--no-fund'], opts);
291
295
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
292
296
  }
293
297
 
@@ -703,8 +707,11 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
703
707
  try { restore(instanceDir, snapshot.prevPin, fsImpl); result.restoreOk = true; log(` ✓ restored pin → ${snapshot.prevPin}`); }
704
708
  catch (e) { result.restoreOk = false; err(` ! could not restore pin (${e.message}) — package.json may need manual repair`); }
705
709
 
706
- // 2. reinstall the previous core.
707
- const inst = npmInstall(instanceDir, run);
710
+ // 2. reinstall the previous core. Same credential resolution as the forward path
711
+ // (task 1002754): the previous pin may be a registry pin too, and a rollback
712
+ // must never fail on the env-shadowing trap the forward install just avoided.
713
+ const rbCred = resolveNpmToken();
714
+ const inst = npmInstall(instanceDir, run, rbCred.source === 'user-npmrc' ? { NPM_TOKEN: rbCred.token } : null);
708
715
  result.reinstallOk = !!inst.ok;
709
716
  if (inst.ok) log(' ✓ reinstalled previous core');
710
717
  else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
@@ -813,9 +820,17 @@ async function runUpgrade(opts, deps = {}) {
813
820
  // REGISTRY (--registry): pin @bongos/core@<version> + ensure the env-fed .npmrc, so
814
821
  // the `npm install` below pulls the PRIVATE package from the registry (needs NPM_TOKEN).
815
822
  // VENDORED (default, --from <tgz>): copy the .tgz into vendor/ + rewrite the file: dep.
823
+ let installEnv = null;
816
824
  if (opts.registry) {
817
825
  if (opts.from) return { ok: false, error: '--registry and --from are mutually exclusive (registry pull vs vendored tarball) — pick one core source' };
818
- if (!hasNpmToken()) log(' ! no NPM_TOKEN in env — `npm install` will 404 on the PRIVATE @bongos/core until a read-only automation token is provided (ADR 0134).');
826
+ // task 1002754 — resolve the credential HERE instead of demanding a hand-exported
827
+ // env var: env NPM_TOKEN wins, else the user-level ~/.npmrc's literal token is
828
+ // injected into the install's env so the instance `.npmrc`'s `${NPM_TOKEN}`
829
+ // expansion (which OUTRANKS the user-level key and expands EMPTY when unset)
830
+ // can never shadow a working credential again.
831
+ const cred = resolveNpmToken();
832
+ if (!cred.token) log(' ! no NPM_TOKEN in env and no literal token in ~/.npmrc — `npm install` will 404 on the PRIVATE @bongos/core until a read-only automation token is provided (ADR 0134).');
833
+ else if (cred.source === 'user-npmrc') { installEnv = { NPM_TOKEN: cred.token }; log(' ✓ registry credential: inherited from the user-level ~/.npmrc (no NPM_TOKEN export needed — task 1002754)'); }
819
834
  const prevPin = dryRun ? null : writeRegistryPin(instanceDir, targetVersion, fsImpl);
820
835
  if (!dryRun) { writeNpmrc(instanceDir, { fsImpl, overwrite: false, log }); snapshot = { prevPin, prevVersion: fromVersion }; }
821
836
  log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${dryRun ? ' + write an env-fed .npmrc' : ''}${prevPin ? ` (was ${prevPin})` : ''}`);
@@ -837,7 +852,7 @@ async function runUpgrade(opts, deps = {}) {
837
852
  if (dryRun) { log(' [dry-run] would: npm install → npm run migrate → materialize .claude → regen API docs+client → restart → verify'); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
838
853
 
839
854
  // 3. npm install.
840
- const inst = npmInstall(instanceDir, run);
855
+ const inst = npmInstall(instanceDir, run, installEnv);
841
856
  if (!inst.ok) {
842
857
  const error = `npm install failed (${inst.reason || 'exit ' + inst.code})`;
843
858
  if (rollbackOnFailure && snapshot) {
package/src/module-api.js CHANGED
@@ -1053,8 +1053,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1053
1053
  // landed since 1.19.311 with no explicit bump. run 32665664699. (task 1002620)
1054
1054
  // 1.19.313 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1055
1055
  // landed since 1.19.312 with no explicit bump. run 32668457836. (task 1002620)
1056
+ // 1.19.314 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1057
+ // landed since 1.19.313 with no explicit bump. run 32668599111. (task 1002620)
1058
+ // 1.19.315 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1059
+ // landed since 1.19.314 with no explicit bump. run 32668969037. (task 1002620)
1056
1060
  // ---------------------------------------------------------------------------
1057
- const CORE_VERSION = '1.19.313'; // CI auto-patch carrier (ADR 0161); see changelog above
1061
+ const CORE_VERSION = '1.19.315'; // CI auto-patch carrier (ADR 0161); see changelog above
1058
1062
 
1059
1063
  // A namespaced logger so a module's log lines are attributable + consistent.
1060
1064
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/go_live.mjs CHANGED
@@ -296,11 +296,17 @@ test("'could not check' is a distinct halt from 'absent'", () => {
296
296
  // `env K=V` list that omits it drops it too. This cost a real failed go-live.
297
297
  // ---------------------------------------------------------------------------
298
298
 
299
- test('a forwarded var is passed BY REFERENCE, never by value', () => {
299
+ test('a forwarded secret NEVER enters argv — it is handed over via --preserve-env', () => {
300
300
  const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { forward: ['NPM_TOKEN'] });
301
- assert.match(cmd, /NPM_TOKEN="\$NPM_TOKEN"/);
302
- // The point of by-reference: the secret's VALUE must never appear in the script text.
303
- assert.ok(!cmd.includes('npm_'), 'a token value must never be interpolated here');
301
+ assert.match(cmd, /--preserve-env=NPM_TOKEN/);
302
+ // THE REGRESSION (task 1003218). `env K="$K"` kept the value out of the script TEXT,
303
+ // but the shell expands it before sudo runs and sudo LOGS THE FULL argv — which put
304
+ // the real token into the droplet's persistent journal 26 times. So the command must
305
+ // not mention the variable as an assignment at all, by reference or otherwise.
306
+ assert.ok(!/NPM_TOKEN=/.test(cmd.replace('--preserve-env=NPM_TOKEN', '')),
307
+ 'a secret must never appear as an argv assignment — sudo logs argv');
308
+ // Non-secret config is still passed plainly; seeing PGDATABASE in a log is useful.
309
+ assert.match(cmd, /PGDATABASE=cb/);
304
310
  });
305
311
 
306
312
  test('forwarding is only wired when the target names an env file to source from', () => {
@@ -310,12 +316,24 @@ test('forwarding is only wired when the target names an env file to source from'
310
316
  assert.deepEqual(G.npmForward({}), []);
311
317
  });
312
318
 
313
- test('an unvetted variable name is never interpolated', () => {
319
+ test('an unvetted variable name never reaches the command', () => {
314
320
  const cmd = G.asOwner({ runAs: 'me' }, 'x', { forward: ['GOOD', 'bad name', '$(id)', '1BAD'] });
315
- assert.match(cmd, /GOOD="\$GOOD"/);
321
+ assert.match(cmd, /--preserve-env=GOOD/);
316
322
  assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
317
323
  });
318
324
 
325
+ test('with no privilege drop a secret needs no mention at all', () => {
326
+ // The child already inherits our environment, so naming it would be pure exposure.
327
+ const cmd = G.asOwner({ env: { A: 'b' } }, 'x', { forward: ['NPM_TOKEN'] });
328
+ assert.equal(cmd, 'env A=b x');
329
+ assert.ok(!cmd.includes('NPM_TOKEN'));
330
+ });
331
+
332
+ test('no forwarding leaves the documented sudo shape untouched', () => {
333
+ assert.equal(G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi'),
334
+ 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
335
+ });
336
+
319
337
  // ---------------------------------------------------------------------------
320
338
  // A failed run must say what the tool said
321
339
  // ---------------------------------------------------------------------------
package/tests/npmrc.mjs CHANGED
@@ -100,6 +100,55 @@ test('writeNpmrc: overwrite:true (default) replaces an existing .npmrc', () => {
100
100
  } finally { rmSync(dir, { recursive: true, force: true }); }
101
101
  });
102
102
 
103
+ // ---- resolveNpmToken (task 1002754) ----------------------------------------
104
+ // The install-time credential resolver that removes the hand-exported-NPM_TOKEN
105
+ // deploy step: env wins, else the user-level ~/.npmrc's LITERAL token; env-fed
106
+ // `${…}` values in the user file are the trap, never a credential.
107
+ test('resolveNpmToken: env NPM_TOKEN wins over everything', () => {
108
+ const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
109
+ try {
110
+ writeFileSync(join(home, '.npmrc'), '//registry.npmjs.org/:_authToken=npm_userlevel\n');
111
+ const r = n.resolveNpmToken({ env: { NPM_TOKEN: ' npm_fromenv ' }, homedir: home });
112
+ assert.deepEqual(r, { token: 'npm_fromenv', source: 'env' });
113
+ } finally { rmSync(home, { recursive: true, force: true }); }
114
+ });
115
+
116
+ test('resolveNpmToken: falls back to the user-level ~/.npmrc literal token', () => {
117
+ const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
118
+ try {
119
+ writeFileSync(join(home, '.npmrc'),
120
+ 'registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=npm_ABC123\nfund=false\n');
121
+ const r = n.resolveNpmToken({ env: {}, homedir: home });
122
+ assert.deepEqual(r, { token: 'npm_ABC123', source: 'user-npmrc' });
123
+ } finally { rmSync(home, { recursive: true, force: true }); }
124
+ });
125
+
126
+ test('resolveNpmToken: an env-fed ${…} value in the user file is the trap, not a credential', () => {
127
+ const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
128
+ try {
129
+ writeFileSync(join(home, '.npmrc'), '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n');
130
+ const r = n.resolveNpmToken({ env: {}, homedir: home });
131
+ assert.deepEqual(r, { token: null, source: null });
132
+ } finally { rmSync(home, { recursive: true, force: true }); }
133
+ });
134
+
135
+ test('resolveNpmToken: a token for a DIFFERENT host is not borrowed', () => {
136
+ const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
137
+ try {
138
+ writeFileSync(join(home, '.npmrc'), '//npm.pkg.github.com/:_authToken=ghp_other\n');
139
+ const r = n.resolveNpmToken({ env: {}, homedir: home });
140
+ assert.deepEqual(r, { token: null, source: null });
141
+ } finally { rmSync(home, { recursive: true, force: true }); }
142
+ });
143
+
144
+ test('resolveNpmToken: no user npmrc at all → null, never a throw', () => {
145
+ const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
146
+ try {
147
+ const r = n.resolveNpmToken({ env: {}, homedir: home });
148
+ assert.deepEqual(r, { token: null, source: null });
149
+ } finally { rmSync(home, { recursive: true, force: true }); }
150
+ });
151
+
103
152
  console.log('');
104
153
  console.log(`npmrc tests: ${passed} passed, ${failed} failed`);
105
154
  process.exit(failed ? 1 : 0);