fix(settings): harden conductor imports

This commit is contained in:
Mohamed Boudra
2026-07-17 14:48:54 +02:00
parent f030ddefb1
commit f44ef807ef
12 changed files with 216 additions and 46 deletions

View File

@@ -238,6 +238,93 @@ setup = "echo $MY_CONDUCTOR_PORT_BACKUP $CONDUCTOR_DEFAULT_BRANCH"
);
});
test("preserves shell expansion for environment variables in script arguments", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.dev]
command = "npm run dev"
args = ["--port", "$CONDUCTOR_PORT", "--label=$WORKSPACE_NAME"]
`,
);
expect(inspect(repo).preview).toMatchObject({
scripts: {
dev: {
type: "service",
port: "$PASEO_PORT",
command: `npm run dev '--port' "$PASEO_PORT" '--label='"$WORKSPACE_NAME"`,
},
},
});
});
test("rejects normalized working directories that escape the project root", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.parent]
command = "npm test"
[scripts.run.parent.options]
cwd = "./.."
[scripts.run.nested]
command = "npm test"
[scripts.run.nested.options]
cwd = "apps/web/../../.."
`,
);
const preview = inspect(repo);
expect(preview.preview).toMatchObject({
scripts: {
parent: { command: "npm test" },
nested: { command: "npm test" },
},
});
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "scripts.parent.cwd", outcome: "unsupported" }),
expect.objectContaining({ key: "scripts.nested.cwd", outcome: "unsupported" }),
]),
);
});
test("does not import scripts available only in Conductor cloud", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.cloud]
command = "npm run cloud"
available_in = ["cloud"]
[scripts.run.everywhere]
command = "npm run everywhere"
available_in = ["local", "cloud"]
`,
);
const preview = inspect(repo);
expect(preview.preview).toMatchObject({
scripts: { everywhere: { command: "npm run everywhere" } },
});
expect(preview.preview?.scripts).not.toHaveProperty("cloud");
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "scripts.cloud",
outcome: "unsupported",
detail: "Cloud-only scripts are not imported.",
}),
]),
);
});
test("malformed TOML identifies the safe relative source path", () => {
const repo = makeRepo();
writeSharedToml(repo, "[scripts\nsetup = nope");

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { join, posix, relative } from "node:path";
import { parse as parseToml } from "smol-toml";
import type { PaseoConfigRaw, PaseoScriptEntryRaw } from "@getpaseo/protocol/messages";
import type { ProjectConfigImportAdapter } from "../../registry.js";
@@ -40,7 +40,7 @@ interface ConductorRunScript {
options?: {
cwd?: string;
};
available_in?: string;
available_in?: string | string[];
}
type RewriteContext = "lifecycle" | "run";
@@ -214,11 +214,12 @@ function normalizeRunScript(entry: Record<string, unknown>, command: string): Co
? entry.args.filter((arg): arg is string => typeof arg === "string")
: undefined;
const options = isRecord(entry.options) ? entry.options : undefined;
const availableIn = normalizeAvailableIn(entry.available_in);
return {
command,
...(args ? { args } : {}),
...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}),
...(typeof entry.available_in === "string" ? { available_in: entry.available_in } : {}),
...(availableIn ? { available_in: availableIn } : {}),
};
}
@@ -228,7 +229,7 @@ function mapRunScript(
patch: PaseoConfigRaw,
items: ProjectConfigImportItem[],
): void {
if (script.available_in === "cloud") {
if (isCloudOnly(script.available_in)) {
items.push({
key: `scripts.${scriptId}`,
label: `Script ${scriptId}`,
@@ -395,24 +396,54 @@ function appendArgs(command: string, args: string[]): string {
if (args.length === 0) {
return command;
}
return `${command} ${args.map(shellQuote).join(" ")}`;
return `${command} ${args.map(shellQuoteArgument).join(" ")}`;
}
function safeCwdPrefix(cwd: string): string | null {
if (
/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) ||
cwd === ".." ||
cwd.startsWith("../") ||
cwd.startsWith("..\\")
) {
return null;
}
if (cwd.includes("/../") || cwd.includes("\\..\\")) {
const normalized = posix.normalize(cwd.replaceAll("\\", "/"));
if (/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || normalized === ".." || normalized.startsWith("../")) {
return null;
}
return `cd -- ${shellQuote(cwd)} && `;
}
function isCloudOnly(availableIn: string | string[] | undefined): boolean {
return (
availableIn === "cloud" ||
(Array.isArray(availableIn) &&
availableIn.length > 0 &&
availableIn.every((target) => target === "cloud"))
);
}
function normalizeAvailableIn(value: unknown): string | string[] | undefined {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === "string");
}
return undefined;
}
function shellQuoteArgument(value: string): string {
const variablePattern = /\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/g;
const parts: string[] = [];
let offset = 0;
for (const match of value.matchAll(variablePattern)) {
const index = match.index;
if (index > offset) {
parts.push(shellQuote(value.slice(offset, index)));
}
parts.push(`"${match[0]}"`);
offset = index + match[0].length;
}
if (offset < value.length) {
parts.push(shellQuote(value.slice(offset)));
}
return parts.length > 0 ? parts.join("") : shellQuote(value);
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`;
}