Deploy Hub configurations from the CLI (#2955)

* feat(cli): add Hub configuration deploy

Deploy configuration bytes directly to an explicitly targeted Hub project using only caller-supplied origin and organization API key authority.

* refactor(cli): name Hub deploy contracts

* docs(hub): complete deploy CLI guidance

* docs(hub): link deployed API references

* feat(cli): bundle referenced Hub partials

Send exact referenced prompt-partial content alongside YAML so Hub can resolve local includes while inline-only configurations keep the existing request shape.

* fix(cli): match Hub YAML size semantics

Apply the finalized Hub request limit to decoded YAML length and cover multibyte configuration payloads without changing the wire body.
This commit is contained in:
Mohamed Boudra
2026-08-07 09:40:36 +02:00
committed by GitHub
parent 3a6d6db9a5
commit 64058722e1
11 changed files with 1468 additions and 24 deletions
+158
View File
@@ -0,0 +1,158 @@
import { z } from "zod";
import type { HubDeployPartial } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
const installResponseSchema = z
.object({
projectSlug: z.string().min(1),
version: z.number().int().positive(),
versionId: z.string().uuid(),
active: z.boolean(),
})
.strict();
const issuePathSchema = z.union([z.string(), z.array(z.union([z.string(), z.number()]))]);
const fieldIssueSchema = z.object({
field: z.string().optional(),
path: issuePathSchema.optional(),
message: z.string(),
});
const problemSchema = z.object({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().int().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
errors: z
.union([z.array(fieldIssueSchema), z.record(z.string(), z.array(z.string()))])
.optional(),
issues: z.array(fieldIssueSchema).optional(),
});
export type HubInstallResult = z.infer<typeof installResponseSchema>;
interface InstallHubConfigurationInput {
origin: string;
apiKey: string;
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
export async function installHubConfiguration(
input: InstallHubConfigurationInput,
): Promise<HubInstallResult> {
let response: Response;
try {
response = await fetch(`${input.origin}/api/v1/configurations/install`, {
method: "POST",
headers: {
authorization: `Bearer ${input.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
projectSlug: input.projectSlug,
yaml: input.yaml,
...(input.partials === undefined || input.partials.length === 0
? {}
: { partials: input.partials }),
}),
});
} catch {
throw new HubDeployError(
"HUB_NETWORK_ERROR",
`Could not reach Paseo Hub at ${input.origin}. Check the Hub URL and network connection.`,
);
}
if (response.status !== 201) {
throw await deploymentFailure(response, input.apiKey);
}
try {
return installResponseSchema.parse(await response.json());
} catch {
throw new HubDeployError(
"HUB_INVALID_RESPONSE",
"Hub returned a malformed deployment response.",
);
}
}
async function deploymentFailure(response: Response, apiKey: string): Promise<HubDeployError> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/problem+json")) {
return new HubDeployError(
"HUB_REQUEST_FAILED",
`Hub deployment failed with HTTP ${response.status}.`,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
return new HubDeployError(
"HUB_INVALID_RESPONSE",
`Hub returned malformed problem details for HTTP ${response.status}.`,
);
}
const parsed = problemSchema.safeParse(body);
if (
!parsed.success ||
(parsed.data.status !== undefined && parsed.data.status !== response.status)
) {
return new HubDeployError(
"HUB_INVALID_RESPONSE",
`Hub returned nonconforming problem details for HTTP ${response.status}.`,
);
}
const title = parsed.data.title ?? `Hub deployment failed with HTTP ${response.status}`;
const detail = parsed.data.detail;
const message = detail === undefined ? title : `${title}: ${detail}`;
const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
return new HubDeployError(
code,
redactSecret(message, apiKey),
details === undefined ? undefined : redactSecret(details, apiKey),
);
}
function formatFieldIssues(
errors: z.infer<typeof problemSchema>["errors"],
issues: z.infer<typeof problemSchema>["issues"],
): string | undefined {
const fieldIssues = Array.isArray(errors) ? errors : issues;
if (fieldIssues !== undefined) {
const lines = fieldIssues.map((issue) => {
const field = issue.field ?? formatIssuePath(issue.path);
return field === undefined ? issue.message : `${field}: ${issue.message}`;
});
return lines.length === 0 ? undefined : lines.join("\n");
}
if (errors === undefined) return undefined;
const lines = Object.entries(errors).flatMap(([field, messages]) =>
messages.map((message: string) => `${field}: ${message}`),
);
return lines.length === 0 ? undefined : lines.join("\n");
}
function formatIssuePath(path: z.infer<typeof issuePathSchema> | undefined): string | undefined {
if (path === undefined || typeof path === "string") return path;
let formatted = "";
for (const segment of path) {
if (typeof segment === "number") {
formatted += `[${segment}]`;
} else {
formatted += formatted.length === 0 ? segment : `.${segment}`;
}
}
return formatted || undefined;
}
function redactSecret(value: string, secret: string): string {
return value.split(secret).join("[redacted]");
}
@@ -0,0 +1,397 @@
import { lstat, readFile } from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import { HubDeployError } from "./error.js";
const DEFAULT_CONFIGURATION_PATH = ".paseo/hub.yml";
const PROMPT_PARTIAL_ROOT = ".paseo/partials";
const PROMPT_PARTIAL_ROOT_PREFIX = `${PROMPT_PARTIAL_ROOT}/`;
const PROJECT_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const MAX_CONFIGURATION_LENGTH = 1_000_000;
const MAX_PROMPT_PARTIAL_COUNT = 100;
const MAX_PROMPT_PARTIAL_PATH_LENGTH = 512;
const MAX_PROMPT_PARTIAL_CONTENT_BYTES = 1_000_000;
const MAX_PROMPT_PARTIAL_BUNDLE_BYTES = 5_000_000;
export interface HubDeployPartial {
path: string;
content: string;
}
export interface HubDeployInput {
projectSlug: string;
yaml: string;
partials?: readonly HubDeployPartial[];
}
interface ResolveHubDeployInput {
cwd: string;
file?: string;
project?: string;
}
export async function resolveHubDeployInput(input: ResolveHubDeployInput): Promise<HubDeployInput> {
const file = input.file ?? DEFAULT_CONFIGURATION_PATH;
const projectRoot = path.resolve(input.cwd);
const configurationPath = resolveConfigurationPath(projectRoot, file);
const yaml = await readConfiguration(projectRoot, configurationPath, file);
const configuration = parseConfiguration(yaml);
const projectSlug = input.project ?? projectFromConfiguration(configuration);
if (projectSlug === undefined) {
throw new HubDeployError(
"HUB_PROJECT_REQUIRED",
"Project is required. Pass --project <slug> or add top-level project to the YAML.",
);
}
if (!PROJECT_SLUG_PATTERN.test(projectSlug)) {
throw new HubDeployError(
"HUB_INVALID_PROJECT",
"Project must be a bare slug such as my-project.",
);
}
const partials = await resolvePromptPartials(projectRoot, configuration);
return {
projectSlug,
yaml,
...(partials.length === 0 ? {} : { partials }),
};
}
function resolveConfigurationPath(cwd: string, file: string): string {
if (file.length === 0 || file.includes("\0")) {
throw invalidConfigurationPath();
}
const segments = file.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment === "..")) {
throw invalidConfigurationPath();
}
const resolved = path.resolve(cwd, file);
const relative = path.relative(cwd, resolved);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw invalidConfigurationPath();
}
return resolved;
}
async function readConfiguration(
projectRoot: string,
configurationPath: string,
displayPath: string,
): Promise<string> {
const unsafePath = () =>
new HubDeployError(
"HUB_CONFIGURATION_UNSAFE_PATH",
`Hub configuration at ${displayPath} must not use a symlink.`,
);
await rejectSymlinkComponents(projectRoot, configurationPath, unsafePath);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
if (stats.isSymbolicLink()) {
throw unsafePath();
}
if (!stats.isFile()) {
throw new HubDeployError(
"HUB_CONFIGURATION_NOT_FILE",
`Hub configuration at ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check file permissions.`,
);
}
let bytes: Buffer;
try {
bytes = await readFile(configurationPath);
} catch (error) {
throw configurationReadError(displayPath, error);
}
const yaml = bytes.toString("utf8");
if (yaml.length > MAX_CONFIGURATION_LENGTH) {
throw new HubDeployError(
"HUB_CONFIGURATION_TOO_LARGE",
`Hub configuration at ${displayPath} exceeds the ${MAX_CONFIGURATION_LENGTH}-character limit.`,
);
}
return yaml;
}
function parseConfiguration(yaml: string): Record<string, unknown> {
let configuration: unknown;
try {
configuration = YAML.parse(yaml);
} catch {
throw new HubDeployError("HUB_INVALID_CONFIGURATION", "Hub configuration is not valid YAML.");
}
if (!isRecord(configuration)) {
throw new HubDeployError(
"HUB_INVALID_CONFIGURATION",
"Hub configuration must be a YAML mapping.",
);
}
return configuration;
}
function projectFromConfiguration(configuration: Record<string, unknown>): string | undefined {
const project: unknown = configuration["project"];
if (project === undefined) return undefined;
if (typeof project !== "string") {
throw new HubDeployError(
"HUB_INVALID_PROJECT",
"Top-level project must be a bare project slug.",
);
}
return project;
}
async function resolvePromptPartials(
projectRoot: string,
configuration: Record<string, unknown>,
): Promise<HubDeployPartial[]> {
const references = collectPromptPartialReferences(configuration);
if (references.length > MAX_PROMPT_PARTIAL_COUNT) {
throw new HubDeployError(
"HUB_PARTIAL_LIMIT_EXCEEDED",
`Hub configuration references ${references.length} partials; the limit is ${MAX_PROMPT_PARTIAL_COUNT}.`,
);
}
const partials: HubDeployPartial[] = [];
let bundleBytes = 0;
for (const reference of references) {
const partialPath = path.resolve(
projectRoot,
PROMPT_PARTIAL_ROOT,
...reference.path.split("/"),
);
const content = await readPartial(projectRoot, partialPath, reference.path);
const contentBytes = Buffer.byteLength(content, "utf8");
if (contentBytes > MAX_PROMPT_PARTIAL_CONTENT_BYTES) {
throw new HubDeployError(
"HUB_PARTIAL_TOO_LARGE",
`Referenced Hub partial ${reference.path} exceeds the ${MAX_PROMPT_PARTIAL_CONTENT_BYTES}-byte limit.`,
);
}
bundleBytes += contentBytes;
if (bundleBytes > MAX_PROMPT_PARTIAL_BUNDLE_BYTES) {
throw new HubDeployError(
"HUB_PARTIAL_BUNDLE_TOO_LARGE",
`Referenced Hub partials exceed the ${MAX_PROMPT_PARTIAL_BUNDLE_BYTES}-byte combined limit.`,
);
}
partials.push({ path: reference.path, content });
}
return partials;
}
interface PromptPartialReference {
path: string;
}
function collectPromptPartialReferences(
configuration: Record<string, unknown>,
): PromptPartialReference[] {
const references: PromptPartialReference[] = [];
const seen = new Set<string>();
const triggers = configuration["triggers"];
if (!Array.isArray(triggers)) return references;
for (const trigger of triggers) {
if (!isRecord(trigger) || !Array.isArray(trigger["steps"])) continue;
for (const step of trigger["steps"]) {
if (!isRecord(step) || !Array.isArray(step["prompt"])) continue;
for (const block of step["prompt"]) {
if (!isRecord(block) || !Object.hasOwn(block, "include")) continue;
const include = block["include"];
if (typeof include !== "string") {
throw new HubDeployError(
"HUB_PARTIAL_PATH_INVALID",
"Hub partial include path must be a string.",
);
}
const normalizedPath = normalizePromptPartialPath(include);
if (seen.has(normalizedPath)) {
throw new HubDeployError(
"HUB_PARTIAL_DUPLICATE",
`Hub partial ${normalizedPath} is referenced more than once. Remove the duplicate include.`,
);
}
seen.add(normalizedPath);
references.push({ path: normalizedPath });
}
}
}
return references;
}
function normalizePromptPartialPath(value: string): string {
const decoded = decodePromptPartialPath(value);
if (decoded.length === 0) throw invalidPromptPartialPath(value);
if (/^(?:[a-zA-Z]:[\\/]|[\\/]{1,2})/u.test(decoded)) {
throw invalidPromptPartialPath(value);
}
const segments = decoded.replaceAll("\\", "/").split("/");
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
throw invalidPromptPartialPath(value);
}
const canonical = `${PROMPT_PARTIAL_ROOT}/${segments.join("/")}`;
if (!canonical.startsWith(PROMPT_PARTIAL_ROOT_PREFIX)) {
throw invalidPromptPartialPath(value);
}
if (canonical.length > MAX_PROMPT_PARTIAL_PATH_LENGTH) {
throw new HubDeployError(
"HUB_PARTIAL_PATH_TOO_LONG",
`Hub partial path ${value} exceeds the ${MAX_PROMPT_PARTIAL_PATH_LENGTH}-character limit.`,
);
}
return canonical.slice(PROMPT_PARTIAL_ROOT_PREFIX.length);
}
function decodePromptPartialPath(value: string): string {
let decoded = value;
for (let attempt = 0; attempt < 8; attempt += 1) {
let next: string;
try {
next = decodeURIComponent(decoded);
} catch {
throw invalidPromptPartialPath(value);
}
if (next === decoded) break;
decoded = next;
}
if (decoded.includes("\0") || /%[0-9a-f]{2}/iu.test(decoded)) {
throw invalidPromptPartialPath(value);
}
return decoded;
}
async function readPartial(
projectRoot: string,
partialPath: string,
displayPath: string,
): Promise<string> {
await rejectSymlinkComponents(
projectRoot,
partialPath,
() =>
new HubDeployError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not use a symlink.`,
),
);
let stats: Awaited<ReturnType<typeof lstat>>;
try {
stats = await lstat(partialPath);
} catch (error) {
if (errorCode(error) === "ENOENT") {
throw new HubDeployError(
"HUB_PARTIAL_MISSING",
`Referenced Hub partial ${displayPath} does not exist.`,
);
}
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
if (stats.isSymbolicLink()) {
throw new HubDeployError(
"HUB_PARTIAL_UNSAFE_PATH",
`Referenced Hub partial ${displayPath} must not be a symlink.`,
);
}
if (!stats.isFile()) {
throw new HubDeployError(
"HUB_PARTIAL_NOT_FILE",
`Referenced Hub partial ${displayPath} must be a regular file.`,
);
}
if (!hasReadPermission(stats.mode)) {
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
try {
return (await readFile(partialPath)).toString("utf8");
} catch {
throw new HubDeployError(
"HUB_PARTIAL_UNREADABLE",
`Could not read referenced Hub partial ${displayPath}. Check the file and permissions.`,
);
}
}
async function rejectSymlinkComponents(
root: string,
target: string,
error: () => HubDeployError,
): Promise<void> {
const relative = path.relative(root, target);
let current = root;
for (const segment of relative.split(path.sep).filter(Boolean)) {
current = path.join(current, segment);
try {
if ((await lstat(current)).isSymbolicLink()) throw error();
} catch (failure) {
if (failure instanceof HubDeployError) throw failure;
if (errorCode(failure) === "ENOENT") return;
throw error();
}
}
}
function configurationReadError(displayPath: string, error: unknown): HubDeployError {
if (errorCode(error) === "ENOENT") {
return new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Pass an existing YAML file.`,
);
}
return new HubDeployError(
"HUB_CONFIGURATION_UNREADABLE",
`Could not read Hub configuration at ${displayPath}. Check the file and permissions.`,
);
}
function invalidConfigurationPath(): HubDeployError {
return new HubDeployError(
"HUB_CONFIGURATION_PATH_INVALID",
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
);
}
function invalidPromptPartialPath(value: string): HubDeployError {
return new HubDeployError(
"HUB_PARTIAL_PATH_INVALID",
`Hub partial path must be a safe relative path under .paseo/partials/: ${value}`,
);
}
function hasReadPermission(mode: number): boolean {
return (mode & 0o444) !== 0;
}
function errorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null) return undefined;
const code = Reflect.get(error, "code");
return typeof code === "string" ? code : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,707 @@
import { createServer } from "node:http";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { renderError, toCommandError } from "../../output/index.js";
import { createHubCommand } from "./index.js";
import { runHubDeploy } from "./deploy.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("hub deploy", () => {
it("deploys the default file with its project metadata and exact YAML through env credentials", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const yaml = "project: studio-api\r\nenvironments: []\r\ntriggers: []\r\n";
await mkdir(configurationDirectory);
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
const hub = await startHub();
try {
const result = await runHubDeploy(
{},
{
cwd,
env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "test-operator-secret" },
},
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer test-operator-secret",
body: JSON.stringify({ projectSlug: "studio-api", yaml }),
});
expect(result.data).toEqual({
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
});
} finally {
await hub.close();
}
});
it("uses an explicit file and lets -p override project metadata without rewriting YAML", async () => {
const cwd = await temporaryDirectory();
const yaml = "project: file-project\nenvironments: []\ntriggers: []\n";
await writeFile(path.join(cwd, "production.yml"), yaml);
const hub = await startHub();
try {
await runHubDeploy(
{
file: "production.yml",
project: "flag-project",
hub: hub.origin,
apiKey: "flag-secret",
},
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
authorization: "Bearer flag-secret",
body: JSON.stringify({ projectSlug: "flag-project", yaml }),
});
} finally {
await hub.close();
}
});
it("deploys mixed inline and partial prompts with only the referenced partial files", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, ".paseo");
const partialDirectory = path.join(configurationDirectory, "partials", "docs");
const yaml = [
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
" - text: Inline request",
" - include: docs/safety.md",
" - include: docs/release.md",
"unknownNestedInclude: ignored.md",
"",
].join("\n");
const safety = "Follow the safety checklist.\n{ include: nested.md }";
const release = "Use the release checklist.";
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "hub.yml"), yaml);
await writeFile(path.join(partialDirectory, "safety.md"), safety);
await writeFile(path.join(partialDirectory, "release.md"), release);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "bundle-secret" } },
);
expect(await hub.received).toEqual({
method: "POST",
url: "/api/v1/configurations/install",
authorization: "Bearer bundle-secret",
body: JSON.stringify({
projectSlug: "studio-api",
yaml,
partials: [
{ path: "docs/safety.md", content: safety },
{ path: "docs/release.md", content: release },
],
}),
});
} finally {
await hub.close();
}
});
it("anchors explicit configuration files and their partial bundle at the current project root", async () => {
const cwd = await temporaryDirectory();
const configurationDirectory = path.join(cwd, "configs");
const partialDirectory = path.join(cwd, ".paseo", "partials");
const yaml =
"project: explicit-project\ntriggers:\n - steps:\n - prompt:\n - include: instructions.md\n";
const partial = "Explicit project-root partial";
await mkdir(configurationDirectory, { recursive: true });
await mkdir(partialDirectory, { recursive: true });
await writeFile(path.join(configurationDirectory, "production.yml"), yaml);
await writeFile(path.join(partialDirectory, "instructions.md"), partial);
const hub = await startHub();
try {
await runHubDeploy(
{ file: "configs/production.yml", hub: hub.origin, apiKey: "explicit-secret" },
{ cwd, env: {} },
);
expect(await hub.received).toMatchObject({
body: JSON.stringify({
projectSlug: "explicit-project",
yaml,
partials: [{ path: "instructions.md", content: partial }],
}),
});
} finally {
await hub.close();
}
});
it("does not search parent directories or alternate default filenames", async () => {
const parent = await temporaryDirectory();
const cwd = path.join(parent, "child");
await mkdir(path.join(parent, ".paseo"));
await mkdir(path.join(cwd, ".paseo"), { recursive: true });
await writeFile(path.join(parent, ".paseo", "hub.yml"), "project: parent-project\n");
await writeFile(path.join(cwd, ".paseo", "hub.yaml"), "project: alternate-project\n");
await expect(
runHubDeploy({ hub: "https://hub.example.com", apiKey: "unused-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_UNREADABLE",
message: "Could not read Hub configuration at .paseo/hub.yml. Pass an existing YAML file.",
});
});
it("requires an explicit project when the YAML omits project metadata", async () => {
const cwd = await temporaryDirectory();
await writeFile(path.join(cwd, "hub.yml"), "environments: []\ntriggers: []\n");
await expect(
runHubDeploy(
{ file: "hub.yml", hub: "https://hub.example.com", apiKey: "unused-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
message: "Project is required. Pass --project <slug> or add top-level project to the YAML.",
});
});
it("fails locally for a missing partial without sending a request", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: missing.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "missing-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_MISSING",
message: "Referenced Hub partial missing.md does not exist.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it.each([
"../secret.md",
"%2e%2e/secret.md",
"/etc/passwd",
"\\\\server\\share\\secret.md",
"C:\\secret.md",
])("fails locally for an unsafe partial path %s without sending a request", async (include) => {
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unsafe-path-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({ code: "HUB_PARTIAL_PATH_INVALID" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial is a directory", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs\n",
);
await mkdir(path.join(cwd, ".paseo", "partials", "docs"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "directory-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_NOT_FILE",
message: "Referenced Hub partial docs must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a referenced partial cannot be read", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: unreadable.md\n",
);
const partial = path.join(cwd, ".paseo", "partials", "unreadable.md");
await mkdir(path.dirname(partial), { recursive: true });
await writeFile(partial, "secret instructions");
await chmod(partial, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "unreadable-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_UNREADABLE",
message:
"Could not read referenced Hub partial unreadable.md. Check the file and permissions.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for duplicate normalized partial references", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: docs/safety.md\n - include: docs\\safety.md\n",
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "duplicate-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_DUPLICATE",
message:
"Hub partial docs/safety.md is referenced more than once. Remove the duplicate include.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration exceeds the YAML size limit", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await writeFile(
path.join(cwd, ".paseo", "hub.yml"),
`project: studio-api\n${"#".repeat(1_000_000)}`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-yaml-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("uses the Hub YAML string limit rather than its UTF-8 byte length", async () => {
const cwd = await temporaryDirectory();
const yaml = `project: studio-api\n# ${"😀".repeat(400_000)}\n`;
await mkdir(path.join(cwd, ".paseo"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
expect(yaml.length).toBeLessThan(1_000_000);
expect(Buffer.byteLength(yaml, "utf8")).toBeGreaterThan(1_000_000);
const hub = await startHub();
try {
await runHubDeploy(
{},
{ cwd, env: { PASEO_HUB_URL: hub.origin, PASEO_HUB_API_KEY: "unicode-yaml-secret" } },
);
expect(JSON.parse((await hub.received).body)).toEqual({ projectSlug: "studio-api", yaml });
} finally {
await hub.close();
}
});
it("fails locally when a partial exceeds its content size limit", async () => {
const cwd = await projectFile(
"project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: large.md\n",
);
await writeFile(path.join(cwd, ".paseo", "partials", "large.md"), "x".repeat(1_000_001));
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-partial-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the referenced partial bundle exceeds its combined size limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 6 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
await Promise.all(
Array.from({ length: 6 }, (_, index) =>
writeFile(path.join(cwd, ".paseo", "partials", `part-${index}.md`), "x".repeat(900_000)),
),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "large-bundle-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_BUNDLE_TOO_LARGE",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally for an explicit configuration path outside the project root", async () => {
const cwd = await temporaryDirectory();
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ file: "../hub.yml", hub: hub.origin, apiKey: "traversal-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_PATH_INVALID",
message:
"Hub configuration path must stay within the current project root; parent-directory paths are not allowed.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration is a directory", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo", "hub.yml"), { recursive: true });
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-directory-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({
code: "HUB_CONFIGURATION_NOT_FILE",
message: "Hub configuration at .paseo/hub.yml must be a regular file.",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the configuration cannot be read", async () => {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
const configuration = path.join(cwd, ".paseo", "hub.yml");
await writeFile(configuration, "project: studio-api\n");
await chmod(configuration, 0o000);
const hub = await startHub();
try {
await expect(
runHubDeploy(
{ hub: hub.origin, apiKey: "configuration-unreadable-secret" },
{ cwd, env: {} },
),
).rejects.toMatchObject({ code: "HUB_CONFIGURATION_UNREADABLE" });
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when the partial count exceeds the bundle limit", async () => {
const cwd = await projectFile(
[
"project: studio-api",
"triggers:",
" - steps:",
" - prompt:",
...Array.from({ length: 101 }, (_, index) => ` - include: part-${index}.md`),
"",
].join("\n"),
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-count-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_LIMIT_EXCEEDED",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("fails locally when a canonical partial path exceeds its length limit", async () => {
const include = `${"a".repeat(497)}.md`;
const cwd = await projectFile(
`project: studio-api\ntriggers:\n - steps:\n - prompt:\n - include: ${JSON.stringify(include)}\n`,
);
const hub = await startHub();
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "partial-path-length-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_PARTIAL_PATH_TOO_LONG",
});
expect(hub.requestCount()).toBe(0);
} finally {
await hub.close();
}
});
it("requires Hub origin and API key with actionable flag and env guidance", async () => {
await expect(runHubDeploy({}, { cwd: "/unused", env: {} })).rejects.toMatchObject({
message: "Hub origin is required. Pass --hub <origin> or set PASEO_HUB_URL.",
});
await expect(
runHubDeploy({ hub: "https://hub.example.com" }, { cwd: "/unused", env: {} }),
).rejects.toMatchObject({
message: "Hub API key is required. Pass --api-key <secret> or set PASEO_HUB_API_KEY.",
});
});
it.each([
"ftp://hub.example.com",
"https://user:password@hub.example.com",
"https://hub.example.com/path",
"https://hub.example.com?debug=true",
"https://hub.example.com#debug",
])("rejects a non-origin Hub URL: %s", async (hub) => {
await expect(
runHubDeploy({ hub, apiKey: "unused-secret" }, { cwd: "/unused", env: {} }),
).rejects.toMatchObject({
message: "Hub URL must be an HTTP or HTTPS origin without credentials, path, query, or hash.",
});
});
it("shows RFC 9457 detail and field issues without raw server extensions", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 422,
contentType: "application/problem+json",
body: {
type: "https://hub.example.com/problems/configuration-invalid",
title: "Configuration validation failed",
status: 422,
detail: "Fix the invalid configuration fields.",
errors: [{ field: "triggers[0].steps[0].agent.provider", message: "Unknown provider" }],
internalTrace: "do-not-expose-this-trace",
},
});
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "validation-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_VALIDATION_FAILED",
message: "Configuration validation failed: Fix the invalid configuration fields.",
details: "triggers[0].steps[0].agent.provider: Unknown provider",
});
} finally {
await hub.close();
}
});
it("reports malformed successful responses without exposing their body", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({ status: 201, body: { projectSlug: "studio-api", version: "7" } });
try {
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "response-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_INVALID_RESPONSE",
message: "Hub returned a malformed deployment response.",
});
} finally {
await hub.close();
}
});
it("reports network failures honestly", async () => {
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub();
await hub.close();
await expect(
runHubDeploy({ hub: hub.origin, apiKey: "network-secret" }, { cwd, env: {} }),
).rejects.toMatchObject({
code: "HUB_NETWORK_ERROR",
message: `Could not reach Paseo Hub at ${hub.origin}. Check the Hub URL and network connection.`,
});
});
it("redacts the operator key from problem detail, field issues, and serialized errors", async () => {
const secret = "operator-secret-must-not-leak";
const cwd = await projectFile("project: studio-api\n");
const hub = await startHub({
status: 401,
contentType: "application/problem+json",
body: {
title: `Rejected key ${secret}`,
status: 401,
detail: `Authorization Bearer ${secret} is invalid`,
errors: [{ field: "apiKey", message: secret }],
},
});
try {
const error = await runHubDeploy({ hub: hub.origin, apiKey: secret }, { cwd, env: {} }).catch(
(failure: unknown) => failure,
);
const jsonOutput = renderError(toCommandError(error), { format: "json" });
expect(jsonOutput).not.toContain(secret);
expect(error).toMatchObject({
message: "Rejected key [redacted]: Authorization Bearer [redacted] is invalid",
details: "apiKey: [redacted]",
});
} finally {
await hub.close();
}
});
it("wires deploy with its exact default file and explicit target options", () => {
const deploy = createHubCommand().commands.find((command) => command.name() === "deploy");
const help = deploy?.helpInformation();
expect(deploy?.registeredArguments[0]?.name()).toBe("file");
expect(deploy?.registeredArguments[0]?.defaultValue).toBe(".paseo/hub.yml");
expect(help).toContain("-p, --project <slug>");
expect(help).toContain("--hub <origin>");
expect(help).toContain("--api-key <secret>");
expect(help).not.toContain("organization");
});
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-deploy-"));
temporaryDirectories.push(directory);
return directory;
}
async function projectFile(yaml: string): Promise<string> {
const cwd = await temporaryDirectory();
await mkdir(path.join(cwd, ".paseo"));
await mkdir(path.join(cwd, ".paseo", "partials"));
await writeFile(path.join(cwd, ".paseo", "hub.yml"), yaml);
return cwd;
}
interface HubResponse {
status: number;
contentType?: string;
body: unknown;
}
interface ReceivedRequest {
method: string | undefined;
url: string | undefined;
authorization: string | undefined;
body: string;
}
interface TestHub {
origin: string;
received: Promise<ReceivedRequest>;
requestCount(): number;
close(): Promise<void>;
}
async function startHub(
configuredResponse: HubResponse = {
status: 201,
body: {
projectSlug: "studio-api",
version: 7,
versionId: "2de5b143-1c88-42db-8a8d-71ca2af97830",
active: true,
},
},
): Promise<TestHub> {
let requestCount = 0;
let resolveRequest: (request: ReceivedRequest) => void;
const received = new Promise<ReceivedRequest>((resolve) => {
resolveRequest = resolve;
});
const server = createServer((request, response) => {
requestCount += 1;
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
resolveRequest({
method: request.method,
url: request.url,
authorization: request.headers.authorization,
body,
});
response.writeHead(configuredResponse.status, {
"content-type": configuredResponse.contentType ?? "application/json",
});
response.end(JSON.stringify(configuredResponse.body));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Test server did not bind");
return {
origin: `http://127.0.0.1:${address.port}`,
received,
requestCount: () => requestCount,
close: () => closeServer(server),
};
}
function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
return new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import type { Command } from "commander";
import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
import { installHubConfiguration, type HubInstallResult } from "./client.js";
import { resolveHubDeployInput } from "./deploy-input.js";
import { HubDeployError } from "./error.js";
export interface HubDeployOptions {
file?: string;
project?: string;
hub?: string;
apiKey?: string;
}
interface HubDeployEnvironment {
cwd: string;
env: Readonly<Record<string, string | undefined>>;
}
const resultSchema: OutputSchema<HubInstallResult> = {
idField: "versionId",
columns: [
{ header: "PROJECT", field: "projectSlug" },
{ header: "VERSION", field: "version" },
{ header: "VERSION ID", field: "versionId" },
{ header: "ACTIVE", field: "active" },
],
};
export async function runHubDeploy(
options: HubDeployOptions,
environment: HubDeployEnvironment = { cwd: process.cwd(), env: process.env },
): Promise<SingleResult<HubInstallResult>> {
const origin = options.hub ?? environment.env.PASEO_HUB_URL;
const apiKey = options.apiKey ?? environment.env.PASEO_HUB_API_KEY;
if (!origin) {
throw new HubDeployError(
"HUB_ORIGIN_REQUIRED",
"Hub origin is required. Pass --hub <origin> or set PASEO_HUB_URL.",
);
}
if (!apiKey) {
throw new HubDeployError(
"HUB_API_KEY_REQUIRED",
"Hub API key is required. Pass --api-key <secret> or set PASEO_HUB_API_KEY.",
);
}
const normalizedOrigin = parseHubOrigin(origin);
const deployInput = await resolveHubDeployInput({
cwd: environment.cwd,
...(options.file === undefined ? {} : { file: options.file }),
...(options.project === undefined ? {} : { project: options.project }),
});
const deployed = await installHubConfiguration({
origin: normalizedOrigin,
apiKey,
...deployInput,
});
return { type: "single", data: deployed, schema: resultSchema };
}
export function addHubDeployCommand(hub: Command): void {
addJsonOption(
hub
.command("deploy")
.description("Install and activate a Hub configuration")
.argument("[file]", "Hub configuration YAML", ".paseo/hub.yml")
.option("-p, --project <slug>", "Target project slug")
.option("--hub <origin>", "Paseo Hub origin")
.option("--api-key <secret>", "Organization API key"),
).action(
withOutput(async (...args) => {
const file = args[0] as string;
const options = args.at(-2) as HubDeployOptions;
return runHubDeploy({ ...options, file });
}),
);
}
function parseHubOrigin(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw invalidHubOrigin();
}
if (
!["http:", "https:"].includes(url.protocol) ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw invalidHubOrigin();
}
return url.origin;
}
function invalidHubOrigin(): HubDeployError {
return new HubDeployError(
"HUB_INVALID_ORIGIN",
"Hub URL must be an HTTP or HTTPS origin without credentials, path, query, or hash.",
);
}
+10
View File
@@ -0,0 +1,10 @@
export class HubDeployError extends Error {
constructor(
readonly code: string,
message: string,
readonly details?: string,
) {
super(message);
this.name = "HubDeployError";
}
}
+3 -1
View File
@@ -4,6 +4,7 @@ import { withOutput, type ListResult, type OutputSchema } from "../../output/ind
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { connectToDaemon } from "../../utils/client.js";
import { createDeviceAuthorizationWorkflow } from "./device-authorization.js";
import { addHubDeployCommand } from "./deploy.js";
interface HubCommandClient {
connectHub(url: string, token: string): Promise<{ status: HubStatus }>;
@@ -100,7 +101,7 @@ async function withClient<T>(
export function createHubCommand(
environment: HubCommandEnvironment = productionEnvironment,
): Command {
const hub = new Command("hub").description("Manage this daemon's Paseo Hub relationship");
const hub = new Command("hub").description("Manage Paseo Hub");
addJsonAndDaemonHostOptions(
hub.command("connect").argument("<url>").option("--token <token>"),
).action(
@@ -144,6 +145,7 @@ export function createHubCommand(
});
}),
);
addHubDeployCommand(hub);
return hub;
}