Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 63 additions & 13 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ 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 --home-dir <base-dir>`. Add `--share`
to also publish it on the tailnet so the user can open it from another device
(see "Share a dev server with the user" below).
4. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.

Ports are derived from the worktree path, so a worktree prefers the same pair on
every run rather than racing others for the default. That is a preference, not a
guarantee — the runner shifts both ports when either is already taken. Read the
actual values from the dev-runner line rather than assuming `5733`/`13773`, and
re-read them after a restart; `node scripts/dev-runner.ts dev --dry-run` prints
them without starting anything.

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 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.
Expand All @@ -38,24 +47,63 @@ Treat the overall testing or implementation loop—not an assistant turn or one
4. Wait for the pairing exchange and redirect to finish before navigating elsewhere.
5. Continue in the same browser context so its stored bearer session remains available.

Treat pairing URLs as secrets. Do not copy them into final responses, screenshots, committed files, or durable logs. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it.
Treat pairing URLs as secrets: they grant access to the environment. Never put one anywhere it outlives the moment — screenshots, committed files, durable logs, or a PR description.

The one exception is handing access to the user who asked for it: when they want to open the app themselves (see "Share a dev server with the user"), the URL is the deliverable, so give it to them directly in the response. Do not volunteer one otherwise. A pairing token is short-lived and single-use; opening the URL in another browser or opening it twice can consume it.

## Recover a consumed or expired pairing token

Create another token against the same database and web URL as the running dev server:
Ask the running server for a fresh pairing URL:

```bash
bun run dev:pair # implicit ~/.t3 home
bun run dev:pair -- --base-dir <base-dir> # explicit --home-dir environment
```

It finds the running server's own `server-runtime.json` under the base directory
— checking both the `dev` and `userdata` state directories and verifying the
recorded process is alive — so it needs no port and builds the URL against the
web origin already in use, including a `--share` tailnet URL. Pass `--base-dir`
only when the dev server was started with `--home-dir`. Add `--ttl 1h` for a
longer window or `--json` for machine-readable output.

Use the printed `Pair URL` once. If the command reports no running server, the
dev process has exited — restart it and use the URL from its own startup log.

Use `auth pairing list` to inspect active token metadata; it intentionally cannot
reveal token secrets. `auth pairing create` remains available for scripted cases
that need explicit control over every flag.

## Share a dev server with the user

When the user wants to try the change themselves — from their laptop, their
phone, or anything else on their tailnet — start the stack with `--share`:

```bash
T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
--base-dir <base-dir> \
--dev-url <web-url> \
--base-url <web-url> \
--ttl 15m \
--label agent-ui-test
bun run dev:share # or: vp run dev --share --home-dir <base-dir>
```

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.
This publishes the web port over HTTPS on the machine's tailnet and prints
`[dev-runner] shared on tailnet: https://<host>:<port>/`. The pairing URL logged
right after is already built against that origin, so give the user that URL
verbatim — it is the deliverable, and it works unchanged on any of their devices.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
This is the one case where a pairing URL belongs in a response: it is going to
the person who asked for it. It still must not go anywhere durable.

Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to
the backend, so one shared port covers the whole app. Do not map backend paths by
hand, and do not set `VITE_HTTP_URL`/`VITE_WS_URL` for a shared server — absolute
localhost URLs get compiled into the bundle and send the remote browser to its
own machine.

The mapping is removed when the dev server exits, and re-running `--share`
replaces any mapping left behind by a run that was killed. If the tailnet is
unavailable the dev server still starts and serves locally; the warning explains
what to fix.

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.
Expect one failed `ws://localhost:<port>` HMR attempt in the remote console
before Vite falls back to the page origin. That is Vite's own hot-reload socket,
not the app's connection — ignore it.

## Inspect or seed SQLite state

Expand Down Expand Up @@ -83,7 +131,9 @@ If completion is uncertain, keep the environment alive and mention that it is re
## Troubleshoot predictably

- If the browser shows an unauthenticated pairing screen, issue a new token instead of retrying the consumed URL.
- If the pairing URL is no longer visible, create a replacement token with both `--dev-url` and `--base-url`.
- If the replacement token is rejected, verify that the CLI and server use the identical absolute base directory and web URL.
- If the pairing URL is no longer visible, run `bun run dev:pair`.
- If the replacement token is rejected, verify that the CLI and server resolve the same state directory — pass `--base-dir` only when the server was started with `--home-dir`, and use the same path.
- If the UI shows unexpected data, verify that every command uses the identical explicit base directory before editing anything.
- If ports move because another instance is running, trust the current dev-runner output rather than assuming ports `13773` and `5733`.
- If a remote browser reaches the page but the app cannot connect, check that the served bundle has no absolute `localhost` backend URL baked in: `VITE_HTTP_URL` and `VITE_WS_URL` must be unset for `dev`/`dev:web`.
- If a shared URL returns 403 with "host is not allowed", the hostname is outside `*.ts.net`; add it to `T3CODE_DEV_ALLOWED_HOSTS` (comma-separated).
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@
- 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

- Start the web stack with `bun run dev` (equivalently `vp run dev`). Never run `vp dev` from the repo root — that starts a bare Vite with no backend.
- Ports are derived from the worktree path, so a worktree prefers the same pair every run instead of everyone racing for the default. They still shift when something already holds them, so read the actual values from the `[dev-runner] …` line rather than assuming them. `--dry-run` prints them without starting anything.
- To let the user try a change on their own device, use `bun run dev:share`. It publishes the web port on the tailnet and logs a pairing URL already built against that origin — hand them that URL as-is. Details and troubleshooting live in the `test-t3-app` skill.
- Dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL`/`VITE_WS_URL` for `dev`/`dev:web` — they compile absolute localhost URLs into the bundle and break every non-localhost origin.
- Need a pairing URL for a server that is already running? `bun run dev:pair`. It finds the server itself; no ports or flags to get wrong.

## 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,
} 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
4 changes: 3 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import { verifyRequestDpopProof } from "./dpop.ts";
import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts";

export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
// Re-exported from PairingGrantStore, which keys the dev startup-token TTL off it.
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT =
PairingGrantStore.INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT;

export interface IssuedPairingLink {
readonly id: string;
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
16 changes: 15 additions & 1 deletion apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,16 @@ 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);
export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const PAIRING_TOKEN_LENGTH = 12;
const PAIRING_TOKEN_REJECTION_LIMIT =
Expand Down Expand Up @@ -371,7 +381,11 @@ 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?.subject === INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT;
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
Loading
Loading