Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2654583
Keep worktree dev state isolated
t3dotgg Jul 26, 2026
e52d104
Distinguish worktrees from submodules, cover precedence
t3dotgg Jul 26, 2026
6404fc1
Match worktrees by their gitdir tail, ignore blank --home-dir
t3dotgg Jul 26, 2026
8684ca4
Make browser dev shareable over Tailscale
t3dotgg Jul 26, 2026
dbad3bc
Decline --share for dev:desktop
t3dotgg Jul 26, 2026
ce4664d
Classify tailscale stderr instead of quoting it; probe the bind host
t3dotgg Jul 26, 2026
2869a9e
Signal single-origin dev positively so .env cannot revive backend URLs
t3dotgg Jul 26, 2026
37e1c79
Apply the bind host to the server port only
t3dotgg Jul 26, 2026
b50efd5
Address review: recursive secret check, pinned test port, bun docs
t3dotgg Jul 26, 2026
2db512a
Split DevShareError into three classes and keep the cause
t3dotgg Jul 26, 2026
029dfc3
Merge remote-tracking branch 'origin/main' into agent/dev-server-sharing
t3dotgg Jul 26, 2026
4333a66
Fix dev sharing across hosted and restarted servers
t3dotgg Jul 26, 2026
f48ee4a
Update hosted cookie test expectation
t3dotgg Jul 26, 2026
8ba59f6
Claim dev share ownership after serving
t3dotgg Jul 26, 2026
3b4765b
Make dev share acquisition transactional
t3dotgg Jul 26, 2026
5f48002
Handoff dev share leases between clear and serve
t3dotgg Jul 26, 2026
0a504fa
Restore dev sharing when lease handoff fails
t3dotgg Jul 26, 2026
2071c98
Keep desktop session cookies port-scoped
t3dotgg Jul 27, 2026
63b2613
Drop an inherited HOST in browser dev modes
t3dotgg Jul 27, 2026
1c8e540
Remove the dev-share lease protocol
t3dotgg Jul 27, 2026
faf6467
Print the exact stop command at dev-runner startup
t3dotgg Jul 27, 2026
08d9260
Use HostProcessPlatform for the stop-command platform check
t3dotgg Jul 27, 2026
9055f7c
Revert the printed stop command
t3dotgg Jul 27, 2026
83783d0
Reject a specific non-loopback --host in browser dev; restore vp run …
t3dotgg Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
2. Choose a base directory that belongs only to the current worktree or test:
- Use the repository's ignored `.t3` directory for reusable worktree-local state.
- Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
3. Start the full web stack with `vp run dev --home-dir <base-dir>`.
3. Start the full web stack with `vp run dev`. Add `--share` when the user needs to open it from another tailnet device. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.

Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.

The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.

Ports are derived from the worktree path but can shift when occupied. Always read the actual values from the `[dev-runner]` line.

Shared browser dev is single-origin: Vite proxies the backend paths, so never set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.

The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.

## Preserve the environment while iterating
Expand Down Expand Up @@ -55,7 +61,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \

Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port.

Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `<worktree>/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.

## Inspect or seed SQLite state

Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
- Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it.
- Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.

## Dev Servers

- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins.
- Start the web stack with `bun run dev`. Use `bun run dev:share` when someone needs to open it from another device on the tailnet.
- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`.
- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them.

## Package Roles

- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ import * as ServerConfig from "../config.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";
import * as EnvironmentAuth from "./EnvironmentAuth.ts";
import { resolveSessionCookieName } from "./utils.ts";

import * as ServerSecretStore from "./ServerSecretStore.ts";

/** Pinned so the session cookie name (which is port-scoped) is predictable. */
const TEST_SERVER_PORT = 13_773;

const makeServerConfigLayer = (overrides?: Partial<ServerConfig.ServerConfig["Service"]>) =>
Layer.effect(
ServerConfig.ServerConfig,
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
return {
...config,
port: TEST_SERVER_PORT,
...overrides,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} satisfies ServerConfig.ServerConfig["Service"];
}),
Expand All @@ -35,7 +40,9 @@ const makeCookieRequest = (
): Parameters<EnvironmentAuth.EnvironmentAuth["Service"]["authenticateHttpRequest"]>[0] =>
({
cookies: {
t3_session: sessionToken,
// Derived, not hardcoded: the name is port-scoped so concurrent servers
// on one hostname don't share a cookie.
[resolveSessionCookieName({ port: TEST_SERVER_PORT })]: sessionToken,
},
headers: {},
}) as unknown as Parameters<
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ export class EnvironmentAuth extends Context.Service<
readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
readonly subject?: string;
readonly proofKeyThumbprint?: string;
readonly purpose?: "startup";
}) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
readonly issuePairingCredential: (
input?: AuthCreatePairingCredentialInput,
Expand Down Expand Up @@ -746,11 +747,13 @@ export const make = Effect.gen(function* () {
readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
readonly subject: string;
readonly label?: string;
readonly purpose?: "startup";
}) =>
createPairingLink({
scopes: input.scopes,
subject: input.subject,
...(input.label ? { label: input.label } : {}),
...(input.purpose ? { purpose: input.purpose } : {}),
}).pipe(
Effect.map(
(issued) =>
Expand All @@ -774,6 +777,7 @@ export const make = Effect.gen(function* () {
...(input?.ttl ? { ttl: input.ttl } : {}),
...(input?.label ? { label: input.label } : {}),
...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
...(input?.purpose ? { purpose: input.purpose } : {}),
});
return {
id: issued.id,
Expand Down Expand Up @@ -872,6 +876,7 @@ export const make = Effect.gen(function* () {
issuePairingCredentialForSubject({
scopes: AuthAdministrativeScopes,
subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
purpose: "startup",
}).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));

const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/auth/EnvironmentAuthPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,15 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {

expect(descriptor.policy).toBe("loopback-browser");
expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
expect(descriptor.sessionCookieName).toBe("t3_session");
// Port-scoped in web mode too: cookies ignore ports, so two dev servers
// on one hostname would otherwise clobber each other's session.
expect(descriptor.sessionCookieName).toBe("t3_session_13773");
}).pipe(
Effect.provide(
makeEnvironmentAuthPolicyLayer({
mode: "web",
host: "127.0.0.1",
port: 13773,
}),
),
),
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/auth/EnvironmentAuthPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ export const make = Effect.gen(function* () {
policy,
bootstrapMethods,
sessionMethods: ["browser-session-cookie", "bearer-access-token", "dpop-access-token"],
sessionCookieName: resolveSessionCookieName({
mode: config.mode,
port: config.port,
}),
sessionCookieName: resolveSessionCookieName({ port: config.port }),
};

return EnvironmentAuthPolicy.of({
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ export class PairingGrantStore extends Context.Service<
readonly subject?: string;
readonly label?: string;
readonly proofKeyThumbprint?: string;
/**
* "startup" marks the credential the server mints for itself at boot,
* which gets the long dev TTL when a dev URL is configured.
*/
readonly purpose?: "startup";
}) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
readonly listActive: () => Effect.Effect<
ReadonlyArray<AuthPairingLink>,
Expand Down Expand Up @@ -243,6 +248,15 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
// window can still recover by re-bootstrapping rather than locking
// the user out of the backend.
const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24);
// A dev server's startup token is read off a log by whoever (or whatever) is
// driving the session, often minutes later — after a `node --watch` restart, a
// detour into another task, or a hand-off to the person actually doing the
// testing. Five minutes turns that into a restart-the-server loop for no
// security benefit: the token only unlocks a local dev backend, and its holder
// could read the log anyway. Same reasoning (and duration) as the desktop
// bootstrap grant above. Only applies when a dev URL is configured; user-issued
// pairing links and real servers keep the 5-minute default.
const DEV_STARTUP_TTL_HOURS = Duration.hours(24);
const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const PAIRING_TOKEN_LENGTH = 12;
const PAIRING_TOKEN_REJECTION_LIMIT =
Expand Down Expand Up @@ -371,7 +385,10 @@ export const make = Effect.gen(function* () {
),
);
const credential = yield* generatePairingToken;
const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
const isDevStartupToken = config.devUrl !== undefined && input?.purpose === "startup";
const ttl =
input?.ttl ??
(isDevStartupToken ? DEV_STARTUP_TTL_HOURS : DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES);
const now = yield* DateTime.now;
const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
const issued: IssuedBootstrapCredential = {
Expand Down
5 changes: 1 addition & 4 deletions apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,10 +467,7 @@ export const make = Effect.gen(function* () {
const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
const cookieName = resolveSessionCookieName({
mode: serverConfig.mode,
port: serverConfig.port,
});
const cookieName = resolveSessionCookieName({ port: serverConfig.port });

const emitUpsert = (clientSession: AuthClientSession) =>
PubSub.publish(changesPubSub, {
Expand Down
17 changes: 9 additions & 8 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import * as Result from "effect/Result";

const SESSION_COOKIE_NAME = "t3_session";

export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
}): string {
if (input.mode !== "desktop") {
return SESSION_COOKIE_NAME;
}

/**
* Cookies are scoped by host but *not* by port, so every server reachable at a
* given hostname shares one cookie jar. Suffixing the port keeps concurrent
* instances from overwriting each other's session — otherwise two dev servers
* (different worktrees, or several ports behind one tailnet name) fight over
* `t3_session`, and whichever wrote last makes every other one reject the
* cookie with "Invalid session token signature" until it's cleared by hand.
*/
export function resolveSessionCookieName(input: { readonly port: number }): string {
return `${SESSION_COOKIE_NAME}_${input.port}`;
}

Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AuthOrchestrationReadScope,
EnvironmentHttpApi,
} from "@t3tools/contracts";
import { isDevProxiedPath } from "@t3tools/shared/devProxy";
import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -48,9 +49,21 @@ export const browserApiCorsLayer = Layer.unwrap(
const devOrigin = config.devUrl?.origin;
// Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
// explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
//
// T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second
// origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies
// through Vite and is same-origin (no preflight at all), so this is a
// safety net for the desktop renderer and any direct-to-backend caller.
const extraDevOrigins = (process.env.T3CODE_DEV_ALLOWED_ORIGINS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return HttpRouter.cors({
...(devOrigin
? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true }
? {
allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...extraDevOrigins],
credentials: true,
}
: {}),
allowedMethods: browserApiCorsAllowedMethods,
allowedHeaders: browserApiCorsAllowedHeaders,
Expand Down Expand Up @@ -216,6 +229,10 @@ export const staticAndDevRouteLayer = HttpRouter.add(
}

const config = yield* ServerConfig.ServerConfig;
if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
return HttpServerResponse.text("Not Found", { status: 404 });
}

if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
status: 302,
Expand Down
83 changes: 59 additions & 24 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import "vite-plus/test/config";
import { defineConfig } from "vite-plus";
import pkg from "./package.json" with { type: "json" };

import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";

import { loadRepoEnv } from "../../scripts/lib/public-config";

const repoEnv = loadRepoEnv();
Object.assign(process.env, repoEnv);

const port = Number(process.env.PORT ?? 5733);
const host = process.env.HOST?.trim() || "localhost";
const explicitHost = process.env.HOST?.trim();
const host = explicitHost || "localhost";
Comment thread
cursor[bot] marked this conversation as resolved.
const configuredWsUrl = process.env.VITE_WS_URL?.trim();
const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
Expand Down Expand Up @@ -65,7 +68,20 @@ const unitTestProject = {
},
} satisfies TestProjectInlineConfiguration;

function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {
function resolveDevProxyTarget(
backendPort: string | undefined,
wsUrl: string | undefined,
): string | undefined {
// Browser dev is single-origin: the backend port is proxied through this
// server so the app works from any origin (localhost, tailnet, LAN, phone).
// T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode.
const port = Number(backendPort?.trim());
if (Number.isInteger(port) && port > 0) {
return `http://localhost:${port}/`;
}

// dev:desktop still points the renderer straight at the backend, so fall
// back to deriving the target from the explicit websocket URL.
if (!wsUrl) {
return undefined;
}
Expand All @@ -86,7 +102,17 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined {
}
}

const devProxyTarget = resolveDevProxyTarget(configuredWsUrl);
const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl);

// Vite rejects requests whose Host header isn't localhost, which blocks sharing
// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale:
// the DNS is controlled by tailscale, so they can't be rebound by an attacker.
// Anything else (ngrok, a LAN IP alias) goes through the env var.
const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
const allowedHosts = [".ts.net", ...configuredAllowedHosts];

export default defineConfig(() => {
return {
Expand Down Expand Up @@ -145,32 +171,41 @@ export default defineConfig(() => {
host,
port,
strictPort: true,
allowedHosts,
...(devProxyTarget
? {
proxy: {
"/.well-known": {
target: devProxyTarget,
changeOrigin: true,
},
"/api": {
target: devProxyTarget,
changeOrigin: true,
},
"/attachments": {
target: devProxyTarget,
changeOrigin: true,
},
// One entry per shared prefix; the server's dev catch-all 404s the
// same list, so the two sides cannot drift. `/ws` is the app's own
// socket — Vite's HMR socket is matched separately and exactly
// (path "/" plus a vite-hmr subprotocol), so the two upgrade
// handlers don't collide.
proxy: Object.fromEntries(
DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
prefix,
{
target: devProxyTarget,
changeOrigin: true,
...(prefix === "/ws" ? { ws: true } : {}),
},
]),
),
}
: {}),
// Electron's BrowserWindow needs the HMR socket pinned to an explicit
// host to connect reliably; dev:desktop is the only mode that sets HOST.
// Everywhere else, leaving this unset lets the client derive it from the
// page origin, which is what makes HMR work over Tailscale/LAN instead of
// failing an attempt against the wrong machine's localhost first.
// (Vite 8 logs connection state via console.debug — enable "Verbose".)
...(explicitHost
? {
hmr: {
protocol: "ws",
host: explicitHost,
clientPort: port,
},
}
: {}),
hmr: {
// Explicit config so Vite's HMR WebSocket connects reliably
// inside Electron's BrowserWindow. Vite 8 uses console.debug for
// connection logs — enable "Verbose" in DevTools to see them.
protocol: "ws",
host,
clientPort: port,
},
},
build: {
outDir: "dist",
Expand Down
Loading
Loading