forked from EduCraft/curriculum-project-hub
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53998d2651 | |||
| 5b55cf18a8 | |||
| b0d691d53f | |||
| 1f48c5b707 | |||
| 12a2f3117f | |||
| 2ee84d9543 | |||
| 3087132083 |
@@ -41,7 +41,9 @@ Everything else is platform-managed or derived: instance/Organization id,
|
||||
server, SSH settings, release, resource ceilings, database coordinates and
|
||||
generated password, domain, port, workspace, provider/base URL, model/role,
|
||||
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
|
||||
automatically admitted to this Organization; OWNER remains the initial
|
||||
|
||||
@@ -117,6 +117,8 @@ const platformEnv = [
|
||||
envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
|
||||
envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
|
||||
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}`),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paradigm/hub",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -23,6 +23,11 @@ const SAFE_HOST_ENV_KEYS = [
|
||||
"CPH_BIN",
|
||||
] 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
|
||||
// sandbox appends its own user directory and randomized bridge socket names,
|
||||
// so keep our prefix well below that hard limit.
|
||||
@@ -151,9 +156,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
// These values are short-lived loopback capabilities, not Organization
|
||||
// provider secrets. Denying them also strips Claude's own request auth.
|
||||
envVars: [],
|
||||
envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -41,11 +41,6 @@ export interface ProviderProxyDiagnostic {
|
||||
| "provider_proxy_redirect_refused"
|
||||
| "provider_proxy_upstream_failed";
|
||||
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
|
||||
readonly authShape?: {
|
||||
readonly bearerLength: number;
|
||||
readonly apiKeyLength: number;
|
||||
readonly expectedLength: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderProxyOptions {
|
||||
@@ -86,7 +81,7 @@ export async function openProviderProxyLease(
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: capability,
|
||||
ANTHROPIC_API_KEY: capability,
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
sensitiveValues: [capability],
|
||||
async close(): Promise<void> {
|
||||
@@ -107,18 +102,8 @@ async function forwardProviderRequest(
|
||||
upstream: ProviderUpstreamCredential,
|
||||
options: ProviderProxyOptions,
|
||||
): Promise<void> {
|
||||
if (!authorized(request.headers.authorization, header(request.headers["x-api-key"]), capability)) {
|
||||
options.onDiagnostic?.({
|
||||
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,
|
||||
},
|
||||
});
|
||||
if (!authorized(request.headers.authorization, capability)) {
|
||||
options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" });
|
||||
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
||||
response.end("unauthorized");
|
||||
return;
|
||||
@@ -180,24 +165,13 @@ async function forwardProviderRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function authorized(
|
||||
authorization: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
capability: string,
|
||||
): boolean {
|
||||
const value = authorization?.startsWith("Bearer ")
|
||||
? authorization.slice("Bearer ".length)
|
||||
: apiKey;
|
||||
if (value === undefined) return false;
|
||||
const supplied = Buffer.from(value);
|
||||
function authorized(value: string | undefined, capability: string): boolean {
|
||||
if (value === undefined || !value.startsWith("Bearer ")) return false;
|
||||
const supplied = Buffer.from(value.slice("Bearer ".length));
|
||||
const expected = Buffer.from(capability);
|
||||
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 {
|
||||
const result = new Headers();
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
|
||||
@@ -73,7 +73,6 @@ export async function startHub(): Promise<void> {
|
||||
providerId: context.providerId,
|
||||
errorCode: diagnostic.code,
|
||||
failureCategory: diagnostic.category,
|
||||
authShape: diagnostic.authShape,
|
||||
}, "provider proxy diagnostic");
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -66,7 +66,10 @@ describe("agent subprocess security policy", () => {
|
||||
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
|
||||
},
|
||||
credentials: {
|
||||
envVars: [],
|
||||
envVars: expect.arrayContaining([
|
||||
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
|
||||
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,14 +53,13 @@ describe("run-scoped provider proxy", () => {
|
||||
expect(lease.sdkEnv).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/),
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "",
|
||||
authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
@@ -105,38 +104,7 @@ describe("run-scoped provider proxy", () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(upstreamRequests).toBe(0);
|
||||
expect(diagnostics).toEqual([{
|
||||
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);
|
||||
expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]);
|
||||
});
|
||||
|
||||
it("does not forward provider credentials across an upstream redirect", async () => {
|
||||
@@ -172,7 +140,7 @@ describe("run-scoped provider proxy", () => {
|
||||
leases.push(lease);
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
|
||||
@@ -288,7 +288,10 @@ describe("runAgent", () => {
|
||||
},
|
||||
sandbox: {
|
||||
credentials: {
|
||||
envVars: [],
|
||||
envVars: expect.arrayContaining([
|
||||
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
|
||||
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user