Compare commits

...

7 Commits

Author SHA1 Message Date
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
10 changed files with 32 additions and 78 deletions
+3 -1
View File
@@ -41,7 +41,9 @@ Everything else is platform-managed or derived: instance/Organization id,
server, SSH settings, release, resource ceilings, database coordinates and server, SSH settings, release, resource ceilings, database coordinates and
generated password, domain, port, workspace, provider/base URL, model/role, generated password, domain, port, workspace, provider/base URL, model/role,
curated skills, concurrency, request/file limits and the managed Mihomo proxy curated skills, concurrency, request/file limits and the managed Mihomo proxy
environment. environment. `NODE_USE_ENV_PROXY=1` is required on Node.js 24 so Hub's built-in
`fetch` actually uses that proxy; merely setting `HTTP_PROXY`/`HTTPS_PROXY` is
not sufficient.
The Feishu app is scoped to this Silo. OAuth users authenticated by that app are The Feishu app is scoped to this Silo. OAuth users authenticated by that app are
automatically admitted to this Organization; OWNER remains the initial automatically admitted to this Organization; OWNER remains the initial
+2
View File
@@ -117,6 +117,8 @@ const platformEnv = [
envLine("HTTPS_PROXY", "http://127.0.0.1:7890"), envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"), envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
envLine("NO_PROXY", "127.0.0.1,localhost,::1"), envLine("NO_PROXY", "127.0.0.1,localhost,::1"),
envLine("NODE_USE_ENV_PROXY", "1"),
envLine("ANTHROPIC_DEFAULT_SONNET_MODEL", "anthropic/claude-sonnet-5"),
envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`), envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
"", "",
].join("\n"); ].join("\n");
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.18", "version": "0.0.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.18", "version": "0.0.20",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.18", "version": "0.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+6 -3
View File
@@ -23,6 +23,11 @@ const SAFE_HOST_ENV_KEYS = [
"CPH_BIN", "CPH_BIN",
] as const; ] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's // Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names, // sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit. // so keep our prefix well below that hard limit.
@@ -151,9 +156,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
}, },
credentials: { credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
// These values are short-lived loopback capabilities, not Organization envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
// provider secrets. Denying them also strips Claude's own request auth.
envVars: [],
}, },
}, },
}; };
+6 -32
View File
@@ -41,11 +41,6 @@ export interface ProviderProxyDiagnostic {
| "provider_proxy_redirect_refused" | "provider_proxy_redirect_refused"
| "provider_proxy_upstream_failed"; | "provider_proxy_upstream_failed";
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect"; readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
readonly authShape?: {
readonly bearerLength: number;
readonly apiKeyLength: number;
readonly expectedLength: number;
};
} }
export interface ProviderProxyOptions { export interface ProviderProxyOptions {
@@ -86,7 +81,7 @@ export async function openProviderProxyLease(
sdkEnv: { sdkEnv: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
ANTHROPIC_AUTH_TOKEN: capability, ANTHROPIC_AUTH_TOKEN: capability,
ANTHROPIC_API_KEY: capability, ANTHROPIC_API_KEY: "",
}, },
sensitiveValues: [capability], sensitiveValues: [capability],
async close(): Promise<void> { async close(): Promise<void> {
@@ -107,18 +102,8 @@ async function forwardProviderRequest(
upstream: ProviderUpstreamCredential, upstream: ProviderUpstreamCredential,
options: ProviderProxyOptions, options: ProviderProxyOptions,
): Promise<void> { ): Promise<void> {
if (!authorized(request.headers.authorization, header(request.headers["x-api-key"]), capability)) { if (!authorized(request.headers.authorization, capability)) {
options.onDiagnostic?.({ options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" });
code: "provider_proxy_unauthorized",
category: "authorization",
authShape: {
bearerLength: request.headers.authorization?.startsWith("Bearer ")
? request.headers.authorization.length - "Bearer ".length
: 0,
apiKeyLength: header(request.headers["x-api-key"])?.length ?? 0,
expectedLength: capability.length,
},
});
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
response.end("unauthorized"); response.end("unauthorized");
return; return;
@@ -180,24 +165,13 @@ async function forwardProviderRequest(
} }
} }
function authorized( function authorized(value: string | undefined, capability: string): boolean {
authorization: string | undefined, if (value === undefined || !value.startsWith("Bearer ")) return false;
apiKey: string | undefined, const supplied = Buffer.from(value.slice("Bearer ".length));
capability: string,
): boolean {
const value = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: apiKey;
if (value === undefined) return false;
const supplied = Buffer.from(value);
const expected = Buffer.from(capability); const expected = Buffer.from(capability);
return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected); return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected);
} }
function header(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers { function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers {
const result = new Headers(); const result = new Headers();
for (const [name, value] of Object.entries(source)) { for (const [name, value] of Object.entries(source)) {
-1
View File
@@ -73,7 +73,6 @@ export async function startHub(): Promise<void> {
providerId: context.providerId, providerId: context.providerId,
errorCode: diagnostic.code, errorCode: diagnostic.code,
failureCategory: diagnostic.category, failureCategory: diagnostic.category,
authShape: diagnostic.authShape,
}, "provider proxy diagnostic"); }, "provider proxy diagnostic");
}, },
}), }),
+4 -1
View File
@@ -66,7 +66,10 @@ describe("agent subprocess security policy", () => {
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
}, },
credentials: { credentials: {
envVars: [], envVars: expect.arrayContaining([
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
}, },
}); });
}); });
+4 -36
View File
@@ -53,14 +53,13 @@ describe("run-scoped provider proxy", () => {
expect(lease.sdkEnv).toMatchObject({ expect(lease.sdkEnv).toMatchObject({
ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/), ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/),
ANTHROPIC_AUTH_TOKEN: expect.any(String), ANTHROPIC_AUTH_TOKEN: expect.any(String),
ANTHROPIC_API_KEY: expect.any(String), ANTHROPIC_API_KEY: "",
}); });
expect(lease.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(lease.sdkEnv.ANTHROPIC_API_KEY);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
method: "POST", method: "POST",
headers: { headers: {
"x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "", authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`,
"content-type": "application/json", "content-type": "application/json",
"anthropic-version": "2023-06-01", "anthropic-version": "2023-06-01",
}, },
@@ -105,38 +104,7 @@ describe("run-scoped provider proxy", () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(upstreamRequests).toBe(0); expect(upstreamRequests).toBe(0);
expect(diagnostics).toEqual([{ expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]);
code: "provider_proxy_unauthorized",
category: "authorization",
authShape: { bearerLength: 0, apiKeyLength: 0, expectedLength: 43 },
}]);
});
it("accepts the run capability in the Anthropic x-api-key header", async () => {
let upstreamRequests = 0;
const upstream = createServer((_request, response) => {
upstreamRequests += 1;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true }));
});
upstreamServers.push(upstream);
upstream.listen(0, "127.0.0.1");
await once(upstream, "listening");
const address = upstream.address();
if (address === null || typeof address === "string") throw new Error("expected upstream TCP address");
const lease = await openProviderProxyLease({
baseUrl: `http://127.0.0.1:${address.port}`,
authToken: "customer-secret",
anthropicApiKey: "",
});
leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
headers: { "x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "" },
});
expect(response.status).toBe(200);
expect(upstreamRequests).toBe(1);
}); });
it("does not forward provider credentials across an upstream redirect", async () => { it("does not forward provider credentials across an upstream redirect", async () => {
@@ -172,7 +140,7 @@ describe("run-scoped provider proxy", () => {
leases.push(lease); leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
headers: { "x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "" }, headers: { authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}` },
redirect: "manual", redirect: "manual",
}); });
+4 -1
View File
@@ -288,7 +288,10 @@ describe("runAgent", () => {
}, },
sandbox: { sandbox: {
credentials: { credentials: {
envVars: [], envVars: expect.arrayContaining([
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
}, },
}, },
}, },