Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
606d9ea
0.2.2
rachellerathbone Mar 23, 2026
d39645f
0.2.1
rachellerathbone Mar 23, 2026
b90ceb0
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 26, 2026
33857ea
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 28, 2026
adedf27
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 28, 2026
16a72e1
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 29, 2026
64df5fe
chore: bump version to 0.2.2
rachellerathbone Mar 29, 2026
a586fa9
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 29, 2026
a74f9c6
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 30, 2026
5e7bfac
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Mar 31, 2026
e947595
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 4, 2026
806c160
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 7, 2026
f6fa7b4
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 8, 2026
3d1951b
0.3.0
rachellerathbone Apr 8, 2026
1f681a7
0.4.0
rachellerathbone Apr 8, 2026
d1f287e
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 8, 2026
2757536
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 8, 2026
22575e4
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 8, 2026
3b94d75
0.6.0
rachellerathbone Apr 8, 2026
a440ce1
update readme
rachellerathbone Apr 8, 2026
eb2d665
Merge branch 'main' of https://github.com/Multicorn-AI/multicorn-shield
rachellerathbone Apr 8, 2026
616b9ae
0.6.1
rachellerathbone Apr 8, 2026
1364803
fix proxy bug
rachellerathbone Apr 9, 2026
d49d4dd
address comments
rachellerathbone Apr 9, 2026
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
42 changes: 26 additions & 16 deletions bin/multicorn-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
deleteAgentByName,
getAgentByPlatform,
getDefaultAgent,
isAllowedShieldApiBaseUrl,
type ProxyConfig,
} from "../src/proxy/config.js";
import { createProxyServer } from "../src/proxy/index.js";
Expand All @@ -28,7 +29,8 @@ interface CliArgs {
readonly wrapCommand: string;
readonly wrapArgs: readonly string[];
readonly logLevel: LogLevel;
readonly baseUrl: string;
/** Set only when `--base-url` appears on the command line (omit default so init can resolve from config). */
readonly baseUrl: string | undefined;
readonly dashboardUrl: string;
readonly agentName: string;
readonly deleteAgentName: string;
Expand All @@ -41,7 +43,7 @@ function parseArgs(argv: readonly string[]): CliArgs {
let wrapCommand = "";
let wrapArgs: string[] = [];
let logLevel: LogLevel = "info";
let baseUrl = "https://api.multicorn.ai";
let baseUrl: string | undefined = undefined;
let dashboardUrl = "";
let agentName = "";
let deleteAgentName = "";
Expand Down Expand Up @@ -229,20 +231,17 @@ async function main(): Promise<void> {
return;
}

// Validate base URL before any network calls that send the API key.
if (
!cli.baseUrl.startsWith("https://") &&
!cli.baseUrl.startsWith("http://localhost") &&
!cli.baseUrl.startsWith("http://127.0.0.1")
) {
process.stderr.write(
`Error: --base-url must use HTTPS. Received: "${cli.baseUrl}"\n` +
"Use https:// or http://localhost for local development.\n",
);
process.exit(1);
// --wrap mode: validate explicit --base-url before disk read when the flag is set.
if (cli.baseUrl !== undefined && cli.baseUrl.length > 0) {
if (!isAllowedShieldApiBaseUrl(cli.baseUrl)) {
process.stderr.write(
"Error: --base-url must use HTTPS. Received a non-HTTPS URL.\n" +
"Use https:// or http://localhost for local development.\n",
);
process.exit(1);
}
}

// --wrap mode
const config = await loadConfig();
if (config === null) {
process.stderr.write(
Expand All @@ -251,9 +250,20 @@ async function main(): Promise<void> {
process.exit(1);
}

const agentName = resolveWrapAgentName(cli, config);
const finalBaseUrl =
cli.baseUrl !== undefined && cli.baseUrl.length > 0 ? cli.baseUrl : config.baseUrl;

const finalBaseUrl = cli.baseUrl !== "https://api.multicorn.ai" ? cli.baseUrl : config.baseUrl;
if (cli.baseUrl === undefined || cli.baseUrl.length === 0) {
if (!isAllowedShieldApiBaseUrl(finalBaseUrl)) {
process.stderr.write(
"Error: --base-url must use HTTPS. Received a non-HTTPS URL.\n" +
"Use https:// or http://localhost for local development.\n",
);
process.exit(1);
}
}

const agentName = resolveWrapAgentName(cli, config);
const finalDashboardUrl =
cli.dashboardUrl !== "" ? cli.dashboardUrl : deriveDashboardUrl(finalBaseUrl);

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "multicorn-shield",
"version": "0.6.0",
"version": "0.6.1",
"description": "The control layer for AI agents: permissions, consent, spending limits, and audit logging.",
"license": "MIT",
"type": "module",
Expand Down
137 changes: 137 additions & 0 deletions src/proxy/__tests__/proxy.edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,143 @@ describe("config file parsing", () => {
expect(config.apiKey).toBe("mcs_new_key");
});

it("runInit reads base URL from config.json when user enters a new key", async () => {
captureStderr();
writeFileMock.mockResolvedValue(undefined);
mkdirMock.mockResolvedValue(undefined);
const existingConfig = JSON.stringify({
apiKey: "mcs_old_key1234",
baseUrl: "https://enterprise.example.com",
});
readFileMock.mockImplementation((path: string) => {
if (path.includes(".openclaw")) return Promise.resolve(MINIMAL_OPENCLAW_JSON);
if (path.includes("config.json")) return Promise.resolve(existingConfig);
return Promise.reject(new Error("ENOENT"));
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
global.fetch = fetchMock;

mockPrompts({
"Use this key": "n",
"API key": "mcs_new_key",
Select: "1",
"call this agent": "my-agent",
"Connect another": "n",
});

const config = await runInit();

expect(config).not.toBeNull();
if (!config) throw new Error("expected config");
expect(config.baseUrl).toBe("https://enterprise.example.com");
expect(config.apiKey).toBe("mcs_new_key");
const agentsFetchCall = fetchMock.mock.calls.find(
(c): c is [string, ...unknown[]] =>
typeof c[0] === "string" && c[0].includes("/api/v1/agents"),
);
expect(agentsFetchCall).toBeDefined();
if (agentsFetchCall === undefined) throw new Error("expected /api/v1/agents fetch");
expect(agentsFetchCall[0]).toContain("enterprise.example.com");
});

it("runInit reads base URL from config.json when apiKey is missing (partial config)", async () => {
captureStderr();
writeFileMock.mockResolvedValue(undefined);
mkdirMock.mockResolvedValue(undefined);
const partialOnlyBase = JSON.stringify({
baseUrl: "https://only-base.example.com",
});
readFileMock.mockImplementation((path: string) => {
if (path.includes(".openclaw")) return Promise.resolve(MINIMAL_OPENCLAW_JSON);
if (path.includes("config.json")) return Promise.resolve(partialOnlyBase);
return Promise.reject(new Error("ENOENT"));
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
global.fetch = fetchMock;

mockPrompts({
"API key": "mcs_new_key",
Select: "1",
"call this agent": "solo-agent",
"Connect another": "n",
});

const config = await runInit();

expect(config).not.toBeNull();
if (!config) throw new Error("expected config");
expect(config.baseUrl).toBe("https://only-base.example.com");
const agentsFetchCall = fetchMock.mock.calls.find(
(c): c is [string, ...unknown[]] =>
typeof c[0] === "string" && c[0].includes("/api/v1/agents"),
);
expect(agentsFetchCall).toBeDefined();
if (agentsFetchCall === undefined) throw new Error("expected /api/v1/agents fetch");
expect(agentsFetchCall[0]).toContain("only-base.example.com");
});

it("runInit rejects non-HTTPS base URL from config.json", async () => {
captureStderr();
writeFileMock.mockResolvedValue(undefined);
mkdirMock.mockResolvedValue(undefined);
const insecureBase = JSON.stringify({
baseUrl: "http://evil.example.com",
});
readFileMock.mockImplementation((path: string) => {
if (path.includes(".openclaw")) return Promise.resolve(MINIMAL_OPENCLAW_JSON);
if (path.includes("config.json")) return Promise.resolve(insecureBase);
return Promise.reject(new Error("ENOENT"));
});
global.fetch = vi.fn();

const config = await runInit();

expect(config).toBeNull();
expect(stderrBuffer).toContain("HTTPS");
expect(stderrBuffer).toContain("base-url");
expect(global.fetch).not.toHaveBeenCalled();
});

it("runInit keeps explicit --base-url over config.json base URL", async () => {
captureStderr();
writeFileMock.mockResolvedValue(undefined);
mkdirMock.mockResolvedValue(undefined);
const existingConfig = JSON.stringify({
apiKey: "mcs_existing",
baseUrl: "https://from-file.example.com",
});
readFileMock.mockImplementation((path: string) => {
if (path.includes(".openclaw")) return Promise.resolve(MINIMAL_OPENCLAW_JSON);
if (path.includes("config.json")) return Promise.resolve(existingConfig);
return Promise.reject(new Error("ENOENT"));
});
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
global.fetch = fetchMock;

mockPrompts({
"Use this key": "n",
"API key": "mcs_new_after_override",
Select: "1",
"call this agent": "my-agent",
"Connect another": "n",
});

const config = await runInit("https://explicit-override.example.com");

expect(config).not.toBeNull();
if (!config) throw new Error("expected config");
expect(config.baseUrl).toBe("https://explicit-override.example.com");
expect(config.apiKey).toBe("mcs_new_after_override");
const agentsFetchCall = fetchMock.mock.calls.find(
(c): c is [string, ...unknown[]] =>
typeof c[0] === "string" && c[0].includes("/api/v1/agents"),
);
expect(agentsFetchCall).toBeDefined();
if (agentsFetchCall === undefined) throw new Error("expected /api/v1/agents fetch");
expect(agentsFetchCall[0]).toContain("explicit-override.example.com");
expect(agentsFetchCall[0]).not.toContain("from-file.example.com");
});

it("runInit normalizes agent name with spaces and uppercase", async () => {
captureStderr();
writeFileMock.mockResolvedValue(undefined);
Expand Down
25 changes: 25 additions & 0 deletions src/proxy/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
loadConfig,
readBaseUrlFromConfig,
saveConfig,
validateApiKey,
getAgentByPlatform,
Expand Down Expand Up @@ -195,6 +196,30 @@ describe("loadConfig", () => {
});
});

describe("readBaseUrlFromConfig", () => {
beforeEach(() => {
vi.resetAllMocks();
});

it("returns undefined when the file does not exist (brand new install)", async () => {
readFileMock.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));

await expect(readBaseUrlFromConfig()).resolves.toBeUndefined();
});

it("returns undefined when JSON has no baseUrl", async () => {
readFileMock.mockResolvedValue(JSON.stringify({ apiKey: "mcs_x" }));

await expect(readBaseUrlFromConfig()).resolves.toBeUndefined();
});

it("returns baseUrl when present even if loadConfig would reject the file", async () => {
readFileMock.mockResolvedValue(JSON.stringify({ baseUrl: "https://self-hosted.example.com" }));

await expect(readBaseUrlFromConfig()).resolves.toBe("https://self-hosted.example.com");
});
});

describe("getAgentByPlatform", () => {
const sample: ProxyConfig = {
apiKey: "k",
Expand Down
Loading
Loading