mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(settings): harden conductor imports
This commit is contained in:
@@ -2084,6 +2084,7 @@ export const ar: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2098,6 +2098,7 @@ export const en = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2136,6 +2136,7 @@ export const es: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2139,6 +2139,7 @@ export const fr: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2109,6 +2109,7 @@ export const ja: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2122,6 +2122,7 @@ export const ptBR: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2127,6 +2127,7 @@ export const ru: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -2060,6 +2060,7 @@ export const zhCN: TranslationResources = {
|
||||
success: "{{source}} settings imported",
|
||||
errorTitle: "Couldn't import settings",
|
||||
errors: {
|
||||
capabilityMissing: "Update the host to use this.",
|
||||
notFound: "No {{source}} project config was found.",
|
||||
invalid: "{{path}} couldn't be parsed.",
|
||||
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ProjectConfigImportIntent } from "./route";
|
||||
|
||||
export type ProjectConfigImportVisibleError =
|
||||
| ProjectConfigRpcError
|
||||
| { code: "capability_missing" }
|
||||
| { code: "transport"; message: string };
|
||||
|
||||
export type ProjectConfigImportState =
|
||||
@@ -58,12 +59,7 @@ export function ProjectConfigImportSheet({
|
||||
);
|
||||
const preview = state.preview;
|
||||
const visibleError = state.status === "error" ? state.error : null;
|
||||
const needsRefresh =
|
||||
visibleError?.code === "stale_source_config" ||
|
||||
visibleError?.code === "stale_project_config" ||
|
||||
visibleError?.code === "nothing_to_import";
|
||||
const retryAction = state.status === "error" ? state.retryAction : "refresh";
|
||||
const handleRetry = retryAction === "apply" ? onApply : onRefresh;
|
||||
const canImport =
|
||||
state.status === "ready" &&
|
||||
state.preview.status === "available" &&
|
||||
@@ -93,25 +89,12 @@ export function ProjectConfigImportSheet({
|
||||
title={t("settings.project.import.errorTitle")}
|
||||
description={projectConfigImportErrorText(visibleError, t, sourceName)}
|
||||
>
|
||||
{needsRefresh ? (
|
||||
<Button
|
||||
testID="project-config-import-refresh"
|
||||
onPress={onRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("settings.project.import.refreshPreview")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
testID="project-config-import-retry"
|
||||
onPress={handleRetry}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("settings.project.actions.tryAgain")}
|
||||
</Button>
|
||||
)}
|
||||
<ImportErrorRetryButton
|
||||
error={visibleError}
|
||||
retryAction={retryAction}
|
||||
onRefresh={onRefresh}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<Button
|
||||
testID="project-config-import-cancel-error"
|
||||
onPress={onClose}
|
||||
@@ -147,6 +130,44 @@ export function ProjectConfigImportSheet({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportErrorRetryButton(input: {
|
||||
error: ProjectConfigImportVisibleError;
|
||||
retryAction: "refresh" | "apply";
|
||||
onRefresh: () => void;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (input.error.code === "capability_missing") {
|
||||
return null;
|
||||
}
|
||||
const needsRefresh =
|
||||
input.error.code === "stale_source_config" ||
|
||||
input.error.code === "stale_project_config" ||
|
||||
input.error.code === "nothing_to_import";
|
||||
if (needsRefresh) {
|
||||
return (
|
||||
<Button
|
||||
testID="project-config-import-refresh"
|
||||
onPress={input.onRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("settings.project.import.refreshPreview")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
testID="project-config-import-retry"
|
||||
onPress={input.retryAction === "apply" ? input.onApply : input.onRefresh}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{t("settings.project.actions.tryAgain")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBody({ preview }: { preview: ProjectConfigImportPreview }) {
|
||||
const { t } = useTranslation();
|
||||
const sections = [
|
||||
@@ -214,6 +235,8 @@ function projectConfigImportErrorText(
|
||||
switch (error.code) {
|
||||
case "transport":
|
||||
return error.message;
|
||||
case "capability_missing":
|
||||
return t("settings.project.import.errors.capabilityMissing");
|
||||
case "source_config_not_found":
|
||||
return t("settings.project.import.errors.notFound", { source: sourceName });
|
||||
case "invalid_source_config":
|
||||
|
||||
@@ -50,13 +50,18 @@ export function useProjectConfigImportModel(input: {
|
||||
const [applyError, setApplyError] = useState<ProjectConfigImportVisibleError | null>(null);
|
||||
const [retryAction, setRetryAction] = useState<ProjectConfigImportRetryAction>("apply");
|
||||
const activeSource = intent ? registry.get(intent.source) : null;
|
||||
const routeIntentCapabilityMissing = isRouteIntentCapabilityMissing({
|
||||
intent,
|
||||
routeIntent: input.routeIntent,
|
||||
sources,
|
||||
});
|
||||
const activePreview = useProjectConfigImportPreviewQuery({
|
||||
client: input.client,
|
||||
serverId: input.serverId,
|
||||
repoRoot: input.repoRoot,
|
||||
source: intent?.source ?? null,
|
||||
protocolSource: intent?.protocolSource ?? null,
|
||||
enabled: Boolean(intent && input.projectConfigLoaded),
|
||||
enabled: Boolean(intent && input.projectConfigLoaded && !routeIntentCapabilityMissing),
|
||||
});
|
||||
const preview = activePreview.data?.ok ? activePreview.data : null;
|
||||
const queryClient = useQueryClient();
|
||||
@@ -159,13 +164,14 @@ export function useProjectConfigImportModel(input: {
|
||||
if (!intent) {
|
||||
return null;
|
||||
}
|
||||
const error =
|
||||
applyError ??
|
||||
normalizeProjectConfigImportError(
|
||||
activePreview.data && !activePreview.data.ok
|
||||
? activePreview.data.error
|
||||
: activePreview.error,
|
||||
);
|
||||
const error = routeIntentCapabilityMissing
|
||||
? ({ code: "capability_missing" } as const)
|
||||
: (applyError ??
|
||||
normalizeProjectConfigImportError(
|
||||
activePreview.data && !activePreview.data.ok
|
||||
? activePreview.data.error
|
||||
: activePreview.error,
|
||||
));
|
||||
if (error) {
|
||||
return {
|
||||
status: "error",
|
||||
@@ -190,6 +196,7 @@ export function useProjectConfigImportModel(input: {
|
||||
intent,
|
||||
preview,
|
||||
retryAction,
|
||||
routeIntentCapabilityMissing,
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -264,6 +271,20 @@ function projectConfigImportAvailabilityStatus(count: number): "none" | "one" |
|
||||
return count === 1 ? "one" : "many";
|
||||
}
|
||||
|
||||
function isRouteIntentCapabilityMissing(input: {
|
||||
intent: ProjectConfigImportIntent | null;
|
||||
routeIntent: ProjectConfigImportIntent | null;
|
||||
sources: ProjectConfigImportSourceRegistration[];
|
||||
}): boolean {
|
||||
if (!input.intent || input.routeIntent?.intentId !== input.intent.intentId) {
|
||||
return false;
|
||||
}
|
||||
const intentSourceKey = stableProjectConfigImportSourceKey(input.intent.source);
|
||||
return !input.sources.some(
|
||||
(source) => stableProjectConfigImportSourceKey(source.source) === intentSourceKey,
|
||||
);
|
||||
}
|
||||
|
||||
function useAdvertisedProjectConfigImportSources(
|
||||
serverId: string | null | undefined,
|
||||
registry: ProjectConfigImportSourceRegistry,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, "'\\''")}'`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user