Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .agents/skills/test-pylon-app/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
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.
Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed a shared runtime home: `~/.pylon-code` is Pylon's live install, and `~/.t3` may be T3 Code's. 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.

Expand Down Expand Up @@ -67,7 +67,7 @@ Read [references/sqlite-fixtures.md](references/sqlite-fixtures.md) before chang
- 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.

The helper refuses to write to the shared `~/.t3` directory by default and creates a database backup before each mutation.
The helper refuses to write to either runtime home, `~/.pylon-code` or `~/.t3`, by default and creates a database backup before each mutation.

## Tear down only when the testing loop is finished

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ We need to be on the same page with terminology. When communicating, use this la
- **project** means an environment-local workspace record rooted at a directory.
- **thread** means the durable conversation and work history for a project.
- **turn** means one user-to-agent cycle, including follow-up work such as checkpointing.
- **runtime home** means the base data directory. It currently uses T3-compatible paths and environment variables; runtime state normally lives below its `userdata` directory.
- **runtime home** means the base data directory. It defaults to `~/.pylon-code` on every launch path, while the override names stay T3-compatible (`--base-dir`, `T3CODE_HOME`); runtime state normally lives below its `userdata` directory.

## The three ways to hurt yourself

Expand Down
66 changes: 66 additions & 0 deletions apps/server/scripts/migrate-dev-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,70 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
assert.equal(error._tag, "MigrateDevDbSharedHomeError");
}),
);

// The source home and the write guard are separate concerns. This run
// deletes its destination database, so narrowing the guard to wherever the
// source lives would leave the other runtime home writable — and `~/.t3` may
// be a live T3 Code install.
it.effect("refuses to rebuild either runtime home", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-protected-" });

// Deliberately no `source`: the guard runs first, so aiming --base-dir at
// a live install is refused as such rather than reported as a missing
// source the user never chose.
for (const name of [".t3", ".pylon-code"]) {
const baseDir = path.join(homeDir, name);
yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true });
const error = yield* runMigrateDevDb(
{ baseDir, projects: 5, threadsPerProject: 10 },
{ homeDir },
).pipe(Effect.flip);
assert.equal(error._tag, "MigrateDevDbSharedHomeError", name);
assert.equal(
error.message,
"Refusing to rebuild a shared runtime home database (~/.pylon-code or ~/.t3). Use an isolated --base-dir.",
);
}

// An isolated directory under the same home is still a valid target.
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-src-" });
const source = yield* createFixtureSource(sourceDir);
const isolated = path.join(homeDir, "scratch");
const result = yield* runMigrateDevDb(
{ baseDir: isolated, source, projects: 5, threadsPerProject: 10 },
{ homeDir },
);
assert.equal(result.databasePath, path.join(isolated, "userdata", "state.sqlite"));
}),
);

// `~/.t3` may be T3 Code's database, whose schema carries upstream's
// migration numbering; seeding a Pylon dev database from it is the hazard
// AGENTS.md "Test data" exists to prevent.
it.effect("defaults its source to the Pylon runtime home", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// An injected home with neither runtime directory present, so the run
// stops at "source missing" and names the path it looked in.
const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-home-" });
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-target-" });

const error = yield* runMigrateDevDb(
{ baseDir, projects: 1, threadsPerProject: 1 },
{ homeDir },
).pipe(Effect.flip);

assert.equal(error._tag, "MigrateDevDbSourceMissingError");
if (error._tag === "MigrateDevDbSourceMissingError") {
assert.equal(
error.sourcePath,
path.join(homeDir, ".pylon-code", "userdata", "state.sqlite"),
);
}
}),
);
});
60 changes: 44 additions & 16 deletions apps/server/scripts/migrate-dev-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/**
* Rebuild an isolated dev database from a pruned snapshot of the real
* ~/.t3 database, then run this checkout's migrations against it.
* ~/.pylon-code database, then run this checkout's migrations against it.
*
* `vp run migrate-dev-db` from a worktree:
* 1. Nukes `<worktree>/.t3/userdata/state.sqlite`.
Expand All @@ -24,7 +24,7 @@
* cursors never rewind.
*/

// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the runtime home guard.
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeOS from "node:os";
Expand Down Expand Up @@ -55,7 +55,7 @@ export class MigrateDevDbSharedHomeError extends Schema.TaggedErrorClass<Migrate
{},
) {
override get message(): string {
return "Refusing to rebuild the shared ~/.t3 database. Use an isolated --base-dir.";
return "Refusing to rebuild a shared runtime home database (~/.pylon-code or ~/.t3). Use an isolated --base-dir.";
}
}

Expand Down Expand Up @@ -144,15 +144,20 @@ export class MigrateDevDbPhaseError extends Schema.TaggedErrorClass<MigrateDevDb
export interface RunMigrateDevDbInput {
/** Isolated .t3 directory. Defaults to `<worktree>/.t3` of the cwd. */
readonly baseDir?: string | undefined;
/** Source database. Defaults to `~/.t3/userdata/state.sqlite`. */
/** Source database. Defaults to `~/.pylon-code/userdata/state.sqlite`. */
readonly source?: string | undefined;
readonly projects: number;
readonly threadsPerProject: number;
}

export interface RunMigrateDevDbOptions {
/** Overridable for tests; the directory writes must never target. */
/**
* Overridable for tests. Replaces both the home the default `--source` is
* read from and the sole directory `--base-dir` may not name.
*/
readonly sharedHome?: string | undefined;
/** Injected by tests so they never read the developer's own home. */
readonly homeDir?: string | undefined;
}

interface KeptProject {
Expand Down Expand Up @@ -361,11 +366,28 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
const homeDir = options.homeDir ?? NodeOS.homedir();

// Where a default `--source` is read from. Pylon's runtime home, never
// `~/.t3`: seeding a dev database from T3 Code's would copy a schema carrying
// upstream's migration numbering into a Pylon server that then dies on its
// first query.
const sourceHome = path.resolve(options.sharedHome ?? path.join(homeDir, ".pylon-code"));
const sourcePath = path.resolve(
input.source ?? path.join(sharedHome, "userdata", "state.sqlite"),
input.source ?? path.join(sourceHome, "userdata", "state.sqlite"),
);

// Which directories `--base-dir` may not name. Deliberately *not* the same
// list as the source home: this run deletes its destination database, so
// narrowing the guard to wherever the source happens to live would leave the
// other runtime home writable. `~/.t3` may be T3 Code's install, and this is
// the only thing standing between `--base-dir ~/.t3` and removeDatabaseFiles.
const protectedHomes = (
options.sharedHome === undefined
? [path.join(homeDir, ".pylon-code"), path.join(homeDir, ".t3")]
: [options.sharedHome]
).map((home) => path.resolve(home));

const baseDir =
input.baseDir !== undefined
? path.resolve(input.baseDir)
Expand All @@ -377,16 +399,22 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
const databasePath = path.join(stateDir, "state.sqlite");
const snapshotPath = `${databasePath}.migrate-dev-db-tmp`;

// Ahead of every other check, including whether a source exists: someone who
// aimed --base-dir at a live install needs to be told that, not handed an
// unrelated complaint about the source they never chose.
//
// Compared canonically so a symlink pointing at a protected home cannot slip
// past the guard.
const canonicalBaseDir = yield* fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir));
const canonicalProtectedHomes = yield* Effect.all(
protectedHomes.map((home) => fs.realPath(home).pipe(Effect.orElseSucceed(() => home))),
);
if (canonicalProtectedHomes.includes(canonicalBaseDir)) {
return yield* new MigrateDevDbSharedHomeError();
}
if (!(yield* fs.exists(sourcePath))) {
return yield* new MigrateDevDbSourceMissingError({ sourcePath });
}
const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
fs.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)),
fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
]);
if (canonicalBaseDir === canonicalSharedHome) {
return yield* new MigrateDevDbSharedHomeError();
}
// The destination db and snapshot both get deleted below; a --source that
// resolves to either (e.g. a leftover snapshot file) would be destroyed
// before it is ever read.
Expand Down Expand Up @@ -521,7 +549,7 @@ export const migrateDevDbCommand = Command.make(
),
source: Flag.string("source").pipe(
Flag.optional,
Flag.withDescription("Source database. Defaults to ~/.t3/userdata/state.sqlite."),
Flag.withDescription("Source database. Defaults to ~/.pylon-code/userdata/state.sqlite."),
),
},
({ projects, threadsPerProject, baseDir, source }) =>
Expand All @@ -548,7 +576,7 @@ export const migrateDevDbCommand = Command.make(
}),
).pipe(
Command.withDescription(
"Rebuild the worktree dev database from a pruned snapshot of the real ~/.t3 data, then run migrations.",
"Rebuild the worktree dev database from a pruned snapshot of the real ~/.pylon-code data, then run migrations.",
),
);

Expand Down
45 changes: 45 additions & 0 deletions apps/server/scripts/t3-sqlite-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,49 @@ it.layer(NodeServices.layer)("t3-sqlite-state", (it) => {
assert.equal(aliasError._tag, "SqliteStateSharedHomeMutationError");
}),
);

// Both runtime homes are off limits by default: `~/.pylon-code` is Pylon's
// live install, and `~/.t3` may be T3 Code's database entirely.
it.effect("refuses both runtime homes when no shared home is configured", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// An injected home, so this never reads or writes the real one.
const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sqlite-state-home-" });

for (const name of [".pylon-code", ".t3"]) {
const baseDir = path.join(homeDir, name);
yield* createFixtureDatabase(baseDir);
const error = yield* runSqliteState(
{ operation: "exec", baseDir, sql: "DELETE FROM fixtures" },
{ homeDir },
).pipe(Effect.flip);
assert.equal(error._tag, "SqliteStateSharedHomeMutationError", name);
assert.equal(
error.message,
"Refusing to mutate a shared runtime home database (~/.pylon-code or ~/.t3). Use an isolated --base-dir.",
);
}

// Reads are still fine against a protected home, and an isolated base
// dir under the same home is still writable.
const readOnly = yield* runSqliteState(
{
operation: "query",
baseDir: path.join(homeDir, ".pylon-code"),
sql: "SELECT id FROM fixtures",
},
{ homeDir },
);
assert.equal(readOnly.operation, "query");

const isolated = path.join(homeDir, "scratch");
yield* createFixtureDatabase(isolated);
const allowed = yield* runSqliteState(
{ operation: "exec", baseDir: isolated, sql: "DELETE FROM fixtures" },
{ homeDir },
);
assert.equal(allowed.operation, "exec");
}),
);
});
32 changes: 23 additions & 9 deletions apps/server/scripts/t3-sqlite-state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared T3 home guard.
// @effect-diagnostics nodeBuiltinImport:off - node:os resolves the shared runtime home guard.
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeOS from "node:os";
Expand Down Expand Up @@ -54,7 +54,7 @@ export class SqliteStateDatabaseMissingError extends Schema.TaggedErrorClass<Sql
},
) {
override get message(): string {
return `Database does not exist at '${this.databasePath}'. Start T3 once to run migrations.`;
return `Database does not exist at '${this.databasePath}'. Start Pylon once to run migrations.`;
}
}

Expand All @@ -63,7 +63,7 @@ export class SqliteStateSharedHomeMutationError extends Schema.TaggedErrorClass<
{},
) {
override get message(): string {
return "Refusing to mutate the shared ~/.t3 database. Use an isolated --base-dir.";
return "Refusing to mutate a shared runtime home database (~/.pylon-code or ~/.t3). Use an isolated --base-dir.";
}
}

Expand Down Expand Up @@ -125,7 +125,14 @@ export interface RunSqliteStateInput {
}

export interface RunSqliteStateOptions {
/**
* Overrides the directories this refuses to mutate. Unset guards both real
* runtime homes: `~/.pylon-code` is Pylon's live install, and `~/.t3` may be
* T3 Code's, which is someone else's database entirely.
*/
readonly sharedHome?: string | undefined;
/** Injected by tests so they never name the developer's own home. */
readonly homeDir?: string | undefined;
}

const resolveSqlSource = Effect.fn("resolveSqliteStateSqlSource")(function* (
Expand Down Expand Up @@ -182,19 +189,26 @@ export const runSqliteState = Effect.fn("runSqliteState")(function* (
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const baseDir = path.resolve(input.baseDir);
const sharedHome = path.resolve(options.sharedHome ?? path.join(NodeOS.homedir(), ".t3"));
const homeDir = options.homeDir ?? NodeOS.homedir();
const protectedHomes = (
options.sharedHome === undefined
? [path.join(homeDir, ".pylon-code"), path.join(homeDir, ".t3")]
: [options.sharedHome]
).map((home) => path.resolve(home));
const databasePath = path.join(baseDir, "userdata", "state.sqlite");
const source = yield* resolveSqlSource(input.sql, input.file);

if (!(yield* fs.exists(databasePath))) {
return yield* new SqliteStateDatabaseMissingError({ databasePath });
}
if (input.operation === "exec") {
const [canonicalBaseDir, canonicalSharedHome] = yield* Effect.all([
fs.realPath(baseDir),
fs.realPath(sharedHome).pipe(Effect.orElseSucceed(() => sharedHome)),
]);
if (canonicalBaseDir === canonicalSharedHome) {
// Compared canonically so a symlink pointing at a protected home cannot
// slip past the guard.
const canonicalBaseDir = yield* fs.realPath(baseDir);
const canonicalProtectedHomes = yield* Effect.all(
protectedHomes.map((home) => fs.realPath(home).pipe(Effect.orElseSucceed(() => home))),
);
if (canonicalProtectedHomes.includes(canonicalBaseDir)) {
return yield* new SqliteStateSharedHomeMutationError();
}
}
Expand Down
Loading
Loading