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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Unreleased
- Config: interpolate host environment variables (`$VAR` / `${VAR}`, `$$` for a
literal `$`) into any config string value. Enables e.g. mounting `$PWD`.
- Config: simplified the `env` schema. Removed `fromHost`; an env var is now a
literal string (`$VAR`-interpolated) or an object
`{ value?, secret?, injectForHosts? }`, where an omitted `value` reads the
host var named like the key. Secrets use `injectForHosts` (was `hosts`).


# 0.1.0 (2026-06-15)
Initial release.

Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,20 @@ directories (and so on), which in turn inherit from the global
},
"env": {
"SOME_VAR": "fixed_value", // Literal value
"MY_VAR": { "fromHost": "MY_VARIABLE" } // Read from host env (different name)
"EDITOR": { "fromHost": true }, // Read from host env (same var name)
"MY_VAR": "${MY_VARIABLE}_and_a_suffix", // ${MY_VARIABLE} is interpolated from the host env
"EDITOR": {}, // Read host var named like the key (i.e. $EDITOR)
"AUTH_TOKEN": {
// Injected as a secret: the guest sees a placeholder; the real value
// (host's $AUTH_TOKEN here, since `value` field is omitted) is substituted only
// in HTTPS requests to these hosts.
"secret": true,
"fromHost": true,
"hosts": ["my-api.hostname.com"]
"injectForHosts": ["my-api.hostname.com"]
},
"GH_TOKEN": {
// A secret whose value comes from a differently-named host var:
"secret": true,
"value": "$GITHUB_TOKEN",
"injectForHosts": ["*.github.com"]
}
},
"mounts": [
Expand Down
92 changes: 92 additions & 0 deletions src/config/interpolate-vars.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: testing literal ${VAR} interpolation syntax in plain strings
import { describe, expect, test } from "vitest";
import { interpolateVars } from "./interpolate-vars.ts";

describe("interpolateVars", () => {
describe("string interpolation", () => {
test("interpolates a bare $VAR", () => {
expect(interpolateVars("$HOME/projects", { HOME: "/home/me" })).toBe(
"/home/me/projects",
);
});

test("interpolates a braced ${VAR}", () => {
expect(interpolateVars("${HOME}/projects", { HOME: "/home/me" })).toBe(
"/home/me/projects",
);
});

test("braces allow a variable adjacent to word characters", () => {
expect(interpolateVars("${SIZE}B", { SIZE: "2" })).toBe("2B");
});

test("interpolates multiple variables in one string", () => {
expect(interpolateVars("$A:$B", { A: "first", B: "second" })).toBe(
"first:second",
);
});

test("$$ is replaced with a literal $", () => {
expect(interpolateVars("price$$5", {})).toBe("price$5");
});

test("$$ next to a variable does not consume the variable", () => {
expect(interpolateVars("$$$VAR", { VAR: "x" })).toBe("$x");
});

test("leaves a $ not forming a valid name untouched", () => {
expect(interpolateVars("$5 and 100$", {})).toBe("$5 and 100$");
});

test("leaves a malformed ${...} untouched", () => {
expect(interpolateVars("${FOO-BAR}", {})).toBe("${FOO-BAR}");
});
});

describe("recursion", () => {
test("interpolates string values inside arrays", () => {
expect(
interpolateVars(["$A", "literal", "$B"], { A: "1", B: "2" }),
).toEqual(["1", "literal", "2"]);
});

test("interpolates string values inside nested objects", () => {
expect(
interpolateVars(
{ mount: { hostPath: "$HOME/x", mode: "readonly" } },
{ HOME: "/home/me" },
),
).toEqual({ mount: { hostPath: "/home/me/x", mode: "readonly" } });
});

test("never interpolates object keys", () => {
expect(interpolateVars({ $FOO: "$BAR" }, { BAR: "v" })).toEqual({
$FOO: "v",
});
});
});

describe("non-string values", () => {
test("passes numbers, booleans and null through unchanged", () => {
expect(interpolateVars({ n: 2, b: true, z: null }, {})).toEqual({
n: 2,
b: true,
z: null,
});
});
});

describe("missing variables", () => {
test("throws naming the missing variable", () => {
expect(() => interpolateVars("$NOPE", {})).toThrowError(
/environment variable "NOPE" is not set/,
);
});

test("error message includes the JSON path of the offending value", () => {
expect(() =>
interpolateVars({ mounts: [{ hostPath: "$NOPE" }] }, {}),
).toThrowError(/at "mounts\[0\]\.hostPath"/);
});
});
});
79 changes: 79 additions & 0 deletions src/config/interpolate-vars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Interpolation of host environment variables into config string values.
*
* Runs on the raw JSON tree (before schema parsing), so it covers *every*
* string value — paths, network hosts, env values, … — without enumerating
* config fields. Object keys are deliberately left untouched.
*/

export type InterpolationVars = Record<string, string | undefined>;

/**
* Recursively interpolate `$VAR` / `${VAR}` references in every string value of
* `value`, looking names up in `vars`. Object keys are never interpolated;
* numbers, booleans and null pass through unchanged.
*
* Syntax (see {@link VAR_PATTERN}):
* - `$NAME` / `${NAME}` where NAME matches `[A-Za-z_][A-Za-z0-9_]*`
* - `$$` is an escape for a literal `$`
* - A `$` not forming one of the above (e.g. `"$5"`, a trailing `"100$"`, or a
* malformed `"${FOO-BAR}"`) is left as-is.
*
* Throws if a referenced variable is not present in `vars` (fail-fast: an unset
* variable silently collapsing a path to "" would be worse than a hard error).
*/
export function interpolateVars(
value: unknown,
vars: InterpolationVars,
): unknown {
return interpolate(value, vars, "");
}

// --- Internals ---

// Alternation order matters: `$$` must be tried before the bare `$NAME` form.
const VAR_PATTERN =
/\$\$|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;

function interpolate(
value: unknown,
vars: InterpolationVars,
path: string,
// `path` is threaded purely to make the missing-variable error point at the
// offending location in the config tree.
): unknown {
if (typeof value === "string") {
return interpolateString(value, vars, path);
}
if (Array.isArray(value)) {
return value.map((item, i) => interpolate(item, vars, `${path}[${i}]`));
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [
key, // keys are never interpolated
interpolate(val, vars, path ? `${path}.${key}` : key),
]),
);
}
return value;
}

function interpolateString(
str: string,
vars: InterpolationVars,
path: string,
): string {
return str.replace(VAR_PATTERN, (match, braced?: string, bare?: string) => {
if (match === "$$") return "$";
const name = braced ?? bare!;
const resolved = vars[name];
if (resolved === undefined) {
throw new Error(
`Config interpolation error${path ? ` at "${path}"` : ""}: ` +
`environment variable "${name}" is not set`,
);
}
return resolved;
});
}
9 changes: 8 additions & 1 deletion src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { SessionSpec } from "../core/session.ts";
import { interpolateVars } from "./interpolate-vars.ts";
import { findAllConfigDirs, mergeConfigs } from "./merge.ts";
import { resolveConfig } from "./resolve.ts";
import { parseConfig } from "./schema.ts";
Expand All @@ -28,9 +29,15 @@ export function loadConfig(): LoadedConfig {
console.log(`Loading config: ${join(dir, "config.json")}`);
}

// Interpolate $VAR / ${VAR} against the host env per layer (before parsing,
// so interpolated values are still schema-validated and every string value
// is covered).
const layers = configDirs.map((dir) => ({
config: parseConfig(
JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")),
interpolateVars(
JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")),
process.env,
),
),
configDir: dir,
}));
Expand Down
10 changes: 6 additions & 4 deletions src/config/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,17 @@ describe("mergeConfigs", () => {
test("secrets merge like other env values", () => {
const result = mergeConfigs([
layer("/a", {
env: { KEY: { secret: true, fromHost: true, hosts: ["a.com"] } },
env: { KEY: { secret: true, injectForHosts: ["a.com"] } },
}),
layer("/b", {
env: { OTHER: { secret: true, fromHost: "X", hosts: ["b.com"] } },
env: {
OTHER: { secret: true, value: "$X", injectForHosts: ["b.com"] },
},
}),
]);
expect(result.env).toEqual({
KEY: { secret: true, fromHost: true, hosts: ["a.com"] },
OTHER: { secret: true, fromHost: "X", hosts: ["b.com"] },
KEY: { secret: true, injectForHosts: ["a.com"] },
OTHER: { secret: true, value: "$X", injectForHosts: ["b.com"] },
});
});

Expand Down
Loading