From 179f61dcda12b17586476f061bddacaeaa0bafad Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Thu, 20 Nov 2025 08:33:51 -0800 Subject: [PATCH] Make sure ports are set correctly --- installer/scripts/darwin_postinstall.sh | 20 +- packages/safe-chain/src/agent/runCommand.js | 25 ++ test/e2e/agent-mode.e2e.spec.js | 398 ++------------------ 3 files changed, 81 insertions(+), 362 deletions(-) diff --git a/installer/scripts/darwin_postinstall.sh b/installer/scripts/darwin_postinstall.sh index 5bcb696..8355cf3 100644 --- a/installer/scripts/darwin_postinstall.sh +++ b/installer/scripts/darwin_postinstall.sh @@ -101,17 +101,27 @@ if ! plutil -lint "${PLIST_PATH}" > /dev/null 2>&1; then fi # Load the LaunchAgent to start the service now -# Need to run as the actual user, not root +# Need to run as the actual user sudo -u "${ACTUAL_USER}" launchctl load "${PLIST_PATH}" 2>/dev/null || true -# Give it a moment to start -sleep 2 +# Give it a moment to start and write the port file +sleep 3 + +# Read the dynamically-assigned port from the port file +PORT_FILE="${USER_HOME}/.safe-chain/port" +if [ -f "${PORT_FILE}" ]; then + PROXY_PORT=$(cat "${PORT_FILE}") + echo "Detected proxy running on port: ${PROXY_PORT}" +else + echo "⚠ Warning: Could not detect proxy port, using default 8080" + PROXY_PORT=8080 +fi # Set system-wide environment variables so all processes can use the proxy # These affect all processes for the user, not just the LaunchAgent echo "Setting system-wide proxy environment variables..." -sudo -u "${ACTUAL_USER}" launchctl setenv HTTPS_PROXY "http://localhost:8080" -sudo -u "${ACTUAL_USER}" launchctl setenv GLOBAL_AGENT_HTTP_PROXY "http://localhost:8080" +sudo -u "${ACTUAL_USER}" launchctl setenv HTTPS_PROXY "http://localhost:${PROXY_PORT}" +sudo -u "${ACTUAL_USER}" launchctl setenv GLOBAL_AGENT_HTTP_PROXY "http://localhost:${PROXY_PORT}" sudo -u "${ACTUAL_USER}" launchctl setenv NODE_EXTRA_CA_CERTS "${CERT_DIR}/ca-cert.pem" sudo -u "${ACTUAL_USER}" launchctl setenv SAFE_CHAIN_CERT_DIR "${CERT_DIR}" diff --git a/packages/safe-chain/src/agent/runCommand.js b/packages/safe-chain/src/agent/runCommand.js index d501f84..5eb5641 100644 --- a/packages/safe-chain/src/agent/runCommand.js +++ b/packages/safe-chain/src/agent/runCommand.js @@ -3,6 +3,9 @@ import { ui } from "../environment/userInteraction.js"; import chalk from "chalk"; import { initializeCliArguments } from "../config/cliArguments.js"; import { getCaCertPath } from "../registryProxy/certUtils.js"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; /** * Run the Safe Chain proxy as a standalone service @@ -42,6 +45,17 @@ export async function runCommand(args) { ui.writeInformation(` PID: ${chalk.cyan(process.pid)}`); ui.emptyLine(); + // Write port to a well-known location for the installer to read + // This allows launchctl setenv to use the actual dynamically-assigned port + const portFilePath = path.join(os.homedir(), ".safe-chain", "port"); + try { + fs.mkdirSync(path.dirname(portFilePath), { recursive: true }); + fs.writeFileSync(portFilePath, String(port), "utf8"); + } catch (/** @type {any} */ error) { + // Non-fatal, just log + ui.writeWarning(`Could not write port file: ${error.message}`); + } + ui.writeInformation(chalk.bold("Environment Variables Set:")); ui.writeInformation(` ${chalk.cyan("HTTPS_PROXY")}: http://localhost:${port}`); ui.writeInformation(` ${chalk.cyan("GLOBAL_AGENT_HTTP_PROXY")}: http://localhost:${port}`); @@ -61,6 +75,17 @@ export async function runCommand(args) { ui.emptyLine(); ui.writeInformation(chalk.yellow("Proxy stopped.")); + // Clean up port file + const portFilePath = path.join(os.homedir(), ".safe-chain", "port"); + try { + if (fs.existsSync(portFilePath)) { + fs.unlinkSync(portFilePath); + } + } catch (/** @type {any} */ error) { + // Non-fatal + ui.writeWarning(`Could not remove port file: ${error.message}`); + } + if (blockedPackages.length > 0) { ui.emptyLine(); ui.writeInformation( diff --git a/test/e2e/agent-mode.e2e.spec.js b/test/e2e/agent-mode.e2e.spec.js index 5b9e231..ae21a6c 100644 --- a/test/e2e/agent-mode.e2e.spec.js +++ b/test/e2e/agent-mode.e2e.spec.js @@ -1,375 +1,59 @@ -import { describe, it, before, after } from "node:test"; +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { DockerTestContainer } from "./DockerTestContainer.js"; import assert from "node:assert"; -import { spawn } from "node:child_process"; -import { join, dirname } from "node:path"; -import { homedir } from "node:os"; -import { fileURLToPath } from "node:url"; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, "../.."); +let container; -const SAFE_CHAIN_BIN = join( - REPO_ROOT, - "packages/safe-chain/bin/safe-chain.js" -); -const AIKIDO_NPM_BIN = join( - REPO_ROOT, - "packages/safe-chain/bin/aikido-npm.js" -); -const AIKIDO_PIP_BIN = join( - REPO_ROOT, - "packages/safe-chain/bin/aikido-pip3.js" -); +before(async () => { + DockerTestContainer.buildImage(); +}); -/** - * Helper to start safe-chain run in agent mode - * @param {string[]} args - Arguments to pass to safe-chain run - * @returns {Promise<{process: import('child_process').ChildProcess, port: number, pid: number}>} - */ -async function startAgentMode(args = []) { - return new Promise((resolve, reject) => { - const proc = spawn("node", [SAFE_CHAIN_BIN, "run", ...args], { - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); +beforeEach(async () => { + container = new DockerTestContainer(); + await container.start(); +}); - let output = ""; - let hasResolved = false; +afterEach(async () => { + if (container) { + await container.stop(); + container = null; + } +}); - const onData = (data) => { - output += data.toString(); - - // Strip ANSI color codes for parsing - const strippedOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); - - // Look for port and pid - they might arrive in separate chunks - const portMatch = strippedOutput.match(/Port:\s+(\d+)/); - const pidMatch = strippedOutput.match(/PID:\s+(\d+)/); - - // Also check for the success checkmark as confirmation - const hasSuccess = strippedOutput.includes("Safe Chain proxy started successfully"); - - if (portMatch && pidMatch && hasSuccess && !hasResolved) { - hasResolved = true; - resolve({ - process: proc, - port: parseInt(portMatch[1]), - pid: parseInt(pidMatch[1]), - }); - } - }; +describe("Agent Mode E2E", () => { + let shell; - proc.stdout.on("data", onData); - proc.stderr.on("data", onData); - - proc.on("error", (error) => { - if (!hasResolved) { - hasResolved = true; - reject(error); - } - }); - - proc.on("exit", (code) => { - if (!hasResolved && code !== 0) { - hasResolved = true; - reject(new Error(`Process exited with code ${code}\n${output}`)); - } - }); - - // Timeout after 5 seconds - setTimeout(() => { - if (!hasResolved) { - hasResolved = true; - proc.kill(); - reject(new Error(`Timeout waiting for agent to start\n${output}`)); - } - }, 5000); - }); -} - -/** - * Helper to stop agent mode process - * @param {import('child_process').ChildProcess} proc - * @returns {Promise} - */ -async function stopAgentMode(proc) { - return new Promise((resolve) => { - if (!proc || proc.killed) { - resolve(); - return; - } - - let resolved = false; - const doResolve = () => { - if (!resolved) { - resolved = true; - resolve(); - } - }; - - proc.on("exit", doResolve); - - // Send SIGTERM - proc.kill("SIGTERM"); - - // Force kill after 1 second if still running - setTimeout(() => { - if (!proc.killed) { - proc.kill("SIGKILL"); - } - doResolve(); - }, 1000); - }); -} - -/** - * Helper to run aikido-npm command - * @param {string[]} args - * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} - */ -async function runAikidoNpm(args) { - return new Promise((resolve) => { - const proc = spawn("node", [AIKIDO_NPM_BIN, ...args], { - stdio: ["ignore", "pipe", "pipe"], - cwd: "/tmp", - }); - - let stdout = ""; - let stderr = ""; - - proc.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - proc.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("exit", (code) => { - resolve({ - stdout, - stderr, - exitCode: code || 0, - }); - }); - }); -} - -/** - * Helper to run aikido-pip command - * @param {string[]} args - * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} - */ -async function runAikidoPip(args) { - return new Promise((resolve) => { - const proc = spawn("node", [AIKIDO_PIP_BIN, ...args], { - stdio: ["ignore", "pipe", "pipe"], - cwd: "/tmp", - }); - - let stdout = ""; - let stderr = ""; - - proc.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - proc.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - proc.on("exit", (code) => { - resolve({ - stdout, - stderr, - exitCode: code || 0, - }); - }); - }); -} - -describe("Agent Mode E2E", { timeout: 60000 }, () => { - describe("safe-chain run", () => { - it("should start proxy successfully", async () => { - let agent; - try { - // Start agent mode - agent = await startAgentMode(); - - // Verify process is running - assert.ok(agent.process); - assert.ok(agent.port > 0); - assert.ok(agent.pid > 0); - } finally { - if (agent) { - await stopAgentMode(agent.process); - } - } - }); - - it("should accept verbose flag", async () => { - let agent; - try { - // Start agent mode with verbose flag - agent = await startAgentMode(["--verbose"]); - - // Verify process started - assert.ok(agent.process); - assert.ok(agent.port > 0); - } finally { - if (agent) { - await stopAgentMode(agent.process); - } - } - }); - - it("should stop cleanly", async () => { - let agent; - try { - // Start agent mode - agent = await startAgentMode(); - - // Verify process is running - assert.ok(agent.process); - - // Stop agent - await stopAgentMode(agent.process); - agent = null; - - // Wait a bit for cleanup - await new Promise((resolve) => setTimeout(resolve, 100)); - } finally { - if (agent) { - await stopAgentMode(agent.process); - } - } - }); + beforeEach(async () => { + shell = await container.openShell("zsh"); + await shell.runCommand("safe-chain setup-ci"); + await shell.runCommand("echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc"); }); - describe("aikido-npm with agent mode", () => { - let agent; - - before(async () => { - // Start agent mode for all npm tests - agent = await startAgentMode(["--verbose"]); - // Wait a bit to ensure proxy is fully ready - await new Promise((resolve) => setTimeout(resolve, 500)); - }); - - after(async () => { - // Stop agent after all tests - if (agent) { - await stopAgentMode(agent.process); - } - }); - - it("should use existing proxy when running npm view", async () => { - const result = await runAikidoNpm(["view", "lodash", "version"]); - - // Should succeed - assert.strictEqual(result.exitCode, 0); - - // Should have output with version - assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash")); - - // Verify proxy intercepted the request (check stderr for proxy messages) - const allOutput = result.stdout + result.stderr; - assert.ok( - allOutput.includes("registry.npmjs.org") || - allOutput.includes("MITM") || - result.exitCode === 0, - "Should use proxy for request" - ); - }); - - it("should use existing proxy when running npm info", async () => { - const result = await runAikidoNpm(["info", "express", "version"]); - - // Should succeed - assert.strictEqual(result.exitCode, 0); - - // Should have output - assert.ok(result.stdout.length > 0); - }); - - it("should detect malware using existing proxy", async () => { - // Note: This test assumes there's a known malware package in the database - // If no malware is configured, the test will just verify the command runs - const result = await runAikidoNpm(["view", "some-test-package", "version"]); - - // Command should complete (may succeed or fail depending on package existence) - assert.ok(result.exitCode !== undefined); - }); + it("should start proxy successfully", async () => { + const result = await shell.runCommand("safe-chain run & sleep 2; ps aux | grep safe-chain"); + assert.ok(result.output.includes("safe-chain"), "Proxy did not start successfully"); }); - describe("aikido-pip with agent mode", () => { - let agent; - - before(async () => { - // Start agent mode for all pip tests - agent = await startAgentMode(["--verbose"]); - // Wait a bit to ensure proxy is fully ready - await new Promise((resolve) => setTimeout(resolve, 500)); - }); - - after(async () => { - // Stop agent after all tests - if (agent) { - await stopAgentMode(agent.process); - } - }); - - it("should use existing proxy when running pip download", async () => { - // Use --dry-run to avoid actual installation - const result = await runAikidoPip(["download", "requests", "--dry-run"]); - - // Command should complete - assert.ok(result.exitCode !== undefined); - - // Should have some output - const allOutput = result.stdout + result.stderr; - assert.ok(allOutput.length > 0); - }); + it("should accept verbose flag", async () => { + const result = await shell.runCommand("safe-chain run --verbose & sleep 2; ps aux | grep safe-chain"); + assert.ok(result.output.includes("safe-chain"), "Proxy did not start with verbose flag"); }); - describe("inline mode (no agent)", () => { - it("should start inline proxy when no agent is running", async () => { - // Run aikido-npm without agent mode - const result = await runAikidoNpm(["view", "lodash", "version"]); - - // Should succeed with inline proxy - assert.strictEqual(result.exitCode, 0); - - // Should have output - assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash")); - }); + it("should stop cleanly", async () => { + await shell.runCommand("safe-chain run & sleep 2; pkill -f safe-chain"); + const result = await shell.runCommand("ps aux | grep safe-chain"); + assert.ok(!result.output.includes("safe-chain run"), "Proxy did not stop cleanly"); }); - describe("combined ecosystems", () => { - let agent; + it("should use existing proxy when running npm view", async () => { + await shell.runCommand("safe-chain run & sleep 2"); + const result = await shell.runCommand("npm view lodash version"); + assert.ok(result.output.includes("4.17") || result.output.includes("lodash")); + }); - before(async () => { - // Start agent mode (supports all ecosystems by default) - agent = await startAgentMode(["--verbose"]); - await new Promise((resolve) => setTimeout(resolve, 500)); - }); - - after(async () => { - if (agent) { - await stopAgentMode(agent.process); - } - }); - - it("should handle npm requests", async () => { - const result = await runAikidoNpm(["view", "chalk", "version"]); - assert.strictEqual(result.exitCode, 0); - assert.ok(result.stdout.length > 0); - }); - - it("should handle pip requests", async () => { - const result = await runAikidoPip(["download", "requests", "--dry-run"]); - // Command should complete - assert.ok(result.exitCode !== undefined); - }); + it("should use existing proxy when running pip download", async () => { + await shell.runCommand("safe-chain run & sleep 2"); + const result = await shell.runCommand("pip download requests --dry-run"); + assert.ok(result.output.length > 0); }); });