Conversation
Inject per-tenant identity into the agent's system prompt so it knows its own URL, owner, dashboard, runtime, and model. The overlay slots between Identity and Environment, reads non-secret env vars stamped into the process by phantom-firstboot, and degrades to the empty string in single-tenant or laptop dev mode. Vars consumed: PHANTOM_TENANT_SLUG, PHANTOM_TENANT_ID, PHANTOM_OWNER_EMAIL, PHANTOM_OWNER_NAME, PHANTOM_DOMAIN, PHANTOM_DASHBOARD_URL, PHANTOM_AGENT_RUNTIME, PHANTOM_MODEL, plus the PHANTOM_GRANTED_INTEGRATIONS (Phase 7) and PHANTOM_CHANNEL_ALLOWLIST (Phase 8b) hooks. Every line is gated on a real value so missing optional vars silently disappear; the block never carries provider keys or secrets. Tests: 23 unit tests on the builder + reader + 4 integration tests on the assembler covering position (Identity < overlay < Environment), defensive shape (slug + dashboard only), full-env shape, and the integration hooks. Failure-path coverage for missing env vars, whitespace-only inputs, malformed list values, and the no-tenant fallback. Mission v1 sequencing step 4 (master plan section 3 Phase 9). No phantomd / phantom-rootfs change required: the overlay is defensive against intermediate phantomd versions that have not yet landed PHANTOM_DOMAIN and PHANTOM_OWNER_NAME injection.
…R-3) Adds the in-VM landing route that the dashboard redirects users to after minting a one-time magic-link token via phantom-control. The handler validates the token server-side via the metadata gateway (http://169.254.169.254/v1/magic-link/validate), enforces a defense-in-depth chain (token shape, per-IP rate limit, tenant-config sanity, cross-tenant slug check, owner-email binding), and mints a phantom_session cookie scoped to the per-tenant subdomain before redirecting to /chat. - New src/ui/auth-magic.ts with handleAuthMagic (~360 LOC). - Wired in src/core/server.ts at top-level /auth/magic (not /ui/*, the token IS the auth so the route bypasses the cookie gate by design). - Per-IP rate limit: 10 / IP / minute, sliding window. Successful validates do not bump the budget. - Cookie: Domain=<slug>.phantom.ghostwright.dev, Path=/, HttpOnly, Secure, SameSite=Lax, Max-Age=7d (matches existing session.ts TTL). Per-slug not wildcard; Lax not Strict. - Token plaintext never echoed in response body, redirect Location, upstream URL path/query, or any structured-log call. - Open-redirect defense on ?redirect= (path-only; rejects https://, protocol-relative //, and backslash tricks). - Every error path 302s to ${PHANTOM_DASHBOARD_URL}?magic_error=<code> so the dashboard surfaces a Sonner toast. Codes: invalid_token, rate_limited, expired, validator_unavailable, owner_mismatch, agent_mismatch, tenant_unconfigured. - 45 new tests at src/ui/__tests__/auth-magic.test.ts covering happy path, every failure code, plaintext-leak guards, rate-limit semantics, cross-tenant + owner-email gates, open-redirect defense, refresh survival. - 2 route-wiring smoke tests at src/core/__tests__/server.test.ts. - CLAUDE.md updated with the magic-link callback section. bun test 2199 pass / 0 fail. bun run lint clean. bun run typecheck clean.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c8eec7900f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| evictBloatedIfNeeded(cutoff); | ||
| return false; | ||
| } | ||
| kept.push(now); |
There was a problem hiding this comment.
Exclude successful auths from rate-limit counters
The limiter currently records every syntactically valid attempt before validation, including successful sign-ins, because checkIpRateLimit() always appends now when under the cap. That contradicts the handler’s documented behavior (“successful validates do NOT bump the budget”) and causes legitimate users to hit rate_limited after ~10 normal sign-ins within a minute (e.g., repeated retries from dashboard redirects or multi-tab clicks), even without probing activity.
Useful? React with 👍 / 👎.
Summary
Adds the per-tenant
/auth/magiccallback that redeems a one-time magic-link token after the dashboard 302s the user's browser tohttps://<slug>.phantom.ghostwright.dev/auth/magic?token=<43-char base64url>. The handler validates the token server-side via the metadata gateway, enforces a defense-in-depth chain, and mints aphantom_sessioncookie scoped to the per-tenant subdomain before redirecting to/chat.This is the third of four cooperating PRs across phantom-control (mint + validate shim), phantomd (metadata-gateway proxy), phantom (this PR; in-VM callback), and the dashboard (mint route + agent-card click handler). The token IS the auth on this route, so
/auth/magicis dispatched at the top level and bypasses the existing/ui/*cookie gate by design.What changed
src/ui/auth-magic.ts(~360 LOC).handleAuthMagic(req, deps)is the route entry; production wires the default fetch + clock, tests stub them viaMagicAuthDeps.src/core/server.tsat top-level/auth/magic(one new import + one match clause).src/ui/__tests__/auth-magic.test.tscovering happy path, every failure code, plaintext-leak guards, rate-limit semantics, cross-tenant + owner-email gates, open-redirect defense, refresh survival.src/core/__tests__/server.test.tsto confirm the dispatch off/.CLAUDE.mdextended with a Phase 6 magic-link callback section documenting the route, env-var contract, cookie shape, defense-in-depth gates, and the cross-repo invariants this PR is paired against.Cross-repo contract
The handler is a strict client of two upstream services. Every wire shape mirrors the upstream byte-for-byte; CI on either side surfaces drift.
^[A-Za-z0-9_-]{43}$regex mirrors the upstream regexes by string identity. Mints emit 32 bytes fromcrypto/randencoded as base64url-no-padding.POST http://169.254.169.254/v1/magic-link/validatewith body{"token": "<43-char>"}. The metadata gateway proxies to phantom-control's bearer-auth shim; the bearer is operator-side only and never visible to this process.{"agent_id": "...", "agent_slug": "...", "owner_email": "..."}. Mirrors phantom-control'smagicLinkValidateResponseand phantomd's proxymagicLinkValidateResponsebyte-for-byte.Defense-in-depth gates (in order)
PHANTOM_TENANT_SLUG+PHANTOM_OWNER_EMAILboth required).agent_slugfrom validator must equalPHANTOM_TENANT_SLUG. The metadata-gateway proxy already enforces this; the in-VM handler duplicates the check so the trust anchor stays inside the VM even if a future code path bypasses the gateway.owner_emailmust equalPHANTOM_OWNER_EMAIL(case-insensitive, trimmed). Sealed-tenant correctness invariant.?redirect=: must be a single-leading-slash path; rejects protocol-relative (//evil.com), absolute (https://evil.com), and backslash-trick (/\evil.com) variants.Cookie shape
Per-slug Domain (NOT wildcard
.phantom.ghostwright.dev): cross-tenant isolation is non-negotiable. SameSite=Lax (NOT Strict): preserves the bookmarked-direct-navigation UX. 7-day Max-Age matches the existingSESSION_TTL_MSinsrc/ui/session.ts. A secondSet-Cookieclears any legacyPath=/uicookie a browser may carry from before the cookie-flatten era.Failure-path coverage
Every error path 302s to
${PHANTOM_DASHBOARD_URL}?magic_error=<code>so the dashboard's Sonner toast surfaces. Codes:invalid_token,rate_limited,expired,validator_unavailable,owner_mismatch,agent_mismatch,tenant_unconfigured. WhenPHANTOM_DASHBOARD_URLis unset (dev), the fallback is/ui/login?magic_error=....The token plaintext is NEVER:
console.logor structured-log call.The catch on the validator fetch is deliberately silent on the caught error's message because some runtimes leak the request URL (which would carry the token) into thrown error strings.
Refresh / restart survival
/auth/magic?token=<x>after a successful first hit: validator returns 404 (single-use already consumed); user lands at?magic_error=expiredwith no half-set cookie.?magic_error=validator_unavailableand can retry from the dashboard.?magic_error=lands on the dashboard: dashboard's existing toast deduplication (Phase 3 §9.7) handles this.Test plan
bun test-> 2199 pass / 10 skip / 1 todo / 0 fail (47 new tests added cleanly).bun run lint-> 0 errors.bun run typecheck-> 0 errors.The new tests cover (see
src/ui/__tests__/auth-magic.test.ts):isValidSession),agent_slug-> 403),https://,//, backslash,javascript:all fall back to/chat),console.logline),/ui/login?magic_error=...when unset),The route-wiring smoke is at
src/core/__tests__/server.test.ts::"GET /auth/magic".Out of scope (deferred to PR-4 dashboard mint)
GET /api/magic-link?agent_id=<x>route that mints + 302s) lands in PR-4 in the dashboard repo.POST /ui/logout) deferred per architect §9; this PR ships the entry path only.phantomctl revoke-magic-linkare all post-v0 enhancements per architect §13.Architect doc
Internal architecture and decision rationale (token shape, JWT-vs-opaque, wildcard-vs-per-slug cookie scope, threat model) are in the architect doc referenced from
CLAUDE.md. The cross-repo wire shape is the only contract a reviewer needs to verify; this PR is faithful to it.