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: 8 additions & 1 deletion cli/src/commands/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { readFileSync } from "fs";
import { join } from "path";

import { ANSI, renderChatApp } from "../components/DefaultMainScreen";
import { findAssistantByName, loadLatestAssistant } from "../lib/assistant-config";
import { GATEWAY_PORT, type Species } from "../lib/constants";

const ANSI = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
};

const FALLBACK_RUNTIME_URL = `http://127.0.0.1:${GATEWAY_PORT}`;
const FALLBACK_ASSISTANT_ID = "default";

Expand Down Expand Up @@ -108,6 +113,8 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
export async function client(): Promise<void> {
const { runtimeUrl, assistantId, species, bearerToken, project, zone } = parseArgs();

const { renderChatApp } = await import("../components/DefaultMainScreen");

process.stdout.write("\x1b[2J\x1b[H");

const app = renderChatApp(
Expand Down
81 changes: 48 additions & 33 deletions cli/src/commands/hatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ export type { PollResult, WatchHatchingResult } from "../lib/gcp";

const INSTALL_SCRIPT_REMOTE_PATH = "/tmp/vellum-install.sh";

async function resolveInstallScriptPath(): Promise<string | null> {
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
if (existsSync(sourcePath)) {
return sourcePath;
}
console.warn("⚠️ Install script not found at", sourcePath, "(expected in compiled binary)");
return null;
// Embedded install script — bun --compile doesn't bundle non-JS assets,
// so we inline it to ensure it's available in the compiled binary.
import INSTALL_SCRIPT_CONTENT from "../adapters/install.sh" with { type: "text" };

function resolveInstallScriptPath(): string {
const tmpPath = join(tmpdir(), `vellum-install-${process.pid}.sh`);
writeFileSync(tmpPath, INSTALL_SCRIPT_CONTENT, { mode: 0o755 });
return tmpPath;
}
const HATCH_TIMEOUT_MS: Record<Species, number> = {
vellum: 2 * 60 * 1000,
Expand All @@ -51,8 +52,8 @@ function desktopLog(msg: string): void {
process.stdout.write(msg + "\n");
}

function buildTimestampRedirect(): string {
return `exec > >(while IFS= read -r line; do printf '[%s] %s\\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$line"; done > /var/log/startup-script.log) 2>&1`;
function buildTimestampRedirect(logPath: string): string {
return `exec > >(while IFS= read -r line; do printf '[%s] %s\\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$line"; done > ${logPath}) 2>&1`;
}

function buildUserSetup(sshUser: string): string {
Expand Down Expand Up @@ -82,7 +83,9 @@ export async function buildStartupScript(
cloud: RemoteHost,
): Promise<string> {
const platformUrl = process.env.VELLUM_ASSISTANT_PLATFORM_URL ?? "https://assistant.vellum.ai";
const timestampRedirect = buildTimestampRedirect();
const logPath = cloud === "custom" ? "/tmp/vellum-startup.log" : "/var/log/startup-script.log";
const errorPath = cloud === "custom" ? "/tmp/vellum-startup-error" : "/var/log/startup-error";
const timestampRedirect = buildTimestampRedirect(logPath);
const userSetup = buildUserSetup(sshUser);
const ownershipFixup = buildOwnershipFixup();

Expand All @@ -102,7 +105,7 @@ set -e

${timestampRedirect}

trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > /var/log/startup-error; echo "Last 20 log lines:" >> /var/log/startup-error; tail -20 /var/log/startup-script.log >> /var/log/startup-error 2>/dev/null || true; fi' EXIT
trap 'EXIT_CODE=\$?; if [ \$EXIT_CODE -ne 0 ]; then echo "Startup script failed with exit code \$EXIT_CODE at line \$LINENO" > ${errorPath}; echo "Last 20 log lines:" >> ${errorPath}; tail -20 ${logPath} >> ${errorPath} 2>/dev/null || true; fi' EXIT
${userSetup}
ANTHROPIC_API_KEY=${anthropicApiKey}
GATEWAY_RUNTIME_PROXY_ENABLED=true
Expand Down Expand Up @@ -401,13 +404,31 @@ function watchHatchingDesktop(
}

function buildSshArgs(host: string): string[] {
return [
host,
const args: string[] = [host];
const keyPath = process.env.VELLUM_SSH_KEY_PATH;
if (keyPath) {
args.push("-i", keyPath);
}
args.push(
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=10",
"-o", "LogLevel=ERROR",
];
);
return args;
}

function buildScpArgs(keyPath?: string): string[] {
const args: string[] = [];
if (keyPath) {
args.push("-i", keyPath);
}
args.push(
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
);
return args;
}

function extractHostname(host: string): string {
Expand Down Expand Up @@ -454,27 +475,22 @@ async function hatchCustom(
const startupScriptPath = join(tmpdir(), `${instanceName}-startup.sh`);
writeFileSync(startupScriptPath, startupScript);

const sshKeyPath = process.env.VELLUM_SSH_KEY_PATH;

const installScriptPath = resolveInstallScriptPath();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Create temp install script inside cleanup guard

resolveInstallScriptPath() now writes a temporary file and can throw (for example when /tmp is full or not writable), but it is called before entering the try/finally cleanup block. In that failure path, startupScriptPath is never deleted, leaving a startup script in /tmp that includes sensitive values like ANTHROPIC_API_KEY and RUNTIME_PROXY_BEARER_TOKEN; moving temp install-script creation inside the guarded block (or adding an outer finally) avoids this leak.

Useful? React with 👍 / 👎.


try {
const installScriptPath = await resolveInstallScriptPath();
if (installScriptPath) {
console.log("📋 Uploading install script to instance...");
await exec("scp", [
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
installScriptPath,
`${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
]);
} else {
console.warn("⚠️ Skipping install script upload (not available in compiled binary)");
}
console.log("📋 Uploading install script to instance...");
await exec("scp", [
...buildScpArgs(sshKeyPath),
installScriptPath,
`${host}:${INSTALL_SCRIPT_REMOTE_PATH}`,
]);

console.log("📋 Uploading startup script to instance...");
const remoteStartupPath = `/tmp/${instanceName}-startup.sh`;
await exec("scp", [
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
...buildScpArgs(sshKeyPath),
startupScriptPath,
`${host}:${remoteStartupPath}`,
]);
Expand All @@ -485,9 +501,8 @@ async function hatchCustom(
`chmod +x ${remoteStartupPath} ${INSTALL_SCRIPT_REMOTE_PATH} && bash ${remoteStartupPath}`,
]);
} finally {
try {
unlinkSync(startupScriptPath);
} catch {}
try { unlinkSync(startupScriptPath); } catch {}
try { unlinkSync(installScriptPath); } catch {}
}

const runtimeUrl = `http://${hostname}:${GATEWAY_PORT}`;
Expand Down
20 changes: 8 additions & 12 deletions cli/src/lib/gcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,13 +370,12 @@ const DESIRED_FIREWALL_RULES: FirewallRuleSpec[] = [
},
];

async function resolveInstallScriptPath(): Promise<string | null> {
const sourcePath = join(import.meta.dir, "..", "adapters", "install.sh");
if (existsSync(sourcePath)) {
return sourcePath;
}
console.warn("\u26a0\ufe0f Install script not found at", sourcePath, "(expected in compiled binary)");
return null;
import INSTALL_SCRIPT_CONTENT from "../adapters/install.sh" with { type: "text" };

function resolveInstallScriptPath(): string {
const tmpPath = join(tmpdir(), `vellum-install-${process.pid}.sh`);
writeFileSync(tmpPath, INSTALL_SCRIPT_CONTENT, { mode: 0o755 });
return tmpPath;
}

async function pollInstance(
Expand Down Expand Up @@ -459,11 +458,7 @@ async function recoverFromCurlFailure(
sshUser: string,
account?: string,
): Promise<void> {
const installScriptPath = await resolveInstallScriptPath();
if (!installScriptPath) {
console.warn("\u26a0\ufe0f Skipping install script upload (not available in compiled binary)");
return;
}
const installScriptPath = resolveInstallScriptPath();

const scpArgs = [
"compute",
Expand All @@ -488,6 +483,7 @@ async function recoverFromCurlFailure(
if (account) sshArgs.push(`--account=${account}`);
console.log("\ud83d\udd27 Running install script on instance...");
await exec("gcloud", sshArgs);
try { unlinkSync(installScriptPath); } catch {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Temp file leaked when recoverFromCurlFailure throws during SCP/SSH exec

The new resolveInstallScriptPath() writes the embedded install script to a temp file (/tmp/vellum-install-<pid>.sh). In recoverFromCurlFailure, the cleanup unlinkSync at line 486 is placed after both exec calls but is not in a finally block. If exec("gcloud", scpArgs) at line 473 or exec("gcloud", sshArgs) at line 485 throws, the temp file is never deleted.

Root Cause

Previously, resolveInstallScriptPath returned a path to an existing file on the filesystem (no temp file was created), so there was nothing to clean up. Now that the function writes embedded content to a temp file (cli/src/lib/gcp.ts:375-378), all callers must ensure cleanup even on error paths.

The hatchCustom function in cli/src/commands/hatch.ts:503-505 correctly uses a finally block for cleanup. But recoverFromCurlFailure in gcp.ts does not:

await exec("gcloud", scpArgs);      // can throw
await exec("gcloud", sshArgs);      // can throw
try { unlinkSync(installScriptPath); } catch {}  // skipped if above throws

Impact: A temp file is left behind in /tmp on every failed recovery attempt. Low severity since it's in the OS temp directory and only on error paths.

Prompt for agents
In cli/src/lib/gcp.ts, in the recoverFromCurlFailure function (lines 454-487), wrap the SCP and SSH exec calls (lines 473 and 485) in a try/finally block so that the temp file at installScriptPath is always cleaned up, even if exec throws. Move the unlinkSync at line 486 into the finally block. This matches the pattern used in cli/src/commands/hatch.ts lines 482-505 where the hatchCustom function correctly uses try/finally for temp file cleanup.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

export async function hatchGcp(
Expand Down
4 changes: 2 additions & 2 deletions clients/macos/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ build_binaries() {

# CLI
build_bun_binary "$CLI_SRC_DIR" "$CLI_SRC_DIR/src/index.ts" \
"$SCRIPT_DIR/cli-bin" "vellum-cli" --external react-devtools-core
"$SCRIPT_DIR/cli-bin" "vellum-cli"

# Gateway
build_bun_binary "$GATEWAY_SRC_DIR" "$GATEWAY_SRC_DIR/src/index.ts" \
Expand Down Expand Up @@ -312,7 +312,7 @@ if [ -d "$CLI_SRC_DIR/src" ] && command -v bun &>/dev/null; then
fi
if [ "$CLI_BIN_NEEDS_BUILD" = true ]; then
build_bun_binary "$CLI_SRC_DIR" "$CLI_SRC_DIR/src/index.ts" \
"$SCRIPT_DIR/cli-bin" "vellum-cli" --external react-devtools-core
"$SCRIPT_DIR/cli-bin" "vellum-cli"
fi

# Also rebuild if CLI binary changed or newly added
Expand Down
5 changes: 3 additions & 2 deletions clients/macos/vellum-assistant/App/AssistantCli.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,8 @@ final class AssistantCli {

let proc = Process()
proc.executableURL = binaryURL
proc.arguments = ["hatch", "--remote", config.remote]
let cliRemote = config.remote == "customHardware" ? "custom" : config.remote
proc.arguments = ["hatch", "--remote", cliRemote]

let stdoutPipe = Pipe()
let stderrPipe = Pipe()
Expand Down Expand Up @@ -412,7 +413,7 @@ final class AssistantCli {
if !config.awsRoleArn.isEmpty {
env["VELLUM_AWS_ROLE_ARN"] = config.awsRoleArn
}
} else if config.remote == "custom" {
} else if cliRemote == "custom" {
if !config.sshHost.isEmpty {
let hostString = config.sshUser.isEmpty
? config.sshHost
Expand Down