diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e17a76e37..1ae214cac 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 421a6dc34..e4b1a4922 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index c32e5d6b3..e2e7241c1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 66ced4d41..40bc64d0f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 3bdf0585b..9c61c8ca4 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 698eedf9c..238ba05f5 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 7e910b8f2..4d9598187 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 0efdf5c32..5f22bdc97 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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.", diff --git a/packages/app/src/project-config-import/project-config-import-sheet.tsx b/packages/app/src/project-config-import/project-config-import-sheet.tsx index e50f0a0f5..25c59e95e 100644 --- a/packages/app/src/project-config-import/project-config-import-sheet.tsx +++ b/packages/app/src/project-config-import/project-config-import-sheet.tsx @@ -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 ? ( - - ) : ( - - )} + + ); + } + return ( + + ); +} + 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": diff --git a/packages/app/src/project-config-import/use-project-config-import-model.ts b/packages/app/src/project-config-import/use-project-config-import-model.ts index a9ed83dda..1892547d1 100644 --- a/packages/app/src/project-config-import/use-project-config-import-model.ts +++ b/packages/app/src/project-config-import/use-project-config-import-model.ts @@ -50,13 +50,18 @@ export function useProjectConfigImportModel(input: { const [applyError, setApplyError] = useState(null); const [retryAction, setRetryAction] = useState("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, diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts index c9e6b7c48..53c1f0cf4 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts @@ -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"); diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts index 871a4fd42..1f11bdfd1 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts @@ -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, 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, "'\\''")}'`; }