Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1800708
Add dev pairing and seed utilities
t3dotgg Jul 26, 2026
373e791
Harden dev:seed against state it cannot settle
t3dotgg Jul 26, 2026
8a76bb8
Fix liveness probe, --json output, and remaining unsettled state
t3dotgg Jul 26, 2026
33e4c76
Keep the settle UPDATE within SQLite's variable ceiling
t3dotgg Jul 26, 2026
d3ee18c
Own the thread ceiling where the bindings are built
t3dotgg Jul 26, 2026
c92ad95
Delete pending turn starts, and follow the real state directory
t3dotgg Jul 26, 2026
dba56ba
Mirror the server's turn-id guard when settling
t3dotgg Jul 26, 2026
8dee889
Fix single-column recency ordering and validate limits
t3dotgg Jul 26, 2026
da233f0
Break the state-dir tie on mtime, not a fixed preference
t3dotgg Jul 26, 2026
e7ac404
Match the base branch's bun run docs, correct the state-dir rule
t3dotgg Jul 26, 2026
974d683
Document the seeder's exported surface
t3dotgg Jul 26, 2026
bc12b99
Re-check for a live server before commit; make the tie WAL-aware
t3dotgg Jul 26, 2026
28a6971
Pin the two liveness probes to identical behavior
t3dotgg Jul 26, 2026
a37db1a
Give the fixture the checkpoint column its guard depends on
t3dotgg Jul 26, 2026
dc375f3
Cap activities by the order the app displays them
t3dotgg Jul 26, 2026
9c61dbe
Cover the command-receipts delete in the fixture
t3dotgg Jul 26, 2026
6045ce2
Rank activities by sequence, scope the seed guard, unify the probes
t3dotgg Jul 26, 2026
14a9306
Guard the canonical database path, not the base directory
t3dotgg Jul 26, 2026
aad1bdd
Refuse the shared home before reporting a missing database
t3dotgg Jul 26, 2026
b769bb6
Resolve the database file itself; missing-target outranks same-source
t3dotgg Jul 26, 2026
c698e6a
Fix dev sharing across hosted and restarted servers
t3dotgg Jul 26, 2026
2025191
Use dev:pair in recovery guidance
t3dotgg Jul 26, 2026
79e549c
Update hosted cookie test expectation
t3dotgg Jul 26, 2026
e125c34
Claim dev share ownership after serving
t3dotgg Jul 26, 2026
df39785
Make dev share acquisition transactional
t3dotgg Jul 26, 2026
9001730
Handoff dev share leases between clear and serve
t3dotgg Jul 26, 2026
200774a
Restore dev sharing when lease handoff fails
t3dotgg Jul 26, 2026
2187054
Keep seeded development state internally consistent
t3dotgg Jul 26, 2026
6800ee3
Address dev utility review findings
t3dotgg Jul 27, 2026
6588b84
Handle root seed-home containment
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
21 changes: 9 additions & 12 deletions .agents/skills/test-t3-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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`. 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.
3. Start the full web stack with `bun run dev`. Use `bun run dev: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.
Expand Down Expand Up @@ -54,26 +54,23 @@ Treat pairing URLs as secrets. Do not copy them into final responses, screenshot

## 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
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:pair
bun run dev:pair -- --base-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.
It finds the live server state under either the `dev` or `userdata` state directory and reuses the recorded web origin, including a tailnet URL. Pass `--base-dir` only when the server was started with `--home-dir`. Use the printed URL once.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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.
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

Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before changing the database.

- Use `node apps/server/scripts/t3-sqlite-state.ts query` for schema discovery and read-only checks.
- To populate realistic list and thread data, stop the server and run `bun run dev:seed`, then restart it. This copies recent projections from the shared home while refusing to write to it. It also refuses to run while a server is still using the target directory, so stop the server first rather than expecting the seed to wait.
- Stop the dev server before using `node apps/server/scripts/t3-sqlite-state.ts exec`, then restart it with the same base directory.
- Seed projection tables only for disposable UI fixtures. Use application commands and APIs when testing business behavior or projection correctness.
- Use the auth CLI, not direct `auth_*` table edits, for pairing and sessions.
Expand All @@ -95,7 +92,7 @@ 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`. Add `-- --base-dir <base-dir>` only when the server was started with `--home-dir`.
- If the replacement token is rejected, verify that `dev:pair` and the server resolve the same base directory.
- 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`.
2 changes: 2 additions & 0 deletions .agents/skills/test-t3-app/references/sqlite-fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The web UI primarily reads these projection tables:
- `projection_pending_approvals`
- `projection_thread_proposed_plans`

`projection_state` holds one cursor row per projector and is not read by the UI, but a fixture that omits it still breaks: `computeSnapshotSequence` returns 0 unless every projector has a row, which makes every shell snapshot advertise sequence 0. Write all of them (`PROJECTOR_NAMES` in `scripts/lib/projection-tables.ts`), at a sequence no higher than the event log actually reaches.

Inspect `PRAGMA table_info(<table>)` and the current migrations under `apps/server/src/persistence/Migrations/` before constructing inserts. Keep identifiers unique, timestamps as ISO strings, JSON columns valid, and related project/thread/turn IDs consistent.

For a substantial current example, inspect `seedDatabase` in `scripts/mobile-showcase-environment.ts`. Adapt its column set to the target database instead of assuming copied SQL remains current.
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

- 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 `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet.
- Use `vp run dev:pair` to issue a fresh URL for the running server without reconstructing its port or origin.
- Use `vp run dev:seed` while the server is stopped to populate an isolated database with recent projects and threads from the shared home. It refuses to run against a directory a live server is using.
- 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.
- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports.
Expand Down
6 changes: 2 additions & 4 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ const makeCookieRequest = (
({
cookies: {
// Derived, not hardcoded: the name is port-scoped so concurrent servers
// on one hostname don't share a cookie. Mode and devUrl mirror
// ServerConfig.layerTest, so this resolves to whatever the server reads.
[resolveSessionCookieName({ mode: "web", port: TEST_SERVER_PORT, devUrl: undefined })]:
sessionToken,
// on one hostname don't share a cookie.
[resolveSessionCookieName({ port: TEST_SERVER_PORT, devUrl: undefined })]: sessionToken,
},
headers: {},
}) as unknown as Parameters<
Expand Down
23 changes: 2 additions & 21 deletions apps/server/src/auth/EnvironmentAuthPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {

expect(descriptor.policy).toBe("desktop-managed-local");
expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
// Packaged desktop has no devUrl, but still needs the port scope: it
// scans upward from 3773 for a free port and binds 127.0.0.1, so a second
// instance shares this one's hostname on a different port.
expect(descriptor.sessionCookieName).toBe("t3_session_3773");
expect(descriptor.sessionCookieName).toBe("t3_session");
}).pipe(
Effect.provide(
makeEnvironmentAuthPolicyLayer({
Expand All @@ -48,22 +45,6 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
),
);

it.effect("keeps desktop cookies port-scoped on the port a second instance lands on", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const descriptor = yield* policy.getDescriptor();

expect(descriptor.sessionCookieName).toBe("t3_session_3774");
}).pipe(
Effect.provide(
makeEnvironmentAuthPolicyLayer({
mode: "desktop",
port: 3774,
}),
),
),
);

it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
Expand Down Expand Up @@ -100,7 +81,7 @@ it.layer(NodeServices.layer)("EnvironmentAuthPolicy.layer", (it) => {
),
);

it.effect("scopes web session cookies by port only in development", () =>
it.effect("scopes session cookies by port only in development", () =>
Effect.gen(function* () {
const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
const descriptor = yield* policy.getDescriptor();
Expand Down
6 changes: 1 addition & 5 deletions apps/server/src/auth/EnvironmentAuthPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +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,
devUrl: config.devUrl,
}),
sessionCookieName: resolveSessionCookieName({ port: config.port, devUrl: config.devUrl }),
};

return EnvironmentAuthPolicy.of({
Expand Down
1 change: 0 additions & 1 deletion apps/server/src/auth/SessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,6 @@ export const make = Effect.gen(function* () {
const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
const cookieName = resolveSessionCookieName({
mode: serverConfig.mode,
port: serverConfig.port,
devUrl: serverConfig.devUrl,
});
Expand Down
22 changes: 5 additions & 17 deletions apps/server/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,16 @@ import * as Result from "effect/Result";
const SESSION_COOKIE_NAME = "t3_session";

/**
* Cookies are scoped by host but *not* by port, so any two servers that can be
* live on one hostname at once need separate names — otherwise the second
* clobbers the first's session and both sides see "Invalid session token
* signature" until someone clears cookies by hand.
*
* Two populations qualify, for the same reason but from different causes:
*
* - **Dev servers** (`devUrl` set), which run several at a time across worktrees.
* - **Desktop**, which scans upward from 3773 for a free port and binds
* 127.0.0.1, so a second instance lands on a different port and the same host.
*
* Hosted deployments keep the stable production name: their public port can
* change between releases, and scoping it would log every user out.
* Cookies are scoped by host but *not* by port, so concurrent dev servers on a
* hostname need separate names. Hosted deployments keep the stable production
* name: their public port can change between releases, and scoping it would log
* every user out.
*/
export function resolveSessionCookieName(input: {
readonly mode: "web" | "desktop";
readonly port: number;
readonly devUrl: URL | undefined;
}): string {
return input.devUrl === undefined && input.mode !== "desktop"
? SESSION_COOKIE_NAME
: `${SESSION_COOKIE_NAME}_${input.port}`;
return input.devUrl === undefined ? SESSION_COOKIE_NAME : `${SESSION_COOKIE_NAME}_${input.port}`;
}

export function base64UrlEncode(input: string | Uint8Array): string {
Expand Down
161 changes: 161 additions & 0 deletions apps/server/src/cli/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";

import { persistServerRuntimeState } from "../serverRuntimeState.ts";
import { findLiveServerRuntimeState } from "./auth.ts";

/** A pid that cannot belong to a live process (above the usual pid_max). */
const DEAD_PID = 0x7ffffffe;

const writeRuntimeState = Effect.fn(function* (input: {
readonly baseDir: string;
readonly stateDir: "dev" | "userdata";
readonly pid: number;
readonly port: number;
readonly devUrl?: string;
}) {
const path = yield* Path.Path;
yield* persistServerRuntimeState({
path: path.join(input.baseDir, input.stateDir, "server-runtime.json"),
state: {
version: 1,
pid: input.pid,
port: input.port,
origin: `http://127.0.0.1:${String(input.port)}`,
...(input.devUrl ? { devUrl: input.devUrl } : {}),
startedAt: "2026-07-20T00:00:00.000Z",
},
});
});

const makeBaseDir = Effect.fn(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-auth-cli-test-" });
return {
baseDir,
// What the flag heuristic resolves to without --dev-url: the userdata dir.
serverRuntimeStatePath: path.join(baseDir, "userdata", "server-runtime.json"),
};
});

describe("findLiveServerRuntimeState", () => {
it.effect("prefers the dev server when both state directories are live", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* makeBaseDir();
// The configured path points at userdata, but this command is about dev.
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "userdata",
pid: process.pid,
port: 16_601,
});
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "dev",
pid: process.pid,
port: 16_602,
devUrl: "http://localhost:8888/",
});

const found = yield* findLiveServerRuntimeState(config);

const live = Option.getOrThrow(found);
assert.equal(live.stateDir, path.join(config.baseDir, "dev"));
assert.equal(live.state.devUrl, "http://localhost:8888/");
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("prefers a legacy dev state without a dev URL", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* makeBaseDir();
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "userdata",
pid: process.pid,
port: 16_601,
});
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "dev",
pid: process.pid,
port: 16_602,
});

const live = Option.getOrThrow(yield* findLiveServerRuntimeState(config));
assert.equal(live.stateDir, path.join(config.baseDir, "dev"));
assert.equal(live.state.port, 16_602);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("falls back to a live non-dev server when no dev server is running", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* makeBaseDir();
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "userdata",
pid: process.pid,
port: 16_601,
});

const found = yield* findLiveServerRuntimeState(config);

const live = Option.getOrThrow(found);
assert.equal(live.stateDir, path.join(config.baseDir, "userdata"));
assert.equal(live.state.port, 16_601);
}).pipe(Effect.provide(NodeServices.layer)),
);

// A server killed with SIGKILL never clears its state file.
it.effect("ignores state files whose process is gone", () =>
Effect.gen(function* () {
const config = yield* makeBaseDir();
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "dev",
pid: DEAD_PID,
port: 16_602,
devUrl: "http://localhost:8888/",
});

assert.isTrue(Option.isNone(yield* findLiveServerRuntimeState(config)));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("skips a stale dev server in favor of a live one elsewhere", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const config = yield* makeBaseDir();
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "dev",
pid: DEAD_PID,
port: 16_602,
devUrl: "http://localhost:8888/",
});
yield* writeRuntimeState({
baseDir: config.baseDir,
stateDir: "userdata",
pid: process.pid,
port: 16_601,
});

const live = Option.getOrThrow(yield* findLiveServerRuntimeState(config));
assert.equal(live.stateDir, path.join(config.baseDir, "userdata"));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("reports nothing when no state files exist", () =>
Effect.gen(function* () {
const config = yield* makeBaseDir();
assert.isTrue(Option.isNone(yield* findLiveServerRuntimeState(config)));
}).pipe(Effect.provide(NodeServices.layer)),
);
});
Loading
Loading