Skip to content

Agent credential bootstrap - Manual Test Plan โ€‹

FieldValue
Branch / PRfeat/admin-and-merchant-portals (no PR opened yet)
Environmentdev VM, main checkout, non-interactive Bash tool shell (PATH has no Cloud SDK entry)
Build flags / confign/a
Build (commit)cb4a10a8
TesterClaude (agent session), live-executed before this project was reported done
Date2026-08-24

Summary โ€‹

#ScenarioResultNotes
1Fresh creation of both credential files[x] pass [ ] fail [ ] blocked24/24 secrets fetched, 0 failed
2gcloud PATH resolution, with a falsifying control[x] pass [ ] fail [ ] blockedcontrol genuinely fails; fix genuinely works
3Idempotent re-run skips already-set keys[x] pass [ ] fail [ ] blocked0 fetched, N skipped on run 2
4Self-healing permissions[x] pass [ ] fail [ ] blockedrun against a synthetic file, not the real secrets
5Concurrent writers never corrupt the target[x] pass [ ] fail [ ] blocked6 real concurrent processes, synthetic target
6lantern doctor reports all four file states[x] pass [ ] fail [ ] blockedhappy path live; other three against synthetic files
7e2e script can parse the real probe file[x] pass [ ] fail [ ] blockedkey presence + length only, value never printed
8No secret value ever appears in bootstrap output[x] pass [ ] fail [ ] blockeddry-run masks with bullets; only key names print
9Both files stay outside git[x] pass [ ] fail [ ] blockedinvisible to git status; gitignored defensively too

Setup / preconditions โ€‹

  • gcloud authenticated as cattreedev@gmail.com (operator's existing ADC), SDK installed at /home/mechelle/google-cloud-sdk, deliberately NOT on PATH in this shell (the exact condition under test).
  • The 24 secrets already existed in GCP Secret Manager, project lantern-app-dev, before this session started.
  • Neither ~/.lantern-agent-probe.env nor ~/.lantern-test-accounts.env existed at session start (confirmed via ls).
  • Scenarios 4 and 5 deliberately run against synthetic scratch files, never the real credential files, so there is no window where a live secret sits at the wrong permission or races a concurrent write.

Scenarios โ€‹

1. Fresh creation of both credential files โ€‹

  • Goal: npm run env:bootstrap (via node tooling/scripts/bootstrap-env.mjs) creates both files from nothing.
  • Preconditions: neither file exists.
  • Steps:
    1. Run node tooling/scripts/bootstrap-env.mjs --dry-run first, confirm the preview.
    2. Run node tooling/scripts/bootstrap-env.mjs for real.
    3. ls -l both files.
  • Expected: both files created, all keys fetched, mode 0600.
  • Verify (data/console): ls -l output; script's own N fetched, N skipped, N failed summary line.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: Dry run previewed all 24 keys masked. Real run: agent-probe 4 fetched/0 skipped/0 failed, test-accounts 20 fetched/0 skipped/0 failed.
Evidence: `-rw------- 1 mechelle mechelle 190 ... .lantern-agent-probe.env` and `-rw------- 1 mechelle mechelle 624 ... .lantern-test-accounts.env`
Follow-up: none

2. gcloud PATH resolution, with a falsifying control โ€‹

  • Goal: prove the resolver is load-bearing, not decorative: the OLD behavior (bare gcloud) must actually fail here, and the NEW behavior (resolved path) must actually succeed, in the identical shell.
  • Preconditions: none.
  • Steps:
    1. Run bare gcloud version in this shell (the control: the code path being replaced).
    2. Run node -e "import('./tooling/shared/gcloud.mjs').then(m => console.log(m.resolveGcloudBin()))".
    3. Run ./lantern doctor and read the gcloud CLI / gcloud auth (ADC) rows.
  • Expected: step 1 fails (falsifying arm: if it succeeded, the whole premise of this project would be wrong and PATH resolution was never the problem). Steps 2 and 3 succeed.
  • Verify (data/console): exit code of step 1; doctor's printed row text.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: Step 1: `/bin/bash: line 4: gcloud: command not found`, exit 127 (the control genuinely fails, confirming this was a real bug, not a hypothetical one). Step 2: resolved to `/home/mechelle/google-cloud-sdk/bin/gcloud`. Step 3: doctor printed "gcloud CLI: installed" and "gcloud auth (ADC): authenticated", both green.
Evidence: raw command output captured in this session's transcript.
Follow-up: none

3. Idempotent re-run skips already-set keys โ€‹

  • Goal: a second env:bootstrap run (no --force) must not re-fetch anything it already wrote.
  • Preconditions: scenario 1 already ran, both files populated.
  • Steps:
    1. Run node tooling/scripts/bootstrap-env.mjs again, no flags.
  • Expected: 0 fetched, 4 skipped for the probe file, 0 fetched, 20 skipped for the test-accounts file (opposite of run 1's 4 fetched, 0 skipped / 20 fetched, 0 skipped).
  • Verify (data/console): the script's own summary line, both files.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: `0 fetched, 4 skipped (already set), 0 failed` and `0 fetched, 20 skipped (already set), 0 failed`, exactly the opposite counts from run 1.
Evidence: command output.
Follow-up: none

4. Self-healing permissions โ€‹

  • Goal: if a credential file's mode ever drifts from 0600 (a stray chmod, an editor save), the next bootstrap run must silently correct it, because writeSecretFile chmods unconditionally on every write, even a run where every key was skipped.
  • Preconditions: a synthetic scratch file at the wrong mode. Never run this against the real credential files (no reason to open even a brief window on a live secret when a synthetic file proves the same code path).
  • Steps:
    1. Create a scratch file, chmod 644 it.
    2. Run the exact tmp-suffix-plus-rename-plus-chmod sequence from writeSecretFile against it.
  • Expected: mode is 0600 afterward.
  • Verify (data/console): ls -l before and after.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: before `-rw-r--r--` (0644), after `-rw-------` (0600).
Evidence: `ls -l` output captured in this session's transcript.
Follow-up: none

5. Concurrent writers never corrupt the target โ€‹

  • Goal: several agent sessions on this shared VM can run env:bootstrap around the same time; the pid-suffixed tmp path plus atomic rename must mean the target file is always either the old complete content or one writer's new complete content, never a mix of two.
  • Preconditions: synthetic scratch target, six standalone Node processes each writing a distinguishable payload.
  • Steps:
    1. Launch 6 processes in the background, all targeting the same synthetic path, all racing.
    2. Wait for all 6 to finish.
    3. Read the final file back; count distinct lines (sort -u | wc -l); check for leftover .tmp.* files.
  • Expected: exactly one distinct line value (proves the file is one writer's complete, uncorrupted output, not an interleaved mix); mode 0600; zero leftover tmp files.
  • Verify (data/console): sort -u <file> | wc -l must equal 1; ls on the tmp glob must find nothing.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: `sort -u | wc -l` = 1 (proc1 won the race, and every one of its 50 repeated lines survived intact). Mode was `-rw-------`. No `.tmp.*` files remained.
Evidence: command output captured in this session's transcript.
Follow-up: the pre-existing `.env.local` writer (`mergeIntoEnvFile`) uses a non-pid-suffixed tmp path and was NOT changed by this project (out of scope; see the project README's "found but not fixed" section).

6. lantern doctor reports all four file states โ€‹

  • Goal: doctor must correctly distinguish missing / wrong-permissions / present-but-incomplete / present-and-complete, and name the exact fix command for the first three.
  • Preconditions: the happy-path case runs against the real files (post scenario 1); the other three run against synthetic files with the exact row-computation logic doctor uses, so the real files are never deliberately broken to watch doctor react.
  • Steps:
    1. Run ./lantern doctor for real, read the two credential-file rows.
    2. Separately, exercise the same row logic against: a nonexistent path, a present-but-0644 path, and a present-but-missing-one-key path.
  • Expected: real run shows both rows green, "present, mode 0600". Synthetic runs show, respectively: "missing (label)" + the bootstrap hint; "wrong permissions, want 0600" + a chmod hint; "present, missing keys: B_KEY" + the bootstrap hint.
  • Verify (data/console): doctor's printed rows; the synthetic harness's printed objects.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: Real doctor run: both rows green, "present, mode 0600". Synthetic harness: all three failure branches produced exactly the expected detail + hint text, and the fourth (happy) branch matched too.
Evidence: doctor's colored terminal output; synthetic harness console output, both in this session's transcript.
Follow-up: none

7. e2e script can parse the real probe file โ€‹

  • Goal: tooling/e2e/admin-signin-tier.e2e.mjs's own parseEnv (unmodified by this project) must read AGENT_PROBE_EMAIL out of the file this project now writes.
  • Preconditions: the real probe file exists (scenario 1).
  • Steps:
    1. Reproduce that script's exact parseEnv function in a one-off node -e, point it at the real file.
    2. Check typeof value === 'string' && value.length > 0, and the parsed key count, without ever printing the value itself.
  • Expected: AGENT_PROBE_EMAIL parses as a non-empty string; key count is 4.
  • Verify (data/console): the boolean and the count, never the value.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: "AGENT_PROBE_EMAIL parsed as non-empty string: true", "key count in file: 4".
Evidence: command output captured in this session's transcript.
Follow-up: none

8. No secret value ever appears in bootstrap output โ€‹

  • Goal: --dry-run and the real run's console output must never print a fetched value, matching the existing .env.local flow's SM-1 masking rule.
  • Preconditions: none.
  • Steps:
    1. Read the full --dry-run output for both new targets.
    2. Read the full real-run output for both new targets.
  • Expected: every printed line for a secret is either a key name with a checkmark, or the literal โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข mask; no line contains an actual credential.
  • Verify (data/console): manual read of the captured console output.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: confirmed by direct read of both outputs; every value-bearing line used the bullet mask, real-run lines showed only "Fetching KEY_NAME... [checkmark]".
Evidence: command output captured in this session's transcript.
Follow-up: none

9. Both files stay outside git โ€‹

  • Goal: the two files must never be trackable by this repo's git, and the .gitignore pattern must match them defensively even though they live at $HOME.
  • Preconditions: both files exist at $HOME.
  • Steps:
    1. git status --short | grep for either filename.
    2. git check-ignore -v both bare filenames.
    3. realpath both files, confirm they resolve outside git rev-parse --show-toplevel.
  • Expected: zero git status matches; check-ignore reports a match against the new .gitignore lines; realpath is outside the repo root.
  • Verify (data/console): command output.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual: 0 matches in git status for both names. `git check-ignore -v` matched both against `.gitignore:51` and `.gitignore:52`. `/home/mechelle/.lantern-agent-probe.env` resolves outside `/home/mechelle/repos/lantern_app`.
Evidence: command output captured in this session's transcript.
Follow-up: none

Notes โ€‹

  • This plan was written after the three pieces were built, because the task's own "Done when" checklist already specified the exact verifications up front; each scenario above reproduces one of those checklist items (or a falsifying control this session added, scenarios 2's control and scenario 5) rather than being drafted blind. Every result is a real, freshly-run command from this session, not a projection.
  • No PR exists yet for feat/admin-and-merchant-portals (checked via gh pr list), so this plan is not yet linked from a PR; link it when one is opened.

Built with VitePress