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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,17 @@ SESSION_SECRET=insecure-dev-only-session-secret-0000

# Directory where the hub keeps durable repo and asset state. Created
# on boot if absent; relative paths resolve against the hub's working
# directory.
# directory. The default below lives inside this git work tree, so local
# `bun run dev` also needs HUB_ALLOW_GIT_INSIDE_WORK_TREE. Tests must
# not use this path — they install test/disposable-hub-data-dir.ts.
HUB_DATA_DIR=.data/hub

# Opt-in to initialize hub git-on-disk state inside an existing git work
# tree. Required for the default HUB_DATA_DIR above. Never set this in
# tests; a missed nested init walks up to this repo's .git and a genesis
# commit can land on the working branch and delete the checkout.
HUB_ALLOW_GIT_INSIDE_WORK_TREE=1

# Directory of built user-interface files the hub serves at BASE_URL.
# Relative paths resolve against the hub's working directory (apps/hub
# under `bun run dev`), so ../web/dist is the web app's build output;
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ behind human approval.
To check whether a workspace package exists, look in `packages/`, not
`node_modules` — an absent `node_modules` entry means "not installed
yet," not "doesn't exist."
- Bun loads repo-root `.env`, so an unset test inherits
`HUB_DATA_DIR=.data/hub` inside this work tree. The hub's git-on-disk
init then walks up to the enclosing `.git`, and a genesis commit can
land on the working branch and delete the checkout. Tests that boot
the hub or exercise seed/deploy git paths must call
`installDisposableHubDataDir()` from `test/disposable-hub-data-dir.ts`
(it mktemps outside any work tree and clears
`HUB_ALLOW_GIT_INSIDE_WORK_TREE`). Local `bun run dev` may set that
opt-in; tests must not. Run those suites with
`HUB_DATA_DIR=$(mktemp -d)` as a belt-and-suspenders override. Never
delete the `HUB_DATA_DIR` env key itself.
- `scripts/checks/*` are heuristics over source text, not proof — they can
and do false-positive (install artifacts read as vendored trees, comments
read as imports, class names read as user copy). A check failure is a
Expand Down
66 changes: 66 additions & 0 deletions apps/hub/src/asset-service-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterAll, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createBootAssetWiring } from "./asset-service-factory";
import { HubDataDirInsideGitWorkTreeError } from "./hub-data-dir-git-safety";

const scratch: string[] = [];

afterAll(() => {
for (const dir of scratch) rmSync(dir, { recursive: true, force: true });
});

function scratchDir(prefix: string): string {
const dir = mkdtempSync(path.join(tmpdir(), prefix));
scratch.push(dir);
return dir;
}

function git(cwd: string, args: readonly string[]): void {
const env = { ...process.env };
delete env["GIT_DIR"];
delete env["GIT_WORK_TREE"];
delete env["GIT_INDEX_FILE"];
env["GIT_AUTHOR_NAME"] = "safety-test";
env["GIT_AUTHOR_EMAIL"] = "safety@test";
env["GIT_COMMITTER_NAME"] = "safety-test";
env["GIT_COMMITTER_EMAIL"] = "safety@test";
const result = Bun.spawnSync(["git", "-c", "core.hooksPath=", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
env,
});
if (result.exitCode !== 0) {
throw new Error(
`git ${args.join(" ")} failed: ${result.stderr.toString()}`,
);
}
}

test("createBootAssetWiring refuses a data dir inside an existing git work tree", async () => {
const workTree = scratchDir("boot-asset-inside-");
git(workTree, ["init", "-b", "main"]);
const dataDir = path.join(workTree, ".data", "hub");
mkdirSync(dataDir, { recursive: true });
await expect(
createBootAssetWiring({ db: {} as never, dataDir }),
).rejects.toBeInstanceOf(HubDataDirInsideGitWorkTreeError);
});

test("createBootAssetWiring opt-in skips the work-tree refusal", async () => {
const workTree = scratchDir("boot-asset-opt-in-");
git(workTree, ["init", "-b", "main"]);
const dataDir = path.join(workTree, ".data", "hub");
mkdirSync(dataDir, { recursive: true });
try {
await createBootAssetWiring({
db: {} as never,
dataDir,
allowGitInsideWorkTree: true,
});
} catch (error) {
expect(error).not.toBeInstanceOf(HubDataDirInsideGitWorkTreeError);
}
});
6 changes: 6 additions & 0 deletions apps/hub/src/asset-service-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@intx/hub-sessions";
import { generateKeyPair } from "@intx/crypto";
import type { DB } from "@intx/db";
import { assertHubDataDirGitSafety } from "./hub-data-dir-git-safety";

// Host policy constant, not configuration — shared by boot (tool-package
// registry routing) and asset-service construction.
Expand All @@ -35,7 +36,12 @@ export interface BootAssetWiring {
export async function createBootAssetWiring(args: {
db: DB["db"];
dataDir: string;
allowGitInsideWorkTree?: boolean;
}): Promise<BootAssetWiring> {
assertHubDataDirGitSafety(
args.dataDir,
args.allowGitInsideWorkTree === true ? { allowInsideWorkTree: true } : {},
);
const signingKey = await generateKeyPair();
const agentRepoStore = createAgentRepoStore({
dataDir: args.dataDir,
Expand Down
7 changes: 7 additions & 0 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ const HubEnv = type({
"ALLOW_UNVERIFIED_EMAILS?": type("'1' | 'true'").describe(
"dev/test-only opt-in to let @workbench/access-policy trust an email that better-auth has not verified — self-signup domain checks and pending-invite redemption normally require emailVerified; never set this for a real deployment",
),
"HUB_ALLOW_GIT_INSIDE_WORK_TREE?": type("'1' | 'true'").describe(
"opt-in to initialize hub git-on-disk state inside a directory that is already a git work tree; refused by default because a nested init that misses its own .git walks up and commits onto the enclosing working branch",
),
"SIDECAR_PROVISIONERS?": type("string").describe(
"comma-separated sidecar-allocation backend ids to register for workbenches placed on their own exclusive sidecar, e.g. 'docker'; unset or empty (default) keeps the hub on its current single shared sidecar with no exclusive-placement backend available",
),
Expand Down Expand Up @@ -357,6 +360,8 @@ export type HubConfig = {
/** Dev/test-only opt-in to skip @workbench/access-policy's email-
* verification requirement. */
readonly allowUnverifiedEmails: boolean;
/** Opt-in to initialize git-on-disk state inside an existing git work tree. */
readonly allowGitInsideWorkTree?: boolean;
/** Dev/test-only override for the routine scheduler's poll interval —
* see `ROUTINE_SCHEDULER_POLL_INTERVAL_MS` above. Unset runs the real
* production cadence (`routine-scheduler.ts`'s own default). */
Expand Down Expand Up @@ -666,6 +671,8 @@ export function readHubConfig(
};
if (parsed.OPERATOR_TENANT_ID !== undefined)
hubConfig.operatorTenantId = parsed.OPERATOR_TENANT_ID;
if (parsed.HUB_ALLOW_GIT_INSIDE_WORK_TREE !== undefined)
hubConfig.allowGitInsideWorkTree = true;
if (parsed.PORT !== undefined) hubConfig.listenPort = Number(parsed.PORT);
if (parsed.ROUTINE_SCHEDULER_POLL_INTERVAL_MS !== undefined)
hubConfig.routineSchedulerPollIntervalMs = Number(
Expand Down
90 changes: 90 additions & 0 deletions apps/hub/src/hub-data-dir-git-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterAll, expect, test } from "bun:test";
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
assertHubDataDirGitSafety,
enclosingGitWorkTree,
HubDataDirInsideGitWorkTreeError,
} from "./hub-data-dir-git-safety";

const scratch: string[] = [];

afterAll(() => {
for (const dir of scratch) rmSync(dir, { recursive: true, force: true });
});

function scratchDir(prefix: string): string {
const dir = mkdtempSync(path.join(tmpdir(), prefix));
scratch.push(dir);
return dir;
}

function git(cwd: string, args: readonly string[]): void {
const env = { ...process.env };
delete env["GIT_DIR"];
delete env["GIT_WORK_TREE"];
delete env["GIT_INDEX_FILE"];
env["GIT_AUTHOR_NAME"] = "safety-test";
env["GIT_AUTHOR_EMAIL"] = "safety@test";
env["GIT_COMMITTER_NAME"] = "safety-test";
env["GIT_COMMITTER_EMAIL"] = "safety@test";
const result = Bun.spawnSync(["git", "-c", "core.hooksPath=", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
env,
});
if (result.exitCode !== 0) {
throw new Error(
`git ${args.join(" ")} failed: ${result.stderr.toString()}`,
);
}
}

test("a directory with no enclosing git work tree is allowed", () => {
const dir = scratchDir("hub-data-outside-");
expect(enclosingGitWorkTree(dir)).toBeNull();
expect(() => assertHubDataDirGitSafety(dir)).not.toThrow();
});

test("a directory inside an existing git work tree fails loud", () => {
const workTree = scratchDir("hub-data-inside-wt-");
git(workTree, ["init", "-b", "main"]);
writeFileSync(path.join(workTree, "keep.txt"), "keep\n");
git(workTree, ["add", "keep.txt"]);
git(workTree, ["commit", "-m", "keep"]);
const dataDir = path.join(workTree, ".data", "hub");
mkdirSync(dataDir, { recursive: true });

expect(enclosingGitWorkTree(dataDir)).toBe(realpathSync(workTree));
expect(() => assertHubDataDirGitSafety(dataDir)).toThrow(
HubDataDirInsideGitWorkTreeError,
);
try {
assertHubDataDirGitSafety(dataDir);
throw new Error("expected assertHubDataDirGitSafety to throw");
} catch (error) {
expect(error).toBeInstanceOf(HubDataDirInsideGitWorkTreeError);
const message = (error as Error).message;
expect(message).toContain(realpathSync(dataDir));
expect(message).toContain(realpathSync(workTree));
expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE=1");
expect(message).toContain("refuses");
}
});

test("opt-in allows initializing inside an existing git work tree", () => {
const workTree = scratchDir("hub-data-opt-in-");
git(workTree, ["init", "-b", "main"]);
const dataDir = path.join(workTree, ".data", "hub");
expect(() =>
assertHubDataDirGitSafety(dataDir, { allowInsideWorkTree: true }),
).not.toThrow();
});
82 changes: 82 additions & 0 deletions apps/hub/src/hub-data-dir-git-safety.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Refuse to initialize hub git-on-disk state inside an existing git work
// tree. isomorphic-git walks up from `dataDir` looking for `.git`; when
// HUB_DATA_DIR lives inside this repo (the `.env` default `.data/hub`),
// a missed nested init authors a genesis commit onto the working branch
// and a checkout of that tree can delete the checkout.
import { spawnSync } from "node:child_process";
import { mkdirSync, realpathSync } from "node:fs";
import path from "node:path";

const GIT_DIR_VARS = new Set([
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
]);

export function envWithoutGitDir(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (GIT_DIR_VARS.has(key)) continue;
next[key] = value;
}
return next;
}

export function enclosingGitWorkTree(
dir: string,
env: NodeJS.ProcessEnv = process.env,
): string | null {
const absolute = path.resolve(dir);
mkdirSync(absolute, { recursive: true });
const canonical = realpathSync(absolute);
const result = spawnSync(
"git",
["-c", "safe.directory=*", "-C", canonical, "rev-parse", "--show-toplevel"],
{ encoding: "utf8", env: envWithoutGitDir(env) },
);
if (result.status !== 0) return null;
const toplevel = result.stdout.trim();
return toplevel === "" ? null : realpathSync(toplevel);
}

export class HubDataDirInsideGitWorkTreeError extends Error {
readonly hubDataDir: string;
readonly enclosingWorkTree: string;

constructor(hubDataDir: string, enclosingWorkTree: string) {
super(
[
`HUB_DATA_DIR (${hubDataDir}) is inside the git work tree at ${enclosingWorkTree}.`,
"The hub refuses to initialize git-on-disk state there: a nested",
"init that misses its own .git walks up and commits onto the working",
"branch, which can delete the checkout.",
"Point HUB_DATA_DIR at a directory outside any git work tree, or set",
"HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 to opt in.",
].join(" "),
);
this.name = "HubDataDirInsideGitWorkTreeError";
this.hubDataDir = hubDataDir;
this.enclosingWorkTree = enclosingWorkTree;
}
}

export function assertHubDataDirGitSafety(
dir: string,
opts?: {
allowInsideWorkTree?: boolean;
env?: NodeJS.ProcessEnv;
},
): void {
if (opts?.allowInsideWorkTree === true) return;
const enclosing = enclosingGitWorkTree(dir, opts?.env ?? process.env);
if (enclosing === null) return;
throw new HubDataDirInsideGitWorkTreeError(
realpathSync(path.resolve(dir)),
enclosing,
);
}
8 changes: 7 additions & 1 deletion apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,13 @@ export async function createHub(config: HubConfig) {
config.signInRateLimit.max,
);
const { signingKey, agentRepoStore, assetService } =
await createBootAssetWiring({ db, dataDir: config.hubDataDir });
await createBootAssetWiring({
db,
dataDir: config.hubDataDir,
...(config.allowGitInsideWorkTree === true
? { allowGitInsideWorkTree: true }
: {}),
});
const baseLookups = createHubSessionLookups({ db, agentRepoStore });
// Shared with `createRunKeyHistoryListener` below: one store instance
// for the process, read here ahead of `workflow_run` and written to
Expand Down
23 changes: 23 additions & 0 deletions apps/hub/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,29 @@ describe("readHubConfig", () => {
expect(readHubConfig(validEnv).allowUnverifiedEmails).toBe(false);
});

test("allowGitInsideWorkTree is omitted by default", () => {
expect(readHubConfig(validEnv).allowGitInsideWorkTree).toBeUndefined();
});

test("HUB_ALLOW_GIT_INSIDE_WORK_TREE='1' or 'true' opts in", () => {
expect(
readHubConfig({ ...validEnv, HUB_ALLOW_GIT_INSIDE_WORK_TREE: "1" })
.allowGitInsideWorkTree,
).toBe(true);
expect(
readHubConfig({ ...validEnv, HUB_ALLOW_GIT_INSIDE_WORK_TREE: "true" })
.allowGitInsideWorkTree,
).toBe(true);
});

test("HUB_ALLOW_GIT_INSIDE_WORK_TREE rejects any other value", () => {
const message = readExpectingError({
...validEnv,
HUB_ALLOW_GIT_INSIDE_WORK_TREE: "yes",
});
expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE");
});

test("ALLOW_UNVERIFIED_EMAILS='1' or 'true' opts in", () => {
expect(
readHubConfig({ ...validEnv, ALLOW_UNVERIFIED_EMAILS: "1" })
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"setup:memory": "bun run scripts/setup-memory.ts",
"seed": "bun packages/cli/src/index.ts seed",
"reset": "bun packages/cli/src/index.ts reset",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:db-gate && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:report-error && bun run check:tsconfig-references",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:db-gate && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:hub-git-safety && bun run check:report-error && bun run check:tsconfig-references",
"check:deletion": "bun run scripts/checks/deletion.ts",
"check:report-error": "bun run scripts/checks/report-error.ts",
"check:killdates": "bun run scripts/checks/killdates.ts",
Expand All @@ -42,6 +42,7 @@
"check:react-ui-pin": "bun run scripts/checks/react-ui-pin.ts",
"check:tool-package-pins": "bun run scripts/checks/tool-package-pins.ts",
"check:tool-package-freshness": "bun run scripts/checks/tool-package-freshness.ts",
"check:hub-git-safety": "bun run scripts/checks/hub-git-safety.ts",
"check:tsconfig-references": "bun run scripts/generate-tsconfig-references.ts --check",
"build:sidecar-image": "docker build -f apps/sidecar/Dockerfile -t corbits-sidecar:dev .",
"eval": "bun run scripts/evals-run.ts"
Expand Down
Loading
Loading