feat: Cursor-grade onboarding + token isolation (Epic 7, Stream 3)#68
Conversation
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.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Warning Review limit reached
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughAdds a file-backed credential store ( ChangesFirst-run Onboarding & Token Isolation
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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).
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (7)
src/config.ts (1)
1-2: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winUse the shared
DEFAULT_API_URLconstant to avoid default-value drift.
src/credentials.tsand 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 winAdd 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-onlyapiUrlinput inbuildCredentialRecord().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 winAdd a
defaultDriveSlugwhitespace regression test.Since
loadConfig()now resolves drive defaults from env helpers, add coverage that whitespace-onlyPAGESPACE_DRIVEdoes 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 winAvoid duplicating the canonical default API URL constant.
DEFAULT_API_URLis already defined insrc/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 winAdd a regression test for
modelsstep hold behavior when input is absent.There’s no test asserting that
nextOnboardingStep()stays on"models"wheninput.modelsis 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 winAdd regression coverage for "credential file exists but token is not usable".
Current tests validate credential-store presence, but not the failure mode where
hasCredentials === trueandhasToken === 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 winImport
sanitizeChildEnvfromsrc/env.tsinstead of duplicating.The PR objectives state code duplication between bin and src was eliminated, but
SECRET_ENV_KEYSandsanitizeChildEnvare duplicated here despite being exported fromsrc/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
📒 Files selected for processing (15)
README.mdbin/pagespace.mjssrc/config.tssrc/credentials.tssrc/doctor.tssrc/env.tssrc/onboarding.tstest/run-token-isolation.tstest/unit/config.test.tstest/unit/credentials.test.tstest/unit/doctor.test.tstest/unit/drive-set.test.tstest/unit/env-isolate-token.test.tstest/unit/env.test.tstest/unit/onboarding.test.ts
2witstudios
left a comment
There was a problem hiding this comment.
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()stripsPAGESPACE_AUTH_TOKENfrom the spawn env before pi launchesapplyEnv()now skipsSECRET_ENV_KEYSso.envfiles 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— duplicatesDEFAULT_API_URLfromcredentials.ts. Import the shared constant to prevent drift.src/doctor.ts:70—hasCredentialsfrom file existence ≠ usable token. Works today becausereadCredentials()validates before the doctor sees it, but the contract should be documented:hasCredentialsmeans "successfully read and validated," not "file exists."bin/pagespace.mjstsx 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.
… 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>
|
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), |
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)
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 bypagespace login(interactive capture → auth ping → persist). Defense-in-depth: refuses group/world-readable files.🚀 First-run onboarding
nextOnboardingStepstate machine.🩺 Reusable doctor
pagespace statusis 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;
.envAND.mcp.jsondemoted to optional overrides.🗂 Multi-drive config —
PAGESPACE_DRIVES(comma-separated set) +PAGESPACE_DRIVE(bare-path default).parseDriveSet()/resolveDefaultDrive()pure helpers.Post-review fix: eliminated all mirroring
An independent
/reviewpass found that the bin had reimplemented the onboarding state machine, credential writes, and the onboarding gate inline — while the unit-tested pure functions insrc/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 checkgreen (typecheck + lint + 186 unit tests)./buildloop.Security ADR:
ml803j05zgon3vt54mnmuz53. Onboarding ADR:w2e336emo2v6587q8dy014el.Summary by CodeRabbit
Release Notes
New Features
pagespace logincommand to authenticate and store credentials locallypagespace login, with clear token/config precedenceDocumentation
Security / Bug Fixes