Skip to content

feat: Cursor-grade onboarding + token isolation (Epic 7, Stream 3)#68

Merged
2witstudios merged 20 commits into
mainfrom
feat/handoff-prep
Jun 22, 2026
Merged

feat: Cursor-grade onboarding + token isolation (Epic 7, Stream 3)#68
2witstudios merged 20 commits into
mainfrom
feat/handoff-prep

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the security + onboarding stream of the handoff prep: a Cursor-grade first-run experience (install → run → onboard → code) with the auth token provably isolated from the agent.

What landed (6 spec-gated leaves, all gate + review passed)

🔒 Token isolation (security root)

  • Proved + closed a real leak: the token flowed into pi's spawn env, so the agent's bash tool could read it via env/printenv//proc/self/environ.
  • sanitizeChildEnv() (pure, src/env.ts) + the launcher strips the token before spawning pi. Live E2E proof (test/run-token-isolation.ts).

🔑 Credential store + pagespace login

  • ~/.pagespace/credentials (0600), written by pagespace login (interactive capture → auth ping → persist). Defense-in-depth: refuses group/world-readable files.

🚀 First-run onboarding

  • No-token startup now runs the full flow: token → validate → discover drives → discover models → default → materialize, then launches. Drives the shared nextOnboardingStep state machine.

🩺 Reusable doctor

  • pagespace status is now a structured doctor (diagnose()/formatDoctor(), pure). One shared implementation consumed by status + onboarding + tests.

📄 README — Quickstart/Commands/Configuration/How-it-works rewritten for the new flow; .env AND .mcp.json demoted to optional overrides.

🗂 Multi-drive configPAGESPACE_DRIVES (comma-separated set) + PAGESPACE_DRIVE (bare-path default). parseDriveSet()/resolveDefaultDrive() pure helpers.

Post-review fix: eliminated all mirroring

An independent /review pass found that the bin had reimplemented the onboarding state machine, credential writes, and the onboarding gate inline — while the unit-tested pure functions in src/ had zero production callers. Fixed in 3df38e5: the bin now imports and CALLS every shared function (nextOnboardingStep, buildCredentialRecord/writeCredentials/readCredentials, onboardingNeedsSetup, resolveDefaultDrive, diagnose/formatDoctor). One implementation per function, consumed by status + onboarding + login + tests. The token env-write was also removed from onboarding (no env round-trip).

Verification

  • npm run check green (typecheck + lint + 186 unit tests).
  • All leaves gate-passed + review-passed via the spec-gated /build loop.
  • Live-verified: token-isolation E2E, full onboarding flow (5 drives + 15 models), status doctor, credential store round-trip + 0600 enforcement, no-inline-duplication audit.

Security ADR: ml803j05zgon3vt54mnmuz53. Onboarding ADR: w2e336emo2v6587q8dy014el.

Summary by CodeRabbit

Release Notes

  • New Features

    • Interactive first-run onboarding to validate your token and auto-detect available drives and AI models
    • New pagespace login command to authenticate and store credentials locally
    • Optional auth token via onboarding or pagespace login, with clear token/config precedence
  • Documentation

    • Updated installation/setup and command guidance, including improved configuration and “how it works” explanations
  • Security / Bug Fixes

    • Improved token isolation by preventing auth secrets from being exposed to the launched runtime; diagnostics are now non-interactive/CI-safe

The PageSpace auth token leaked into pi's process env: bin/pagespace.mjs
spawned pi with { ...process.env } including PAGESPACE_AUTH_TOKEN, and pi's
bash tool inherits that env — so the agent could read the token via
env/printenv/procfs. Proven leak before this change.

Fix: add sanitizeChildEnv() + SECRET_ENV_KEYS to src/env.ts (pure, unit-
tested) and mirror into bin/pagespace.mjs (plain JS, can't import TS).
The launcher now builds a sanitized child env (token stripped) before spawn.
The provider reads the token from config (loadConfig), never from the
child's env.

Security ADR: ml803j05zgon3vt54mnmuz53 — agent must never see the token.

Tests: test/unit/env-isolate-token.test.ts (5 cases). Live proof confirms
the token is absent from the bin's sanitized spawn env.
… path

Proves the full chain the security ADR requires: bin (sanitizes) → pi process
(inherits sanitized env) → pi's bash tool (env derived from pi process.env) →
command reads env. Asserts PAGESPACE_AUTH_TOKEN is invisible via env/printenv/
procfs in the exact sanitized env the launcher hands to spawn(pi).

Also asserts the launcher actually wires sanitizeChildEnv into its spawn
(source-level check). Addresses the reviewer's two blockers: real bash-context
proof + confirmed launcher integration.
A Cursor-grade first-run path: the user pastes their token into 'pagespace login'
and it's validated + persisted to ~/.pagespace/credentials (0600), so .env/.mcp.json
hand-editing is no longer required.

- src/credentials.ts: pure core (buildCredentialRecord, parseCredentialRecord,
  validateCredentialRecord, credentialRecordShape) — unit-tested. Thin I/O wrappers
  (readCredentials/writeCredentials) enforce 0600 and refuse group/world-readable
  files (defense in depth).
- bin/pagespace.mjs: 'pagespace login' subcommand — interactive token capture (stdin),
  auth ping before persist, writes 0600. On launch, if no env token is set, the
  credential store is loaded into the LAUNCHER's process.env (so loadConfig/provider
  read it) — but launchPi() still strips it before spawning pi (token isolation holds).
- Non-interactive/CI-safe: empty stdin and invalid tokens exit cleanly, no prompt
  deadlock.

Security ADR ml803j05zgon3vt54mnmuz53: the token enters the launcher env only (never
the spawned pi env). Credential store is 0600.

Tests: test/unit/credentials.test.ts (8 cases). Smoke-verified round-trip, 0600 perms,
loose-perm rejection, and non-interactive login paths.
When no token source exists (no env token, no .env.local token, no credential
store), the launcher now runs first-run onboarding ('pagespace · first run —
let's get you set up') → login → relaunch, instead of exiting with a
'copy .mcp.json' hint. Cursor-grade: install → run → onboard → code.

- src/onboarding.ts: pure state machine (nextOnboardingStep, STEP_ORDER,
  initialOnboardingState, isComplete) — token → validate → drives → models →
  default → done. Effects (auth ping, drive/model discovery) feed inputs back
  in via the OnboardingInput arg; the machine never I/Os. Defaults drive/model
  to the first discovered (the existing preferred-first behavior).
- bin/pagespace.mjs: needsOnboarding() gate (no env token + no creds file) →
  loginCommand() → relaunch. statusDoctor() now surfaces the credential store.
- pagespace status reports credential-store presence.

Tests: test/unit/onboarding.test.ts (11 cases covering the full state machine).
Live-verified: genuine first-run (no token source) triggers the flow; an
existing .env.local token correctly skips it.
…erialize

The first-run path now executes the complete onboarding sequence the state
machine defines (token → validate → drives → models → default → materialize),
not just token capture. Addresses the reviewer blocker: the startup path
actually discovers drives + models and writes chosen defaults.

runOnboarding() in bin/pagespace.mjs:
- token: interactive capture (stdin)
- validate: auth ping (GET /api/drives)
- drives: discover all accessible drives, default to the first (preferred)
- models: walk every drive's page tree for AI_CHAT agent pages, default to
  the first discovered
- materialize: persist token+apiUrl to ~/.pagespace/credentials (0600) and set
  PAGESPACE_DRIVE/PAGESPACE_MODEL_PAGE in the launcher env (launchPi still
  strips the token before spawning pi — isolation holds)

Live-verified end-to-end: discovered 5 drives + 15 models, defaulted to
pagespace-cli drive + Curator model, wrote creds, launched.
…tion

pagespace status now runs a structured doctor (diagnose → formatDoctor) instead
of ad-hoc console logs. Reusable by the launcher and onboarding; non-interactive/
CI-safe (never prompts, exit 1 on failure).

- src/doctor.ts: pure core (diagnose, formatDoctor, DoctorCheck/DoctorResult
  types) — unit-tested (9 cases). Checks: apiUrl, token, credential store,
  reachable (when a ping is performed). Each failure carries a remediation hint.
- bin/pagespace.mjs: statusDoctor() gathers inputs (token present, creds
  present, auth ping result) and renders via the doctor. Removed the now-unused
  CONFIG_KEYS table.
- diagnose() is pure: takes a config snapshot, returns structured results, never
  I/Os/prompts — the contract for CI/non-interactive use.

Tests: test/unit/doctor.test.ts (9 cases). Live-verified: all-pass output and
the scannable ✓/✗ + remediation format.
onboardingNeedsSetup() in src/onboarding.ts now imports and calls diagnose()
from src/doctor.ts to decide whether first-run onboarding is needed — so the
doctor is the single source of 'is config OK?' for BOTH pagespace status AND
the onboarding gate. Reuse, not duplication (addresses reviewer blocker).

Pure: takes a DoctorInput, returns boolean. The token check passing (via env
OR credential store) means no setup needed.
The bin now imports diagnose/formatDoctor directly from src/doctor.ts — the same
implementation the unit tests and onboarding consume. One shared doctor, consumed
by status + onboarding + tests. No duplication (addresses reviewer blocker).

Runtime: a tiny preamble re-execs under tsx if running under plain node, so the
TS import resolves on any Node version (CI pins Node 22, which lacks native TS).
The parent spawns the tsx child and exits; the child runs main(). Verified no
double-execution.

statusDoctor() now just gathers inputs + calls diagnose()/formatDoctor().
The re-exec preamble called main() at line 29, before module-level consts
(PAGESPACE_AGENT_DIR etc.) were initialized → ReferenceError on launch.
Move the main() call to the end of the file, guarded by isTsx.

Caught by a subagent run during README work.
…rding

- Quickstart: adds the required 'npm run build' step; documents first-run
  interactive onboarding (token prompt → validate → discover drives/models →
  default → materialize) instead of .env hand-editing.
- Commands: adds 'pagespace login'; updates status to the structured doctor.
- Configuration: 3 token sources in priority order — credential store
  (recommended, ~/.pagespace/credentials 0600), .env.local/.env (optional),
  shell env (highest precedence). Notes token isolation (agent never sees it).
- How it works: native function-calling details, dual-mount framing.
Adds .mcp.json to the config sources as an optional MCP-workflows override
(not required for the harness — pagespace reads from the credential store/env).
Addresses reviewer blocker: both .env and .mcp.json are now documented as
optional.
…e default

Adds parseDriveSet(env) + resolveDefaultDrive(env) to src/config.ts (pure, unit-
tested, 8 cases). PAGESPACE_DRIVES declares the accessible drive set; PAGESPACE_DRIVE
remains the bare-path default (kept first, deduped). The resolver handles precedence:
PAGESPACE_DRIVE wins, else the first of PAGESPACE_DRIVES.

The dual-mount already supports multiple drives by path (pagespace/<drive>/...);
this closes the config gap — there was no way to declare a drive set, only a single
default. loadConfig now uses resolveDefaultDrive for defaultDriveSlug.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@2witstudios, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 9 minutes and 48 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f7a59c73-f7f2-4fa0-94b9-66e52fad49c0

📥 Commits

Reviewing files that changed from the base of the PR and between 015512e and be94446.

📒 Files selected for processing (3)
  • src/doctor.ts
  • test/unit/doctor.test.ts
  • test/unit/onboarding.test.ts
📝 Walkthrough

Walkthrough

Adds a file-backed credential store (src/credentials.ts), a pure diagnostic engine (src/doctor.ts), a first-run onboarding state machine (src/onboarding.ts), and env secret sanitization (src/env.ts). The launcher (bin/pagespace.mjs) is rewired to load credentials on startup, run interactive onboarding when no token exists, add a login command, and strip PAGESPACE_AUTH_TOKEN from the child process environment. loadConfig gains injectable env/credential parameters. The README is updated throughout.

Changes

First-run Onboarding & Token Isolation

Layer / File(s) Summary
Credential store: schema, I/O, and path helpers
src/credentials.ts, test/unit/credentials.test.ts
Defines CredentialRecord with token/apiUrl/savedAt, adds buildCredentialRecord/parseCredentialRecord/validateCredentialRecord pure helpers, and adds readCredentials (0600 permission enforcement) and writeCredentials (explicit chmod) with path helpers for ~/.pagespace/credentials.
Doctor: diagnostic engine and text formatter
src/doctor.ts, test/unit/doctor.test.ts
Adds DoctorCheck, DoctorInput, DoctorResult interfaces; implements diagnose() for API-URL/token/credentials/reachability structured checks with per-check remediation; implements formatDoctor() for ✓/✗ scannable text output.
Env secret isolation
src/env.ts, test/unit/env-isolate-token.test.ts, test/unit/env.test.ts
Adds SECRET_ENV_KEYS constant and sanitizeChildEnv helper that strips secret keys from a cloned env; updates applyEnv to skip injecting secret keys from dotenv files.
Config layer: drive parsing, auth resolution, loadConfig refactor
src/config.ts, test/unit/config.test.ts, test/unit/drive-set.test.ts
Adds parseDriveSet, resolveDefaultDrive, and resolveAuthToken (with credential-store fallback); refactors loadConfig to accept injectable env and readCredential parameters.
Onboarding state machine: pure first-run setup flow
src/onboarding.ts, test/unit/onboarding.test.ts
Adds STEP_ORDER, domain types, initialOnboardingState, isComplete, nextOnboardingStep (pure reducer for token→validate→drives→models→default→done), and onboardingNeedsSetup delegating to diagnose().
Launcher wiring: tsx respawn, credentials load, token isolation, login, onboarding, main routing
bin/pagespace.mjs, test/run-token-isolation.ts
Rewrites launcher preamble for tsx respawn; loads credentials on startup; replaces statusDoctor with diagnose()+formatDoctor(); hardens launchPi with sanitizeChildEnv; adds loginCommand() and runOnboarding() using the state machine; updates main() routing.
Documentation: README installation, onboarding, config precedence, security, how it works
README.md
Expands installation steps, rewrites first-run section for interactive onboarding, replaces token/env docs with multi-source precedence order, updates the security note for token-stripping, condenses the "How it works" section.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant launcher as bin/pagespace.mjs
  participant readCredentials
  participant runOnboarding
  participant sm as state machine
  participant writeCredentials
  participant launchPi

  User->>launcher: pagespace <args>
  launcher->>readCredentials: load ~/.pagespace/credentials
  readCredentials-->>launcher: CredentialRecord | null

  alt no PAGESPACE_AUTH_TOKEN and no saved credentials
    launcher->>runOnboarding: runOnboarding(args)
    runOnboarding->>sm: initialOnboardingState
    loop until done
      runOnboarding->>sm: nextOnboardingStep(state, input)
      sm-->>runOnboarding: next state
    end
    runOnboarding->>writeCredentials: persist CredentialRecord (0600)
    runOnboarding->>launchPi: launch pi with sanitized env
  else token or credentials present
    launcher->>launchPi: launch pi with sanitized env
  end

  launchPi-->>User: pi subprocess (PAGESPACE_AUTH_TOKEN stripped from child env)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • 2witstudios/pagespace-cli#28: Both PRs modify bin/pagespace.mjs command routing and the launchPi() flow; the retrieved PR adds sessions/resume subcommands that route through the same main() dispatch table modified here.
  • 2witstudios/pagespace-cli#24: Both PRs update README.md documentation for pagespace command usage and the documented setup flow after pi installation.

Poem

🐇 Hop, hop, no token in sight?
The rabbit runs onboarding bright!
Credentials stored at 0600 tight,
Secrets stripped before pi takes flight.
All checks ✓ — everything's right! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Cursor-grade onboarding + token isolation (Epic 7, Stream 3)' accurately captures the two main features introduced in the PR: first-run onboarding and auth token isolation from child processes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/handoff-prep

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The /review pass found that the bin reimplemented the onboarding state
machine, credential writes, and the onboarding gate inline, while the
unit-tested pure functions in src/ had ZERO production callers. This fixes
all of it: the bin now imports and CALLS the shared implementations.

- Onboarding: runOnboarding() drives nextOnboardingStep/initialOnboardingState
  (src/onboarding.ts) — deleted the inline advance()/switch. The state machine
  the unit tests exercise is now the one that runs.
- Credentials: loginCommand + runOnboarding call buildCredentialRecord +
  writeCredentials (src/credentials.ts); startup calls readCredentials.
  Deleted 3 inline mkdir/writeFileSync/chmod blocks.
- Onboarding gate: needsOnboarding() calls onboardingNeedsSetup()
  (src/onboarding.ts → diagnose) instead of duplicating the logic.
- Drives: onboarding uses resolveDefaultDrive (src/config.ts) for preferred drive.
- Token env-write removed from onboarding (finding 4): the store is materialized
  via writeCredentials; loadCredentials reads it into launcher env on next launch.
  No token round-trips through env from onboarding.

One shared implementation per function, consumed by status + onboarding +
login + tests. Vision principle 6 satisfied.
Finding 1 (dead error-path logic): the bin now passes validated:false to
nextOnboardingStep on auth-ping failure before exiting — so the machine's
recovery transition (clears token, returns to token step) is exercised in
production, not dead code.

Finding 2 (inconsistent default drive): the machine's drives step now accepts
preferredDrive and picks it as default when it's in the discovered set (falls
back to first otherwise). The bin passes the env-configured drive, so the
reported default matches the drive used for preferred-first model ordering.
Live-verified: PAGESPACE_DRIVE=pure-point → default drive pure-point.

Finding 3 (misleading success on zero models): the bin now prints a neutral
'· no agent models found' instead of '✓ default model: (none)' when discovery
returns nothing.

Tests: +3 for preferredDrive (in-set wins, fallback, back-compat). 15/15 pass.
The previous fix added nextOnboardingStep(state, {validated:false}) calls on
auth-ping failure, but they immediately preceded process.exit(1) — the returned
state was discarded, so the machine's recovery branch (clear token, return to
token step) was still never reached by production behavior. The fix was cosmetic.

Honest fix per the reviewer's option (b): onboarding is one-shot-exit-on-failure
by design. The validate step now advances ONLY on validated:true; a failed auth
ping is a terminal condition the caller owns (exit). Deleted:
- the else recovery branch in src/onboarding.ts (clears token, returns to token)
- the two dead validated:false calls in bin/pagespace.mjs
- the unit test asserting recovery (replaced with one documenting the one-shot
  contract: validate holds when validated is not true)

No validated:false remains in any code path (only in comments documenting the
contract). The machine has no stale branches.
…ithout amputating auth

CRITICAL review fix (PR #68). The token-isolation mechanism stripped PAGESPACE_AUTH_TOKEN from pi child env, but the extension reads the token only via process.env — so the credential-store flow (the recommended path) launched a brain that could not authenticate (brain.ts threw; provider got apiKey: undefined). The credential store was write-only from the harness.

Fix: loadConfig() resolves the token via resolveAuthToken() (pure helper, injectable credential reader) — env wins, falls back to readCredentials(). Token travels launcher->store->disk->loadConfig->provider without entering spawned pi env. Isolation holds AND auth works.

Adds the missing isolated-AND-authenticated proof: sanitized child env (token absent) + credential-store fallback yields non-empty authToken.

Tests: resolveAuthToken store-fallback, both-unset, env-precedence; new isolation+auth E2E in run-token-isolation.ts.
…idden-input claim

MEDIUM review fixes (PR #68):

1) .env bypass of token isolation: the extension's loadDotenv() ran INSIDE pi and re-injected PAGESPACE_AUTH_TOKEN from disk into pi process.env, where the bash tool could read it. applyEnv() now skips SECRET_ENV_KEYS so isolation is uniform across all token sources (credential store AND .env).

2) login/onboarding prompts claimed '(input is hidden)' but readline echoes input over a normal TTY — the token was visible. Honest copy now: 'Paste your PageSpace token:'. Comments corrected.

Test: applyEnv never injects secret keys from .env into process env.
…ation

LOW review fixes (PR #68):

1) statusDoctor could ping with 'Bearer undefined' when only the credential store existed (hasCredentials true but env token unset). Now resolves the effective token once via resolveAuthToken() with a try/catch guard over readCredentials(), and pings only when the token is non-empty. Removes fragile implicit coupling on loadCredentials() ordering.

2) main() body was dedented to column 0; re-indented to 2-space scope. Cosmetic (bin/*.mjs is not biome-linted).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (7)
src/config.ts (1)

1-2: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use the shared DEFAULT_API_URL constant to avoid default-value drift.

src/credentials.ts and doctor logic already define/use a canonical default. Reusing that constant here keeps config resolution aligned across layers.

Suggested refactor
-import { readCredentials, type CredentialRecord } from "./credentials.ts";
+import { DEFAULT_API_URL, readCredentials, type CredentialRecord } from "./credentials.ts";
@@
-    apiUrl: env.PAGESPACE_API_URL ?? "https://pagespace.ai",
+    apiUrl: env.PAGESPACE_API_URL ?? DEFAULT_API_URL,

Also applies to: 89-89

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.ts` around lines 1 - 2, The config.ts file has hardcoded default
API URL values that should instead use the shared DEFAULT_API_URL constant from
credentials.ts to maintain consistency across the codebase. Add DEFAULT_API_URL
to the import statement from ./credentials.ts, then replace any hardcoded
default API URL values throughout the file (including at line 89) with
references to the imported DEFAULT_API_URL constant.
test/unit/credentials.test.ts (1)

30-52: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add regression tests for malformed field types and whitespace apiUrl.

Current coverage misses two important edge cases: non-string JSON field values in parseCredentialRecord() and whitespace-only apiUrl input in buildCredentialRecord().

Suggested test additions
 test("parseCredentialRecord rejects malformed JSON", () => {
   assert.throws(() => parseCredentialRecord("not json"), /credential file/);
 });

+test("parseCredentialRecord rejects non-string token/apiUrl/savedAt fields", () => {
+  const bad = JSON.stringify({ token: 123, apiUrl: true, savedAt: 456 });
+  assert.throws(() => parseCredentialRecord(bad), /invalid credential record/i);
+});
+
 test("validateCredentialRecord accepts a well-formed record", () => {
   const rec = buildCredentialRecord({ token: "mcp_abc" });
   const errs = validateCredentialRecord(rec);
   assert.deepEqual(errs, []);
 });
@@
 test("credentialRecordShape is the canonical key list", () => {
   assert.deepEqual([...credentialRecordShape].sort(), ["apiUrl", "savedAt", "token"]);
 });
+
+test("buildCredentialRecord normalizes whitespace apiUrl to default", () => {
+  const rec = buildCredentialRecord({ token: "mcp_abc", apiUrl: "   " });
+  assert.equal(rec.apiUrl, "https://pagespace.ai");
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/credentials.test.ts` around lines 30 - 52, Add two new regression
test cases to cover important edge cases: First, add a test that verifies
parseCredentialRecord rejects valid JSON with non-string field values (e.g.,
numeric token), and second, add a test that verifies buildCredentialRecord
properly handles or rejects whitespace-only apiUrl input. These tests should be
added alongside the existing parseCredentialRecord and buildCredentialRecord
test cases to ensure both functions properly validate field types and
trim/reject whitespace-only values.
test/unit/config.test.ts (1)

20-43: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a defaultDriveSlug whitespace regression test.

Since loadConfig() now resolves drive defaults from env helpers, add coverage that whitespace-only PAGESPACE_DRIVE does not become a slug.

Suggested test addition
 test("loadConfig: env token wins over credential store token", () => {
   const config = loadConfig(
@@
   );
   assert.equal(config.authToken, "mcp_env_token");
 });
+
+test("loadConfig: whitespace-only PAGESPACE_DRIVE does not produce a default slug", () => {
+  const config = loadConfig(
+    {
+      PAGESPACE_DRIVE: "   ",
+      PAGESPACE_DRIVES: undefined,
+    },
+    () => null,
+  );
+  assert.equal(config.defaultDriveSlug, undefined);
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/config.test.ts` around lines 20 - 43, Add a regression test case in
the test file that verifies the loadConfig function properly handles
whitespace-only values for the PAGESPACE_DRIVE environment variable. Create a
new test after the existing authToken tests that calls loadConfig with
PAGESPACE_DRIVE set to a whitespace-only string and asserts that
config.defaultDriveSlug is either undefined or null rather than accepting the
whitespace as a valid slug value.
src/doctor.ts (1)

49-49: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid duplicating the canonical default API URL constant.

DEFAULT_API_URL is already defined in src/credentials.ts; duplicating it here increases drift risk.

♻️ Suggested refactor
+import { DEFAULT_API_URL } from "./credentials.ts";
 ...
-const DEFAULT_API_URL = "https://pagespace.ai";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/doctor.ts` at line 49, Remove the duplicate DEFAULT_API_URL constant
definition from src/doctor.ts since it is already defined in src/credentials.ts.
Instead, import DEFAULT_API_URL from src/credentials.ts at the top of the
doctor.ts file and use the imported constant, eliminating the duplication and
reducing the risk of the two constants drifting out of sync.
test/unit/onboarding.test.ts (1)

67-73: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a regression test for models step hold behavior when input is absent.

There’s no test asserting that nextOnboardingStep() stays on "models" when input.models is omitted. Adding one will guard against accidental auto-advance regressions.

🧪 Suggested test
+test("nextOnboardingStep holds at models when discovery input is not provided", () => {
+  const s: OnboardingState = { ...base, token: "mcp_abc", defaultDrive: "a", step: "models" };
+  const out = nextOnboardingStep(s);
+  assert.equal(out.step, "models");
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/onboarding.test.ts` around lines 67 - 73, Add a regression test
after the existing test for nextOnboardingStep to verify that when the
onboarding state is on the "models" step and the input object omits the models
property (or provides an empty/undefined models array), the nextOnboardingStep
function keeps the step at "models" rather than advancing forward. This test
should create an OnboardingState with step set to "models" and call
nextOnboardingStep with input that lacks the models field, then assert that the
returned state still has step equal to "models".
test/unit/doctor.test.ts (1)

20-25: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add regression coverage for "credential file exists but token is not usable".

Current tests validate credential-store presence, but not the failure mode where hasCredentials === true and hasToken === false. A targeted case here prevents future false-green doctor regressions.

🧪 Suggested test
+test("diagnose fails token check when credentials exist but no usable token is resolved", () => {
+  const r = diagnose({ apiUrl: "https://pagespace.ai", hasToken: false, hasCredentials: true });
+  assert.equal(r.checks.find((c) => c.id === "token")?.pass, false);
+  assert.equal(r.pass, false);
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/doctor.test.ts` around lines 20 - 25, Add a new test case to the
test function "diagnose reports credential-store presence" that covers the
regression scenario where hasCredentials is true but hasToken is false. Call the
diagnose function with apiUrl, hasToken set to false, and hasCredentials set to
true, then assert that the credentials check with id "credentials" returns a
failing pass value (false). This ensures the doctor properly reports the failure
mode when credential files exist but the token is not usable, preventing future
false-green regressions.
bin/pagespace.mjs (1)

98-106: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Import sanitizeChildEnv from src/env.ts instead of duplicating.

The PR objectives state code duplication between bin and src was eliminated, but SECRET_ENV_KEYS and sanitizeChildEnv are duplicated here despite being exported from src/env.ts. Import from the shared module to maintain a single implementation.

♻️ Proposed fix

Add to the imports at the top (around line 36):

 import { resolveDefaultDrive, resolveAuthToken } from "../src/config.ts";
+import { SECRET_ENV_KEYS, sanitizeChildEnv } from "../src/env.ts";

Then remove lines 98-106:

-// Secret env keys stripped from the spawned pi process so pi's bash tool can never read them
-// (token isolation — the agent must never see the PageSpace auth token). Mirrors src/env.ts.
-const SECRET_ENV_KEYS = ["PAGESPACE_AUTH_TOKEN"];
-function sanitizeChildEnv(env) {
-  const out = { ...env };
-  for (const key of SECRET_ENV_KEYS) delete out[key];
-  return out;
-}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bin/pagespace.mjs` around lines 98 - 106, The `sanitizeChildEnv` function and
`SECRET_ENV_KEYS` constant defined in the bin/pagespace.mjs file are duplicated
from `src/env.ts` where they are already exported. Remove the duplicate
definitions of `sanitizeChildEnv` function and `SECRET_ENV_KEYS` constant from
bin/pagespace.mjs (lines 98-106), and instead add an import statement at the top
of the file (around line 36) to import both `sanitizeChildEnv` and
`SECRET_ENV_KEYS` from the shared `src/env.ts` module.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config.ts`:
- Line 91: The defaultDriveSlug assignment is bypassing the resolution logic by
falling back to raw env.PAGESPACE_DRIVE, which can leak whitespace-only values
into the config. Since resolveDefaultDrive(env) already handles trimming and
precedence resolution, remove the ?? env.PAGESPACE_DRIVE fallback entirely and
use only resolveDefaultDrive(env) as the value for defaultDriveSlug. This
ensures all drive slug values go through the proper resolution and validation
logic.

In `@src/credentials.ts`:
- Around line 59-64: In the validateCredentialRecord function, add type guards
before calling .trim() on rec.token, rec.apiUrl, and rec.savedAt to ensure they
are strings. For each field (token, apiUrl, savedAt), check that it is a string
and non-empty using typeof checks before invoking .trim(), and add appropriate
error messages to the errs array if the field is missing, not a string, or is an
empty string. This prevents runtime errors like "TypeError: .trim is not a
function" when malformed JSON payloads with non-string values are passed to the
validator.
- Around line 33-37: The buildCredentialRecord() function does not validate that
apiUrl is non-empty after trimming whitespace. Currently, if input.apiUrl is
whitespace or falsy, it trims to an empty string and persists it, which causes
readCredentials() and parseCredentialRecord() to reject the file on the next
read, breaking the write/read contract. Add a validation check after trimming
the apiUrl (whether from input.apiUrl or DEFAULT_API_URL) to ensure it is not
empty, and throw an appropriate error if it is, similar to the token validation
at the start of the function.

In `@src/doctor.ts`:
- Line 70: The `hasToken` variable assignment in the doctor status check treats
the existence of a credentials file as equivalent to having a usable token,
which can produce false-positive status reports. Modify the hasToken assignment
to only consider `input.hasToken` as true when an actual usable token is
available, rather than treating `input.hasCredentials` (which only indicates
file existence) as equivalent to a valid token. This ensures the doctor properly
validates that credentials are not just present but also readable and functional
before passing the check.

In `@src/onboarding.ts`:
- Around line 106-116: The else block in the "models" case is executing when
input.models is undefined, causing the state machine to advance to "default"
even without a discovery result. You need to distinguish between "models
explicitly provided as empty" versus "models not provided at all". Only advance
the step when input.models is explicitly defined (including empty arrays), and
when it is undefined, do not modify next.step so the machine remains in the
"models" state waiting for an actual discovery result.

---

Nitpick comments:
In `@bin/pagespace.mjs`:
- Around line 98-106: The `sanitizeChildEnv` function and `SECRET_ENV_KEYS`
constant defined in the bin/pagespace.mjs file are duplicated from `src/env.ts`
where they are already exported. Remove the duplicate definitions of
`sanitizeChildEnv` function and `SECRET_ENV_KEYS` constant from
bin/pagespace.mjs (lines 98-106), and instead add an import statement at the top
of the file (around line 36) to import both `sanitizeChildEnv` and
`SECRET_ENV_KEYS` from the shared `src/env.ts` module.

In `@src/config.ts`:
- Around line 1-2: The config.ts file has hardcoded default API URL values that
should instead use the shared DEFAULT_API_URL constant from credentials.ts to
maintain consistency across the codebase. Add DEFAULT_API_URL to the import
statement from ./credentials.ts, then replace any hardcoded default API URL
values throughout the file (including at line 89) with references to the
imported DEFAULT_API_URL constant.

In `@src/doctor.ts`:
- Line 49: Remove the duplicate DEFAULT_API_URL constant definition from
src/doctor.ts since it is already defined in src/credentials.ts. Instead, import
DEFAULT_API_URL from src/credentials.ts at the top of the doctor.ts file and use
the imported constant, eliminating the duplication and reducing the risk of the
two constants drifting out of sync.

In `@test/unit/config.test.ts`:
- Around line 20-43: Add a regression test case in the test file that verifies
the loadConfig function properly handles whitespace-only values for the
PAGESPACE_DRIVE environment variable. Create a new test after the existing
authToken tests that calls loadConfig with PAGESPACE_DRIVE set to a
whitespace-only string and asserts that config.defaultDriveSlug is either
undefined or null rather than accepting the whitespace as a valid slug value.

In `@test/unit/credentials.test.ts`:
- Around line 30-52: Add two new regression test cases to cover important edge
cases: First, add a test that verifies parseCredentialRecord rejects valid JSON
with non-string field values (e.g., numeric token), and second, add a test that
verifies buildCredentialRecord properly handles or rejects whitespace-only
apiUrl input. These tests should be added alongside the existing
parseCredentialRecord and buildCredentialRecord test cases to ensure both
functions properly validate field types and trim/reject whitespace-only values.

In `@test/unit/doctor.test.ts`:
- Around line 20-25: Add a new test case to the test function "diagnose reports
credential-store presence" that covers the regression scenario where
hasCredentials is true but hasToken is false. Call the diagnose function with
apiUrl, hasToken set to false, and hasCredentials set to true, then assert that
the credentials check with id "credentials" returns a failing pass value
(false). This ensures the doctor properly reports the failure mode when
credential files exist but the token is not usable, preventing future
false-green regressions.

In `@test/unit/onboarding.test.ts`:
- Around line 67-73: Add a regression test after the existing test for
nextOnboardingStep to verify that when the onboarding state is on the "models"
step and the input object omits the models property (or provides an
empty/undefined models array), the nextOnboardingStep function keeps the step at
"models" rather than advancing forward. This test should create an
OnboardingState with step set to "models" and call nextOnboardingStep with input
that lacks the models field, then assert that the returned state still has step
equal to "models".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6fb8de36-8f2c-4656-b4e2-e85cc6c9fe10

📥 Commits

Reviewing files that changed from the base of the PR and between 940989b and 1d3fcc5.

📒 Files selected for processing (15)
  • README.md
  • bin/pagespace.mjs
  • src/config.ts
  • src/credentials.ts
  • src/doctor.ts
  • src/env.ts
  • src/onboarding.ts
  • test/run-token-isolation.ts
  • test/unit/config.test.ts
  • test/unit/credentials.test.ts
  • test/unit/doctor.test.ts
  • test/unit/drive-set.test.ts
  • test/unit/env-isolate-token.test.ts
  • test/unit/env.test.ts
  • test/unit/onboarding.test.ts

Comment thread src/config.ts Outdated
Comment thread src/credentials.ts
Comment thread src/credentials.ts Outdated
Comment thread src/doctor.ts Outdated
Comment thread src/onboarding.ts

@2witstudios 2witstudios left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Cursor-grade onboarding + token isolation

Verdict: Approve with minor fixes. The security root is correct and provably tested. Architecture is clean (pure functions, DI for I/O, one implementation per concern). A few edge cases need tightening.


🔒 Security (the headline)

Token isolation is real and correctly implemented:

  • sanitizeChildEnv() strips PAGESPACE_AUTH_TOKEN from the spawn env before pi launches
  • applyEnv() now skips SECRET_ENV_KEYS so .env files can't re-inject the token into process env
  • E2E test (run-token-isolation.ts) proves it through all three exfil vectors: env, printenv, /proc/self/environ
  • Credential store enforces 0600 (refuses group/world-readable on read, explicit chmod on write for existing files)

The attack vector it closes (agent's bash tool reading /proc/self/environ) is a genuine finding. Fix is correct.

Fixes needed before merge

1. src/config.ts:91 — dead fallback leaks whitespace

-    defaultDriveSlug: resolveDefaultDrive(env) ?? env.PAGESPACE_DRIVE,
+    defaultDriveSlug: resolveDefaultDrive(env),

resolveDefaultDrive already trims and resolves precedence. The raw fallback can leak " " back in.

2. src/credentials.ts:36-37 — write/read contract break on whitespace apiUrl

buildCredentialRecord({ token: "x", apiUrl: " " }) trims to "", persists, then readCredentials() rejects it via validateCredentialRecord. Build should validate:

const apiUrl = (input.apiUrl ?? DEFAULT_API_URL).trim();
if (!apiUrl || !/^https?:\/\//.test(apiUrl)) {
  throw new Error("apiUrl must be an http(s) URL");
}

3. src/credentials.ts:61-64 — validateCredentialRecord doesn't type-guard

Malformed JSON like {"token": 123} throws at .trim() instead of producing a clean error list. Guard with typeof:

if (typeof rec.token !== "string" || !rec.token.trim()) errs.push("token is missing");
if (typeof rec.apiUrl !== "string" || !rec.apiUrl.trim()) errs.push("apiUrl is missing");

4. src/onboarding.ts:107-116 — models step conflates undefined with []

When input.models is undefined (discovery not yet called), the else branch runs and advances the machine. undefined (not called) and [] (called, empty) are semantically different. Distinguish them:

case "models": {
  if (input.models === undefined) break; // hold — discovery hasn't returned yet
  if (input.models.length > 0) {
    next.models = input.models;
    next.defaultModel = input.models[0];
  } else {
    next.models = [];
  }
  next.step = "default";
  break;
}

Nitpicks (non-blocking)

  • src/config.ts:1 — duplicates DEFAULT_API_URL from credentials.ts. Import the shared constant to prevent drift.
  • src/doctor.ts:70hasCredentials from file existence ≠ usable token. Works today because readCredentials() validates before the doctor sees it, but the contract should be documented: hasCredentials means "successfully read and validated," not "file exists."
  • bin/pagespace.mjs tsx re-exec — adds ~1s startup overhead on every invocation. Acceptable for a dev tool, but worth a note in the README if startup latency matters for CI.

What's clean

  • Pure functions with injected I/O (credential callback, env parameter). Testable without mocks for the core logic.
  • One implementation per concern — doctor, onboarding, credentials, config all consumed by both the launcher and tests. No mirroring.
  • Onboarding state machine is well-modeled. The "validate is one-shot, caller owns terminal conditions" contract is explicit and documented.
  • Test coverage is comprehensive across all new modules. The E2E isolation test is the standout.

Comment thread src/config.ts Outdated
Comment thread src/credentials.ts
Comment thread src/credentials.ts Outdated
Comment thread src/onboarding.ts Outdated
2witstudios and others added 2 commits June 22, 2026 09:28
… env dedup

Triage of 11 inline review comments; 10 valid, 1 skipped (doctor hasToken is intentional credential-store path, pure fn can't read files).

config.ts: drop ?? env.PAGESPACE_DRIVE fallback (resolveDefaultDrive already trims); use shared DEFAULT_API_URL.

credentials.ts: type guards in validateCredentialRecord (reject non-string JSON fields, no TypeError); buildCredentialRecord rejects whitespace-only apiUrl.

onboarding.ts: distinguish empty discovery (recoverable, advance) from undefined (hold, wait for result).

doctor.ts: import shared DEFAULT_API_URL (DRY, no drift).

bin: import sanitizeChildEnv/SECRET_ENV_KEYS from src/env.ts (no mirroring).

Tests: whitespace DRIVE leak, non-string credential fields, whitespace apiUrl, models hold-vs-advance. 199 passing.
…ists but token is unusable

The token check in diagnose() was using `!!(input.hasToken || input.hasCredentials)`, which
passes when the credential file exists even if the effective token is unreadable/corrupt.
Separate the two concerns: `hasToken` = effective token resolved by the caller, `hasCredentials`
= credential file presence (its own separate check). Add a test for the false-green scenario
and align the onboarding test to reflect the real runtime path (loadCredentials() IIFE copies
the readable credential into env before needsOnboarding() runs, so hasToken is always true
when credentials are readable at onboarding-check time).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Thread PRRT_kwDOSwjTgc6LSlFp (onboarding.ts models undefined): Already fixed in nextOnboardingStep (src/onboarding.ts:106-117). Three distinct branches: non-empty array → advance+set models, explicit empty array → advance with empty models (discovery ran, found nothing), models === undefined → hold (discovery has not run yet). Line 117 comment: 'models === undefined: hold — discovery hasn't run yet; wait for a real result.'

@2witstudios 2witstudios merged commit 248fee6 into main Jun 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant