Skip to content
Merged
7 changes: 6 additions & 1 deletion apps/server/src/agents/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
worktreePathSlug,
} from "../shared/git/worktree.js";
import { readWorktreeStatus } from "../shared/git/worktree-status.js";
import { resolveMediaDir } from "../shared/media.js";
import {
buildGitContextForWorktree,
probeGitContext,
Expand Down Expand Up @@ -905,7 +906,11 @@ export class AgentManager {
}

try {
const mediaDir = agent.mediaDir ?? this.defaultMediaDir(id);
const mediaDir = resolveMediaDir(
id,
agent.mediaDir,
this.config.mediaRoot
);
// mediaDir must exist before launch — both runtimes assume the
// directory is present. (The original inert path created it
// explicitly; the tmux setup-script path created it via the
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/agents/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";

import type { Pool } from "pg";

import { resolveConfiguredPath } from "../shared/lib/resolve-tilde.js";
import type { AgentGitContext, AgentPin } from "./types.js";

export type ActivitySummaryResult = {
Expand Down Expand Up @@ -515,7 +516,7 @@ export async function listMedia(
return result.rows.map((row) => ({
fileName: row.fileName,
filePath: path.join(
row.mediaDir ?? fallbackMediaDir(agentId),
resolveConfiguredPath(row.mediaDir ?? fallbackMediaDir(agentId)),
row.fileName
),
description: row.description,
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/applied-migrations-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";

/**
* Local source of truth for which install-update migrations (CRU-146) have
* been applied on this install. Lives outside the repo checkout so reinstalls
Expand All @@ -14,9 +16,9 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
// reloading the module. Production hosts only set the env var at boot, so
// the lookup cost is negligible.
function appliedStorePath(): string {
return (
return resolveConfiguredPath(
process.env.DISPATCH_APPLIED_MIGRATIONS_STORE_PATH ??
path.join(os.homedir(), ".dispatch", "applied-migrations.json")
path.join(os.homedir(), ".dispatch", "applied-migrations.json")
);
}

Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/assisted-update-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { randomBytes } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";
import type { AssistedUpdateMetadata } from "./release-metadata.js";
import type { CheckResult } from "./release-checks.js";
import type { UpdateMigrationManifest } from "./update-migrations.js";
Expand Down Expand Up @@ -71,9 +73,9 @@ export type AssistedUpdateState = {
// reloading the module. Production hosts only set the env var at boot, so
// the lookup cost is negligible.
function assistedStorePath(): string {
return (
return resolveConfiguredPath(
process.env.DISPATCH_ASSISTED_UPDATE_STORE_PATH ??
path.join(os.homedir(), ".dispatch", "assisted-update.json")
path.join(os.homedir(), ".dispatch", "assisted-update.json")
);
}

Expand Down
18 changes: 7 additions & 11 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import "dotenv/config";
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -37,12 +39,6 @@ function requireEnv(name: string): string {
return value;
}

function expandHome(p: string): string {
return p.startsWith("~/")
? path.join(process.env.HOME ?? "/tmp", p.slice(2))
: p;
}

function loadTls(): TlsConfig | null {
const certPath = process.env.TLS_CERT;
const keyPath = process.env.TLS_KEY;
Expand All @@ -51,8 +47,8 @@ function loadTls(): TlsConfig | null {
throw new Error("Both TLS_CERT and TLS_KEY must be set to enable TLS");
}
return {
cert: readFileSync(expandHome(certPath)),
key: readFileSync(expandHome(keyPath)),
cert: readFileSync(resolveConfiguredPath(certPath)),
key: readFileSync(resolveConfiguredPath(keyPath)),
};
}

Expand Down Expand Up @@ -84,9 +80,9 @@ export function loadConfig(): AppConfig {
port: Number(process.env.DISPATCH_PORT ?? process.env.PORT ?? 6767),
databaseUrl: requireEnv("DATABASE_URL"),
authToken: "", // resolved from DB in start() via getOrCreateAuthToken()
mediaRoot:
process.env.MEDIA_ROOT ??
path.join(process.env.HOME ?? "/tmp", ".dispatch", "media"),
mediaRoot: resolveConfiguredPath(
process.env.MEDIA_ROOT ?? path.join(os.homedir(), ".dispatch", "media")
),
dispatchBinDir: path.resolve(__dirname, "..", "..", "..", "bin"),
codexBin:
process.env.DISPATCH_CODEX_BIN ?? process.env.CODEX_BIN ?? "codex",
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/release-candidate-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import os from "node:os";
import path from "node:path";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";

function candidateStorePath(): string {
return (
return resolveConfiguredPath(
process.env.DISPATCH_RELEASE_CANDIDATE_STORE_PATH ??
path.join(os.homedir(), ".dispatch", "release-candidate.json")
path.join(os.homedir(), ".dispatch", "release-candidate.json")
);
}

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/release-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { lstat, readFile } from "node:fs/promises";
import https from "node:https";
import os from "node:os";
import path from "node:path";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";
import type { RequiredCheckName } from "./release-metadata.js";
import { readReleaseStore } from "./release-store.js";
import { errorMessage } from "./shared/lib/error-message.js";
Expand Down Expand Up @@ -153,7 +155,7 @@ function escapeRegex(value: string): string {

function serviceDefinitionPath(): string {
const configured = process.env.DISPATCH_SERVICE_DEFINITION_PATH?.trim();
if (configured) return configured;
if (configured) return resolveConfiguredPath(configured);
return process.platform === "darwin"
? path.join(
os.homedir(),
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/release-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import os from "node:os";
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";

// Isolated dev stacks and E2E runs set DISPATCH_RELEASE_STORE_PATH to keep
// from reading the host's production release state. Default is the
// machine-scoped production path.
const RELEASE_STORE_PATH =
const RELEASE_STORE_PATH = resolveConfiguredPath(
process.env.DISPATCH_RELEASE_STORE_PATH ??
path.join(os.homedir(), ".dispatch", "release.json");
path.join(os.homedir(), ".dispatch", "release.json")
);

export type ReleaseRecord = {
tag: string;
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/release-tarball-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";

import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";
import { formatBytes } from "./shared/lib/format-bytes.js";
import { runCommand } from "./shared/lib/run-command.js";

Expand All @@ -32,9 +34,9 @@ export const RELEASE_ARTIFACT_NAME = "dispatch-release.tar.gz";
// reloading the module. Production hosts only set the env var at boot, so
// the lookup cost is negligible.
function cacheDir(): string {
return (
return resolveConfiguredPath(
process.env.DISPATCH_RELEASE_CACHE_DIR ??
path.join(os.homedir(), ".dispatch", "cache")
path.join(os.homedir(), ".dispatch", "cache")
);
}

Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ import {
type HttpRequestToken,
} from "./observability/service-resources.js";
import { readServiceResourcesCollectionEnabled } from "./observability/service-resources-settings.js";
import { resolveConfiguredPath } from "./shared/lib/resolve-tilde.js";

const config = loadConfig();
const app = Fastify({
Expand Down Expand Up @@ -260,9 +261,10 @@ function withStreamFlag<T extends AgentRecord>(
return { ...agent, hasStream: streamManager.hasStream(agent.id) };
}

const serverDir =
const serverDir = resolveConfiguredPath(
process.env.DISPATCH_SERVER_DIR ??
path.join(os.homedir(), ".dispatch", "server");
path.join(os.homedir(), ".dispatch", "server")
);
const releaseRuntime = createReleaseRuntime({
pool,
config,
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/server/release-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import path from "node:path";

import { resolveConfiguredPath } from "../shared/lib/resolve-tilde.js";

export type RunCommand = (
command: string,
args: string[],
Expand Down Expand Up @@ -45,7 +47,8 @@ export function isReleaseAuthoringEnabled(): boolean {
* must agree on which checkout that is.
*/
export function resolveAuthoringRepoDir(serverDir: string): string {
return process.env.DISPATCH_RELEASE_AUTHORING_REPO_DIR?.trim() || serverDir;
const configured = process.env.DISPATCH_RELEASE_AUTHORING_REPO_DIR?.trim();
return configured ? resolveConfiguredPath(configured) : serverDir;
}

export type AuthoringRemoteRefreshResult =
Expand Down Expand Up @@ -150,7 +153,7 @@ export function compareSemver(a: string, b: string): number {
export function fixedRuntimePath(serverDir: string): string {
const configured = process.env.DISPATCH_RUNTIME_PATH?.trim();
return configured
? path.resolve(configured)
? resolveConfiguredPath(configured)
: path.join(serverDir, "dispatch");
}

Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/shared/lib/resolve-tilde.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,17 @@ export function resolveTilde(value: string): string {
if (value === "~") return os.homedir();
return value;
}

/**
* Resolve a path that came from configuration — an env var or a stored
* column — into an absolute path.
*
* Config values are not read by a shell, so a leading `~` arrives as a
* literal character. Left alone it becomes a directory *named* `~` next to
* the process's working directory, which fails silently: writes succeed,
* and nothing can find them again. Every configured path goes through here
* so `~` means the same thing everywhere it can be written.
*/
export function resolveConfiguredPath(value: string): string {
return path.resolve(resolveTilde(value));
}
4 changes: 3 additions & 1 deletion apps/server/src/shared/media.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import path from "node:path";

import { resolveConfiguredPath } from "./lib/resolve-tilde.js";

import {
extensionForMime,
isDocumentFile,
Expand Down Expand Up @@ -46,7 +48,7 @@ export function resolveMediaDir(
mediaDir: string | null,
mediaRoot: string
): string {
return mediaDir ?? path.join(mediaRoot, agentId);
return resolveConfiguredPath(mediaDir ?? path.join(mediaRoot, agentId));
}

export function toMediaKey(file: { name: string; updatedAt: string }): string {
Expand Down
101 changes: 101 additions & 0 deletions apps/server/test/configured-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";

/**
* Every path Dispatch reads from configuration must expand a leading `~`.
*
* Each of these modules resolves its own env var, so the expansion is easy to
* add in one place and forget in another — which is what happened with
* MEDIA_ROOT: a literal `~` produced a directory *named* `~` beside the
* process working directory, writes succeeded, and nothing could find them
* again. These assert the file lands at the expanded location and that no
* literal-tilde directory is created anywhere.
*
* They cover both shapes present in the codebase: a path resolved inside a
* function (applied-migrations-store) and one resolved once at module load
* (release-store), which only reads the env var on first import.
*/

let tempHome: string;
const cleanup: string[] = [];

async function withTildeConfig<T>(
envName: string,
relative: string,
body: (expected: string) => Promise<T>
): Promise<T> {
tempHome = await mkdtemp(path.join(os.tmpdir(), "dispatch-cfg-home-"));
cleanup.push(tempHome);
const prevHome = process.env.HOME;
const prevValue = process.env[envName];
// os.homedir() reads $HOME on POSIX, so this keeps the test off the real one.
process.env.HOME = tempHome;
process.env[envName] = `~/${relative}`;
vi.resetModules();
try {
return await body(path.join(tempHome, relative));
} finally {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevValue === undefined) delete process.env[envName];
else process.env[envName] = prevValue;
}
}

afterEach(async () => {
vi.resetModules();
await Promise.all(
cleanup.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
);
await rm(path.join(process.cwd(), "~"), { recursive: true, force: true });
});

describe("configured paths expand a leading tilde", () => {
it("DISPATCH_APPLIED_MIGRATIONS_STORE_PATH (resolved per call)", async () => {
await withTildeConfig(
"DISPATCH_APPLIED_MIGRATIONS_STORE_PATH",
"state/applied-migrations.json",
async (expected) => {
const store = await import("../src/applied-migrations-store.js");
await store.writeAppliedMigrationsState({
appliedMigrations: {
"some-id": { appliedAt: "now", targetTag: "v1" },
},
});
expect((await stat(expected)).isFile()).toBe(true);
}
);
});

it("DISPATCH_RELEASE_STORE_PATH (resolved at module load)", async () => {
await withTildeConfig(
"DISPATCH_RELEASE_STORE_PATH",
"state/release.json",
async (expected) => {
const store = await import("../src/release-store.js");
await store.writeReleaseStore({
tag: "v1.2.3",
deployedAt: new Date(0).toISOString(),
});
expect((await stat(expected)).isFile()).toBe(true);
}
);
});

it("never creates a directory literally named ~", async () => {
await withTildeConfig(
"DISPATCH_APPLIED_MIGRATIONS_STORE_PATH",
"state/applied-migrations.json",
async () => {
const store = await import("../src/applied-migrations-store.js");
await store.writeAppliedMigrationsState({ appliedMigrations: {} });
await expect(stat(path.join(process.cwd(), "~"))).rejects.toMatchObject(
{ code: "ENOENT" }
);
}
);
});
});
Loading
Loading