feat: secure organization provider credentials

This commit is contained in:
2026-07-10 22:04:43 +08:00
parent e049cb6880
commit 848f913597
51 changed files with 3280 additions and 230 deletions
+7 -6
View File
@@ -6,12 +6,9 @@ NODE_ENV="production"
# PostgreSQL connection string.
DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# Claude Code SDK auth via OpenRouter's Anthropic Skin.
# ANTHROPIC_AUTH_TOKEN = your OpenRouter API key (https://openrouter.ai/keys)
# ANTHROPIC_API_KEY must be explicitly empty to avoid auth conflicts.
ANTHROPIC_BASE_URL="https://openrouter.ai/api"
ANTHROPIC_AUTH_TOKEN=""
ANTHROPIC_API_KEY=""
# Provider URL and credentials are write-only Organization Provider Connection
# data in PostgreSQL, encrypted by ADR-0024. Process-global ANTHROPIC_* provider
# settings are rejected in production and must not be added here.
# Model override (optional). Defaults to anthropic/claude-sonnet-5.
# Use an OpenRouter model slug, for example:
@@ -57,3 +54,7 @@ HUB_SESSION_SECRET=""
# Optional OAuth scope (space-separated). Default: contact:user.base:readonly
# Apply matching scopes in the Feishu developer console.
# HUB_OAUTH_SCOPE="contact:user.base:readonly"
# Local development/tests only. Production must not set this: systemd injects
# the fixed cph-secret-keyring credential through CREDENTIALS_DIRECTORY.
# HUB_SECRET_KEYRING_FILE="/absolute/path/to/dev-keyring.json"
+43 -4
View File
@@ -14,13 +14,52 @@ sudo BASE=/srv/curriculum-project-hub \
```
On a clean host the first invocation creates
`/srv/curriculum-project-hub/.secrets/platform.env` with every supported
production key and exits with status 78. Fill every required blank, set
`CPH_BIN` to an executable compatible `cph`, keep the file mode at `0600`, and
run the same command again. No systemd unit is installed until configuration,
`/srv/curriculum-project-hub/.secrets/platform.env` and a random root-owned
`secret-keyring.json`, then exits with status 78. Copy the keyring to a
separately protected recovery location before continuing; it must not live only
on this host or in the ordinary database/workspace backup. Fill every required
environment blank, set `CPH_BIN` to an executable compatible `cph`, keep both
source files root-only at mode `0600`, and run the same command again. No
systemd unit is installed until configuration,
paths, Node, bubblewrap, `socat`, `cph --version`, PostgreSQL connectivity and directory
ownership all pass preflight.
The service account cannot read the keyring source. The installed unit uses
systemd `LoadCredential` to materialize a read-only `cph-secret-keyring` for the
running service. Hub refuses production startup if this credential is missing,
malformed, or does not contain its declared active key. Provider credentials
are configured through the Organization admin connection API, never through
process-global `ANTHROPIC_*` variables; preflight rejects those legacy settings.
Rotate the local KEK only from the controlled host console. The command first
atomically adds a new active key while retaining every previous key, then
authenticates and rewraps every stored Provider DEK, writes redacted org audit
events, and verifies the complete set again:
```sh
sudo bash -c '
set -euo pipefail
systemctl stop cph-hub.service
set -a
. /srv/curriculum-project-hub/.secrets/platform.env
set +a
node /srv/curriculum-project-hub/hub/dist/deployment/rotate-secret-kek.js \
--keyring-file /srv/curriculum-project-hub/.secrets/secret-keyring.json
systemctl start cph-hub.service
'
```
The CLI refuses to rotate while `cph-hub.service` is active; stopping the only
writer prevents a concurrent BYOK version from being appended under the old
in-memory keyring. A PostgreSQL transaction advisory lock excludes a second
rotation and is released automatically if the CLI or host crashes. The
operation is therefore rerunnable after interruption: old and new keys remain
in the atomically replaced keyring, so partially rewrapped rows stay
decryptable. It also refuses success while any envelope still names a
non-active KEK. Back up the updated keyring to the separate recovery location
and complete a restore preflight before considering old-key retirement. The
pilot deliberately does not auto-delete previous keys.
The deployment path also runs `npm run audit:production` from the locked clean
install before building or restarting the service. A current high or critical
production advisory therefore blocks deployment instead of becoming a warning
+4
View File
@@ -7,6 +7,7 @@ Wants=network-online.target
# this start-time assertion keeps later package/host changes fail-closed.
AssertPathExists=__BWRAP_BIN__
AssertPathExists=__SOCAT_BIN__
AssertPathExists=__KEYRING_FILE__
[Service]
Type=simple
@@ -18,6 +19,9 @@ Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=PATH=__RUNTIME_PATH__
# ADR-0024: the root-owned source remains unreadable by the service account;
# systemd materializes a read-only per-unit credential at runtime.
LoadCredential=cph-secret-keyring:__KEYRING_FILE__
UMask=0027
# Run prisma migrations before each start, then launch the Hub.
ExecStartPre=__NODE_BIN__ __HUB_DIR__/node_modules/prisma/build/index.js migrate deploy --schema __HUB_DIR__/prisma/schema.prisma
+37 -5
View File
@@ -21,6 +21,7 @@ WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/workspaces}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8788}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/platform.env}"
KEYRING_FILE="${KEYRING_FILE:-$BASE/.secrets/secret-keyring.json}"
SYSTEMD_UNIT_DIR="${SYSTEMD_UNIT_DIR:-/etc/systemd/system}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
BWRAP_BIN="${BWRAP_BIN:-$(command -v bwrap || true)}"
@@ -63,7 +64,7 @@ if [ "$SERVICE_USER" = "root" ] || [ "$SERVICE_GROUP" = "root" ]; then
fail "the Hub service identity must not be root"
fi
for command in getent groupadd useradd install stat systemctl id; do
for command in getent groupadd useradd install stat systemctl id openssl date mktemp; do
require_command "$command"
done
for pair in \
@@ -97,6 +98,7 @@ for pair in \
"CACHE_DIR:$CACHE_DIR" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \
"SYSTEMD_UNIT_DIR:$SYSTEMD_UNIT_DIR" \
"NODE_BIN:$NODE_BIN" \
"BWRAP_BIN:$BWRAP_BIN" \
@@ -107,6 +109,32 @@ for pair in \
require_safe_unit_value "${pair%%:*}" "${pair#*:}"
done
KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then
fail "local secret keyring must not be a symlink: $KEYRING_FILE"
fi
if [ ! -e "$KEYRING_FILE" ]; then
install -d -o root -g root -m 0700 "$(dirname "$KEYRING_FILE")"
key_id="local-$(date -u +%Y%m%dT%H%M%SZ)"
key_value="$(openssl rand -base64 32)"
tmp_keyring="$(mktemp "$(dirname "$KEYRING_FILE")/.secret-keyring.XXXXXX")"
trap 'rm -f "${tmp_keyring:-}"' EXIT
chmod 0600 "$tmp_keyring"
printf '{"version":1,"activeKeyId":"%s","keys":{"%s":"%s"}}\n' \
"$key_id" "$key_id" "$key_value" >"$tmp_keyring"
install -o root -g root -m 0600 "$tmp_keyring" "$KEYRING_FILE"
rm -f "$tmp_keyring"
tmp_keyring=""
trap - EXIT
unset key_value
KEYRING_CREATED=true
echo "[install] generated local secret keyring: $KEYRING_FILE" >&2
fi
[ -f "$KEYRING_FILE" ] || fail "local secret keyring is not a regular file: $KEYRING_FILE"
keyring_metadata="$(stat -c '%u:%g:%a' -- "$KEYRING_FILE")"
[ "$keyring_metadata" = "0:0:600" ] || \
fail "local secret keyring must be owned by root:root with mode 0600; found $keyring_metadata"
if [ -L "$ENV_FILE" ]; then
fail "environment file must not be a symlink: $ENV_FILE"
fi
@@ -118,9 +146,6 @@ if [ ! -e "$ENV_FILE" ]; then
# rerunning install_service.sh; failed preflight never installs the unit.
NODE_ENV=production
DATABASE_URL=
ANTHROPIC_BASE_URL=https://openrouter.ai/api
ANTHROPIC_AUTH_TOKEN=
ANTHROPIC_API_KEY=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_BOT_OPEN_ID=
@@ -132,18 +157,24 @@ HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=25
HUB_FEISHU_LISTENER_ENABLED=true
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE
CPH_SANDBOX_EXTRA_DENY_READ=$ENV_FILE:$KEYRING_FILE
EOF
chmod 0600 "$ENV_FILE"
echo "[install] seeded complete production configuration: $ENV_FILE" >&2
echo "[install] copy $KEYRING_FILE to the separately protected keyring backup before continuing" >&2
echo "[install] fill every required blank, install cph at CPH_BIN, then rerun" >&2
exit 78
fi
[ -f "$ENV_FILE" ] || fail "environment file is not a regular file: $ENV_FILE"
if [ "$KEYRING_CREATED" = true ]; then
echo "[install] copy $KEYRING_FILE to the separately protected keyring backup, then rerun" >&2
exit 78
fi
PREFLIGHT_ARGS=(
"$NODE_BIN" "$PREFLIGHT_JS"
--env-file "$ENV_FILE"
--keyring-file "$KEYRING_FILE"
--node-bin "$NODE_BIN"
--runuser-bin "$RUNUSER_BIN"
--setpriv-bin "$SETPRIV_BIN"
@@ -249,6 +280,7 @@ sed \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
-e "s|__KEYRING_FILE__|$KEYRING_FILE|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
-e "s|__BWRAP_BIN__|$BWRAP_BIN|g" \
-e "s|__SOCAT_BIN__|$SOCAT_BIN|g" \
+2 -1
View File
@@ -27,7 +27,7 @@
"axios": "1.18.1"
}
},
"description": "Curriculum Project Hub — Feishu-group collaboration + Claude Code SDK agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
@@ -37,6 +37,7 @@
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js",
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"test:watch": "vitest"
@@ -0,0 +1,70 @@
-- ADR-0024: Organization-scoped provider connections and immutable encrypted
-- credential versions. No provider plaintext column exists in this schema.
CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED');
CREATE TYPE "OrganizationConnectionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'DISABLED');
CREATE TABLE "OrganizationProviderConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"mode" "ProviderCredentialMode" NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ProviderCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "ProviderCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationProviderConnection_organizationId_providerId_key"
ON "OrganizationProviderConnection"("organizationId", "providerId");
CREATE UNIQUE INDEX "OrganizationProviderConnection_activeSecretVersionId_key"
ON "OrganizationProviderConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationProviderConnection_organizationId_status_idx"
ON "OrganizationProviderConnection"("organizationId", "status");
CREATE INDEX "OrganizationProviderConnection_providerId_status_idx"
ON "OrganizationProviderConnection"("providerId", "status");
CREATE UNIQUE INDEX "ProviderCredentialVersion_connectionId_version_key"
ON "ProviderCredentialVersion"("connectionId", "version");
CREATE INDEX "ProviderCredentialVersion_connectionId_retiredAt_idx"
ON "ProviderCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "ProviderCredentialVersion_keyId_idx"
ON "ProviderCredentialVersion"("keyId");
CREATE INDEX "ProviderCredentialVersion_createdByUserId_idx"
ON "ProviderCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationProviderConnection"
ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProviderCredentialVersion"
ADD CONSTRAINT "ProviderCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationProviderConnection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProviderCredentialVersion"
ADD CONSTRAINT "ProviderCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationProviderConnection"
ADD CONSTRAINT "OrganizationProviderConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "ProviderCredentialVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "AuditEntry" ADD COLUMN "organizationId" TEXT;
ALTER TABLE "AuditEntry"
ADD CONSTRAINT "AuditEntry_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX "AuditEntry_organizationId_createdAt_idx"
ON "AuditEntry"("organizationId", "createdAt");
+65
View File
@@ -41,6 +41,8 @@ model Organization {
projects Project[]
teams Team[]
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status])
}
@@ -106,6 +108,66 @@ model User {
permissionGrants PermissionGrant[] @relation("grantCreator")
roleTriggerGrants RoleTriggerGrant[] @relation("roleGrantCreator")
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
}
/// ADR-0024: model-provider connection identity is stable and belongs to one
/// Organization. Both BYOK and platform-managed credentials are tenant-local;
/// only ACTIVE connections with an active immutable secret version resolve.
model OrganizationProviderConnection {
id String @id @default(cuid())
organizationId String
providerId String
mode ProviderCredentialMode
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions ProviderCredentialVersion[] @relation("providerCredentialVersions")
activeSecretVersion ProviderCredentialVersion? @relation("activeProviderCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@unique([organizationId, providerId])
@@index([organizationId, status])
@@index([providerId, status])
}
enum ProviderCredentialMode {
BYOK
PLATFORM_MANAGED
}
enum OrganizationConnectionStatus {
DRAFT
ACTIVE
DISABLED
}
/// ADR-0024: write-only provider secret material is one authenticated envelope
/// per immutable version. keyId/envelopeVersion are redacted rotation metadata;
/// all provider URL/token/API-key fields remain inside envelope ciphertext.
model ProviderCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationProviderConnection @relation("providerCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationProviderConnection? @relation("activeProviderCredentialVersion")
createdBy User? @relation("providerCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
@@ -501,6 +563,7 @@ model AuditEntry {
id String @id @default(cuid())
runId String?
projectId String?
organizationId String?
actorUserId String?
action String
metadata Json
@@ -508,9 +571,11 @@ model AuditEntry {
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
project Project? @relation("projectAudit", fields: [projectId], references: [id], onDelete: SetNull)
organization Organization? @relation("organizationAudit", fields: [organizationId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([projectId, createdAt])
@@index([organizationId, createdAt])
@@index([actorUserId])
@@index([createdAt])
}
+6
View File
@@ -23,6 +23,12 @@ export async function handleRouteError(reply: FastifyReply, err: unknown): Promi
function mapDomainError(message: string): { statusCode: number; code: string; message: string } | null {
const lower = message.toLowerCase();
if (lower.includes("provider credential readiness check could not reach provider")) {
return { statusCode: 502, code: "provider_unavailable", message };
}
if (lower.includes("provider credential readiness check failed")) {
return { statusCode: 400, code: "provider_credential_rejected", message };
}
if (lower.includes("not found")) {
return { statusCode: 404, code: "not_found", message };
}
+8
View File
@@ -6,6 +6,8 @@ import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { registerAuthRoutes } from "./routes/authRoutes.js";
import { registerOrgRoutes } from "./routes/orgRoutes.js";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../connections/providerReadiness.js";
export interface AdminPluginConfig {
readonly prisma: PrismaClient;
@@ -14,6 +16,8 @@ export interface AdminPluginConfig {
readonly feishuAppId: string;
readonly feishuAppSecret: string;
readonly projectWorkspaceRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
readonly cookieSecure?: boolean;
readonly oauthScope?: string;
readonly fetchImpl?: typeof fetch;
@@ -43,5 +47,9 @@ export async function registerAdminPlugin(
prisma: config.prisma,
sessionSecret: config.sessionSecret,
projectWorkspaceRoot: config.projectWorkspaceRoot,
secretEnvelope: config.secretEnvelope,
...(config.providerReadinessProbe !== undefined
? { providerReadinessProbe: config.providerReadinessProbe }
: {}),
});
}
+13
View File
@@ -14,11 +14,16 @@ import { registerExplorerRoutes } from "./explorerRoutes.js";
import { registerMembersRoutes } from "./membersRoutes.js";
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
import { registerTeamsRoutes } from "./teamsRoutes.js";
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
export interface OrgRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly projectWorkspaceRoot: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
}
export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteConfig): Promise<void> {
@@ -101,4 +106,12 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
prisma: config.prisma,
sessionSecret: config.sessionSecret,
});
await registerProviderConnectionRoutes(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
secretEnvelope: config.secretEnvelope,
...(config.providerReadinessProbe !== undefined
? { providerReadinessProbe: config.providerReadinessProbe }
: {}),
});
}
@@ -0,0 +1,122 @@
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
import { ProviderConnectionService } from "../../connections/providerConnections.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
import { ProviderReadinessError, type ProviderReadinessProbe } from "../../connections/providerReadiness.js";
export interface ProviderConnectionRouteConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
readonly secretEnvelope: LocalSecretEnvelope;
readonly providerReadinessProbe?: ProviderReadinessProbe;
}
export async function registerProviderConnectionRoutes(
app: FastifyInstance,
config: ProviderConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new ProviderConnectionService(
config.prisma,
config.secretEnvelope,
config.providerReadinessProbe,
);
app.get("/api/org/:orgSlug/provider-connections", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
return {
connections: await connections.list(auth.organization.id),
};
} catch (error) {
request.log.error({
requestId: request.id,
operation: "provider_connection.list",
...providerFailureFacts(error),
}, "provider connection read failed");
return handleRouteError(reply, error);
}
});
app.put("/api/org/:orgSlug/provider-connections/:providerId", async (request, reply) => {
try {
const { orgSlug, providerId } = request.params as { orgSlug: string; providerId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseCredentialBody(request.body);
const result = await connections.rotateByok({
organizationId: auth.organization.id,
providerId,
actorUserId: auth.user.id,
...body,
});
request.log.info({
organizationId: auth.organization.id,
connectionId: result.id,
providerId: result.providerId,
mode: result.mode,
status: result.status,
secretVersion: result.activeVersion,
keyId: result.keyId,
}, result.created ? "provider connection created" : "provider connection rotated");
return reply.status(result.created ? 201 : 200).send(withoutWriteResult(result));
} catch (error) {
request.log.error({
requestId: request.id,
operation: "provider_connection.rotate_byok",
...providerFailureFacts(error),
}, "provider connection mutation failed");
return handleRouteError(reply, error);
}
});
}
function providerFailureFacts(error: unknown): {
readonly errorCode: string;
readonly failureCategory: string;
readonly upstreamStatus?: number;
} {
if (error instanceof ProviderReadinessError) {
return {
errorCode: error.code,
failureCategory: error.category,
...(error.upstreamStatus !== undefined ? { upstreamStatus: error.upstreamStatus } : {}),
};
}
return {
errorCode: "provider_connection_operation_failed",
failureCategory: error instanceof Error ? error.name : typeof error,
};
}
function parseCredentialBody(value: unknown): {
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey?: string;
} {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid provider credential body");
}
const body = value as Record<string, unknown>;
if (typeof body["baseUrl"] !== "string" || typeof body["authToken"] !== "string") {
throw new Error("provider baseUrl and authToken are required");
}
const anthropicApiKey = body["anthropicApiKey"];
if (anthropicApiKey !== undefined && typeof anthropicApiKey !== "string") {
throw new Error("invalid anthropicApiKey");
}
return {
baseUrl: body["baseUrl"],
authToken: body["authToken"],
...(anthropicApiKey !== undefined ? { anthropicApiKey } : {}),
};
}
function withoutWriteResult<T extends { readonly created: boolean }>(result: T): Omit<T, "created"> {
const { created: _created, ...metadata } = result;
return metadata;
}
+3 -2
View File
@@ -56,7 +56,8 @@ export interface RunRequest {
readonly model: string | undefined;
readonly project: ProjectContext;
readonly systemPrompt: string | undefined;
readonly providerEnv?: Record<string, string | undefined> | undefined;
/** Run-scoped loopback proxy capability; never an Organization provider credential. */
readonly providerProxyEnv?: Record<string, string | undefined> | undefined;
readonly resumeSessionId?: string | undefined;
/**
* RoleEntry.tools from admin/runtime settings. Values are Hub role tool ids
@@ -125,7 +126,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const security = await createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: req.project.workspaceDir,
providerEnv: req.providerEnv,
providerProxyEnv: req.providerProxyEnv,
});
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
+3 -2
View File
@@ -29,7 +29,8 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
export interface AgentSecurityInput {
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
}
@@ -103,7 +104,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.DO_NOT_TRACK = "1";
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
for (const [name, value] of Object.entries(input.providerProxyEnv ?? {})) {
if (!PROVIDER_ENV_KEYS.has(name)) {
throw new Error(`unsupported provider environment variable: ${name}`);
}
+39
View File
@@ -0,0 +1,39 @@
export type NetworkFailureCategory =
| "timeout"
| "dns"
| "tls"
| "connection"
| "aborted"
| "network";
/** Classify transport failures without retaining messages that may contain a URL. */
export function classifyNetworkFailure(error: unknown): NetworkFailureCategory {
const chain = errorChain(error);
const names = chain.map((candidate) => candidate.name);
const codes = chain.flatMap((candidate) =>
typeof candidate.code === "string" ? [candidate.code.toUpperCase()] : []
);
if (names.includes("TimeoutError") || codes.some((code) => code === "ETIMEDOUT")) return "timeout";
if (names.includes("AbortError") || codes.some((code) => code === "ABORT_ERR")) return "aborted";
if (codes.some((code) => code === "ENOTFOUND" || code === "EAI_AGAIN")) return "dns";
if (codes.some((code) => code.includes("TLS") || code.includes("CERT") || code.includes("SSL"))) return "tls";
if (codes.some((code) => ["ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETUNREACH", "EPIPE"].includes(code))) {
return "connection";
}
return "network";
}
function errorChain(error: unknown): Array<{ readonly name: string; readonly code?: unknown }> {
const chain: Array<{ readonly name: string; readonly code?: unknown }> = [];
let candidate = error;
for (let depth = 0; depth < 4; depth += 1) {
if (typeof candidate !== "object" || candidate === null) break;
const record = candidate as { readonly name?: unknown; readonly code?: unknown; readonly cause?: unknown };
chain.push({
name: typeof record.name === "string" ? record.name : "Error",
...(record.code !== undefined ? { code: record.code } : {}),
});
candidate = record.cause;
}
return chain;
}
+423
View File
@@ -0,0 +1,423 @@
import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import {
LocalSecretEnvelope,
type SecretEnvelopeV1,
} from "../security/secretEnvelope.js";
import { probeOpenRouterCredential, type ProviderReadinessProbe } from "./providerReadiness.js";
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export interface ProviderCredentialInput {
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey?: string;
}
export interface RotateByokProviderInput extends ProviderCredentialInput {
readonly organizationId: string;
readonly providerId: string;
readonly actorUserId: string;
}
export interface ProviderConnectionMetadata {
readonly id: string;
readonly providerId: string;
readonly mode: "BYOK" | "PLATFORM_MANAGED";
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly activeVersion: number | null;
readonly keyId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface ProviderConnectionWriteResult extends ProviderConnectionMetadata {
readonly created: boolean;
}
export interface ProviderSecretPayloadV1 {
readonly schemaVersion: 1;
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
}
export class ProviderConnectionService {
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: ProviderReadinessProbe = probeOpenRouterCredential,
) {}
async rotateByok(input: RotateByokProviderInput): Promise<ProviderConnectionWriteResult> {
const payload = validateProviderCredential(input);
await this.prisma.$transaction(async (tx) => {
await requireByokActor(tx, input);
});
await this.readinessProbe({
providerId: input.providerId,
baseUrl: payload.baseUrl,
authToken: payload.authToken,
anthropicApiKey: payload.anthropicApiKey,
});
return this.prisma.$transaction(async (tx) => {
await requireByokActor(tx, input);
const connection = await tx.organizationProviderConnection.upsert({
where: {
organizationId_providerId: {
organizationId: input.organizationId,
providerId: input.providerId,
},
},
update: {},
create: {
id: randomUUID(),
organizationId: input.organizationId,
providerId: input.providerId,
mode: "BYOK",
status: "DRAFT",
},
});
await lockConnection(tx, connection.id);
const locked = await tx.organizationProviderConnection.findUniqueOrThrow({
where: { id: connection.id },
include: {
activeSecretVersion: { select: { id: true } },
secretVersions: {
orderBy: { version: "desc" },
take: 1,
select: { version: true },
},
},
});
if (locked.organizationId !== input.organizationId || locked.providerId !== input.providerId) {
throw new Error("provider connection scope changed while rotating credential");
}
if (locked.mode !== "BYOK") {
throw new Error(
"provider connection is managed by platform administrators and cannot be changed through organization API",
);
}
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
const secretVersionId = randomUUID();
const envelope = this.secrets.encryptJson({
purpose: "provider-connection",
organizationId: input.organizationId,
connectionId: locked.id,
secretVersionId,
}, payload);
const now = new Date();
const secretVersion = await tx.providerCredentialVersion.create({
data: {
id: secretVersionId,
connectionId: locked.id,
version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
envelope: envelope as unknown as Prisma.InputJsonValue,
createdByUserId: input.actorUserId,
},
});
if (locked.activeSecretVersion !== null) {
await tx.providerCredentialVersion.update({
where: { id: locked.activeSecretVersion.id },
data: { retiredAt: now },
});
}
const activated = await tx.organizationProviderConnection.update({
where: { id: locked.id },
data: {
status: "ACTIVE",
activeSecretVersionId: secretVersion.id,
activatedAt: now,
disabledAt: null,
},
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
actorUserId: input.actorUserId,
action: version === 1 ? "provider_connection.created" : "provider_connection.rotated",
metadata: {
connectionId: locked.id,
providerId: input.providerId,
mode: "BYOK",
status: "ACTIVE",
secretVersion: version,
envelopeVersion: envelope.version,
keyId: envelope.keyId,
},
},
});
return {
...metadata(activated, { version, keyId: secretVersion.keyId }),
created: version === 1,
};
});
}
async list(organizationId: string): Promise<readonly ProviderConnectionMetadata[]> {
return this.prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, organizationId);
const connections = await tx.organizationProviderConnection.findMany({
where: { organizationId },
orderBy: [{ providerId: "asc" }, { createdAt: "asc" }],
include: {
activeSecretVersion: {
select: { version: true, keyId: true },
},
},
});
return connections.map((connection) => metadata(connection, connection.activeSecretVersion));
});
}
}
export function decryptStoredProviderCredential(
secrets: LocalSecretEnvelope,
input: {
readonly organizationId: string;
readonly connectionId: string;
readonly providerId: string;
readonly secretVersionId: string;
readonly envelopeVersion: number;
readonly keyId: string;
readonly envelope: Prisma.JsonValue;
},
): ProviderSecretPayloadV1 {
if (input.envelopeVersion !== 1) {
throw new Error(`unsupported provider secret envelope version: ${input.envelopeVersion}`);
}
const envelope = input.envelope as unknown as SecretEnvelopeV1;
if (envelope.keyId !== input.keyId || envelope.version !== input.envelopeVersion) {
throw new Error("provider secret envelope metadata mismatch");
}
const payload = secrets.decryptJson<unknown>({
purpose: "provider-connection",
organizationId: input.organizationId,
connectionId: input.connectionId,
secretVersionId: input.secretVersionId,
}, envelope);
return validateStoredProviderPayload(payload, input.providerId);
}
export async function verifyStoredProviderEnvelopes(
prisma: PrismaClient | Prisma.TransactionClient,
secrets: LocalSecretEnvelope,
): Promise<number> {
const versions = await prisma.providerCredentialVersion.findMany({
select: {
id: true,
envelopeVersion: true,
keyId: true,
envelope: true,
connection: {
select: { id: true, organizationId: true, providerId: true },
},
},
});
for (const version of versions) {
decryptStoredProviderCredential(secrets, {
organizationId: version.connection.organizationId,
connectionId: version.connection.id,
providerId: version.connection.providerId,
secretVersionId: version.id,
envelopeVersion: version.envelopeVersion,
keyId: version.keyId,
envelope: version.envelope,
});
}
return versions.length;
}
export async function rewrapStoredProviderEnvelopes(
prisma: Prisma.TransactionClient,
secrets: LocalSecretEnvelope,
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
const versions = await prisma.providerCredentialVersion.findMany({
select: {
id: true,
envelopeVersion: true,
keyId: true,
envelope: true,
connection: {
select: { id: true, organizationId: true, providerId: true },
},
},
orderBy: { createdAt: "asc" },
});
let rewrapped = 0;
for (const version of versions) {
const binding = {
purpose: "provider-connection",
organizationId: version.connection.organizationId,
connectionId: version.connection.id,
secretVersionId: version.id,
} as const;
decryptStoredProviderCredential(secrets, {
organizationId: version.connection.organizationId,
connectionId: version.connection.id,
providerId: version.connection.providerId,
secretVersionId: version.id,
envelopeVersion: version.envelopeVersion,
keyId: version.keyId,
envelope: version.envelope,
});
if (version.keyId === secrets.activeKeyId) continue;
const envelope = version.envelope as unknown as SecretEnvelopeV1;
const nextEnvelope = secrets.rewrap(binding, envelope);
const updated = await prisma.providerCredentialVersion.updateMany({
where: { id: version.id, keyId: version.keyId },
data: {
keyId: nextEnvelope.keyId,
envelope: nextEnvelope as unknown as Prisma.InputJsonValue,
},
});
if (updated.count !== 1) {
throw new Error(`provider secret envelope changed during KEK rotation: ${version.id}`);
}
await prisma.auditEntry.create({
data: {
organizationId: version.connection.organizationId,
action: "provider_secret.kek_rewrapped",
metadata: {
connectionId: version.connection.id,
providerId: version.connection.providerId,
secretVersionId: version.id,
previousKeyId: version.keyId,
keyId: nextEnvelope.keyId,
},
},
});
rewrapped += 1;
}
const verified = await verifyStoredProviderEnvelopes(prisma, secrets);
const remaining = await prisma.providerCredentialVersion.count({
where: { keyId: { not: secrets.activeKeyId } },
});
if (remaining !== 0) {
throw new Error(`KEK rotation left ${remaining} provider envelope(s) on a non-active key`);
}
return { verified, rewrapped };
}
function validateProviderCredential(input: RotateByokProviderInput): ProviderSecretPayloadV1 {
if (!PROVIDER_ID_PATTERN.test(input.providerId)) {
throw new Error("invalid provider connection id");
}
let baseUrl: URL;
try {
baseUrl = new URL(input.baseUrl);
} catch (error) {
throw new Error("invalid provider base URL", { cause: error });
}
if (baseUrl.protocol !== "https:") {
throw new Error("invalid provider base URL: HTTPS is required");
}
const authToken = input.authToken.trim();
if (authToken === "") {
throw new Error("provider auth token is required");
}
return {
schemaVersion: 1,
baseUrl: baseUrl.toString().replace(/\/$/, ""),
authToken,
anthropicApiKey: input.anthropicApiKey?.trim() ?? "",
};
}
function metadata(
connection: {
readonly id: string;
readonly providerId: string;
readonly mode: "BYOK" | "PLATFORM_MANAGED";
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
readonly createdAt: Date;
readonly updatedAt: Date;
},
secret: { readonly version: number; readonly keyId: string } | null,
): ProviderConnectionMetadata {
return {
id: connection.id,
providerId: connection.providerId,
mode: connection.mode,
status: connection.status,
activeVersion: secret?.version ?? null,
keyId: secret?.keyId ?? null,
createdAt: connection.createdAt,
updatedAt: connection.updatedAt,
};
}
function validateStoredProviderPayload(value: unknown, providerId: string): ProviderSecretPayloadV1 {
if (!isRecord(value) || value["schemaVersion"] !== 1 || typeof value["baseUrl"] !== "string" ||
typeof value["authToken"] !== "string" || typeof value["anthropicApiKey"] !== "string" ||
value["authToken"].trim() === "") {
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
}
let baseUrl: URL;
try {
baseUrl = new URL(value["baseUrl"]);
} catch (error) {
throw new Error(`invalid encrypted provider credential payload: ${providerId}`, { cause: error });
}
if (baseUrl.protocol !== "https:") {
throw new Error(`invalid encrypted provider credential payload: ${providerId}`);
}
return {
schemaVersion: 1,
baseUrl: value["baseUrl"],
authToken: value["authToken"],
anthropicApiKey: value["anthropicApiKey"],
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function lockConnection(tx: Prisma.TransactionClient, connectionId: string): Promise<void> {
await tx.$queryRaw`SELECT "id" FROM "OrganizationProviderConnection" WHERE "id" = ${connectionId} FOR UPDATE`;
}
async function requireByokActor(
tx: Prisma.TransactionClient,
input: Pick<RotateByokProviderInput, "organizationId" | "providerId" | "actorUserId">,
): Promise<void> {
await lockActiveOrganization(tx, input.organizationId);
const [authorizedActor, existingConnection] = await Promise.all([
tx.organizationMembership.findFirst({
where: {
organizationId: input.organizationId,
userId: input.actorUserId,
role: { in: ["OWNER", "ADMIN"] },
revokedAt: null,
},
select: { id: true },
}),
tx.organizationProviderConnection.findUnique({
where: {
organizationId_providerId: {
organizationId: input.organizationId,
providerId: input.providerId,
},
},
select: { mode: true },
}),
]);
if (authorizedActor === null) {
throw new Error("BYOK provider rotation requires an active Organization OWNER or ADMIN");
}
if (existingConnection?.mode === "PLATFORM_MANAGED") {
throw new Error(
"provider connection is managed by platform administrators and cannot be changed through organization API",
);
}
}
+225
View File
@@ -0,0 +1,225 @@
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
import { once } from "node:events";
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
const MAX_PROVIDER_REQUEST_BYTES = 32 * 1024 * 1024;
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const REPLACED_REQUEST_HEADERS = new Set([
...HOP_BY_HOP_HEADERS,
"authorization",
"content-length",
"host",
"x-api-key",
]);
export interface ProviderUpstreamCredential {
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
}
export interface AgentProviderLease {
readonly sdkEnv: Readonly<Record<string, string | undefined>>;
readonly sensitiveValues: readonly string[];
close(): Promise<void>;
}
export interface ProviderProxyDiagnostic {
readonly code:
| "provider_proxy_unauthorized"
| "provider_proxy_request_too_large"
| "provider_proxy_redirect_refused"
| "provider_proxy_upstream_failed";
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
}
export interface ProviderProxyOptions {
readonly onDiagnostic?: (diagnostic: ProviderProxyDiagnostic) => void;
}
/**
* Starts one loopback-only proxy for one Agent run. The Agent receives only a
* random run capability; customer provider credentials stay in this Hub
* closure and are attached immediately before the upstream request.
*/
export async function openProviderProxyLease(
upstream: ProviderUpstreamCredential,
options: ProviderProxyOptions = {},
): Promise<AgentProviderLease> {
const capability = randomBytes(32).toString("base64url");
const handler = (request: IncomingMessage, response: ServerResponse): void => {
void forwardProviderRequest(request, response, capability, upstream, options);
};
const server = createServer(handler);
server.requestTimeout = 5 * 60_000;
server.headersTimeout = 30_000;
server.maxRequestsPerSocket = 1_000;
server.listen(0, "127.0.0.1");
await Promise.race([
once(server, "listening"),
once(server, "error").then(([error]) => Promise.reject(error)),
]);
server.unref();
const address = server.address();
if (address === null || typeof address === "string") {
server.close();
throw new Error("provider proxy did not bind a TCP address");
}
let closed = false;
return {
sdkEnv: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
ANTHROPIC_AUTH_TOKEN: capability,
ANTHROPIC_API_KEY: "",
},
sensitiveValues: [capability],
async close(): Promise<void> {
if (closed) return;
closed = true;
server.closeAllConnections();
if (!server.listening) return;
server.close();
await once(server, "close");
},
};
}
async function forwardProviderRequest(
request: IncomingMessage,
response: ServerResponse,
capability: string,
upstream: ProviderUpstreamCredential,
options: ProviderProxyOptions,
): Promise<void> {
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;
}
try {
const requestUrl = new URL(request.url ?? "/", "http://provider-proxy.invalid");
const upstreamBase = upstream.baseUrl.endsWith("/") ? upstream.baseUrl : `${upstream.baseUrl}/`;
const upstreamUrl = new URL(`${requestUrl.pathname.replace(/^\//, "")}${requestUrl.search}`, upstreamBase);
const body = await readBoundedBody(request);
const headers = forwardedRequestHeaders(request.headers);
headers.set("authorization", `Bearer ${upstream.authToken}`);
if (upstream.anthropicApiKey !== "") {
headers.set("x-api-key", upstream.anthropicApiKey);
}
headers.set("accept-encoding", "identity");
const abort = new AbortController();
request.once("aborted", () => abort.abort());
const upstreamResponse = await fetch(upstreamUrl, {
method: request.method ?? "GET",
headers,
...(body.byteLength === 0 ? {} : { body }),
redirect: "manual",
signal: abort.signal,
});
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
options.onDiagnostic?.({ code: "provider_proxy_redirect_refused", category: "redirect" });
await upstreamResponse.body?.cancel();
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
response.end("provider redirect refused");
return;
}
response.writeHead(upstreamResponse.status, forwardedResponseHeaders(upstreamResponse.headers));
if (upstreamResponse.body === null) {
response.end();
return;
}
for await (const chunk of upstreamResponse.body) {
if (!response.write(Buffer.from(chunk))) {
await once(response, "drain");
}
}
response.end();
} catch (error) {
const diagnostic = error instanceof ProviderProxyRequestError
? { code: error.code, category: error.category } as const
: {
code: "provider_proxy_upstream_failed",
category: classifyNetworkFailure(error),
} as const;
options.onDiagnostic?.(diagnostic);
if (!response.headersSent) {
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
response.end("provider request failed");
} else {
response.destroy();
}
}
}
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 forwardedRequestHeaders(source: IncomingHttpHeaders): Headers {
const result = new Headers();
for (const [name, value] of Object.entries(source)) {
if (value === undefined || REPLACED_REQUEST_HEADERS.has(name.toLowerCase())) continue;
if (Array.isArray(value)) {
for (const item of value) result.append(name, item);
} else {
result.set(name, value);
}
}
return result;
}
function forwardedResponseHeaders(source: Headers): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of source.entries()) {
const normalized = name.toLowerCase();
if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "content-length" ||
normalized === "content-encoding") continue;
result[name] = value;
}
return result;
}
async function readBoundedBody(request: IncomingMessage): Promise<Buffer> {
const declared = Number(request.headers["content-length"] ?? 0);
if (Number.isFinite(declared) && declared > MAX_PROVIDER_REQUEST_BYTES) {
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
}
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of request) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
bytes += buffer.byteLength;
if (bytes > MAX_PROVIDER_REQUEST_BYTES) {
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
}
chunks.push(buffer);
}
return Buffer.concat(chunks, bytes);
}
class ProviderProxyRequestError extends Error {
constructor(
readonly code: "provider_proxy_request_too_large",
readonly category: "request_limit",
) {
super(code);
this.name = "ProviderProxyRequestError";
}
}
+65
View File
@@ -0,0 +1,65 @@
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
export interface ProviderReadinessInput {
readonly providerId: string;
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
}
export type ProviderReadinessProbe = (input: ProviderReadinessInput) => Promise<void>;
export class ProviderReadinessError extends Error {
constructor(
readonly code: "provider_readiness_unsupported" | "provider_readiness_unreachable" | "provider_readiness_rejected",
message: string,
readonly category: NetworkFailureCategory | "configuration" | "http",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "ProviderReadinessError";
}
}
/** Verify the pilot's OpenRouter credential without starting or billing a model run. */
export async function probeOpenRouterCredential(input: ProviderReadinessInput): Promise<void> {
if (input.providerId !== "openrouter") {
throw new ProviderReadinessError(
"provider_readiness_unsupported",
`provider readiness probe is unavailable: ${input.providerId}`,
"configuration",
);
}
const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`;
const url = new URL("v1/key", base);
const headers = new Headers({
authorization: `Bearer ${input.authToken}`,
accept: "application/json",
});
if (input.anthropicApiKey !== "") headers.set("x-api-key", input.anthropicApiKey);
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers,
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
throw new ProviderReadinessError(
"provider_readiness_unreachable",
"provider credential readiness check could not reach provider",
classifyNetworkFailure(error),
);
}
await response.body?.cancel();
if (!response.ok) {
throw new ProviderReadinessError(
"provider_readiness_rejected",
`provider credential readiness check failed: status ${response.status}`,
"http",
response.status,
);
}
}
+61 -3
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { access, readFile, realpath, stat } from "node:fs/promises";
import { access, lstat, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { PrismaClient } from "@prisma/client";
import { parse } from "dotenv";
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "../security/secretEnvelope.js";
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
import {
DeploymentPreflightError,
validateDeploymentPreflight,
@@ -18,6 +20,7 @@ const execFileAsync = promisify(execFile);
interface Options {
readonly envFile: string;
readonly keyringFile: string;
readonly nodeBin: string;
readonly runuserBin: string;
readonly setprivBin: string;
@@ -47,6 +50,7 @@ async function main(): Promise<void> {
const canonicalInput = await canonicalizeInput(rawInput);
const result = validateDeploymentPreflight(canonicalInput);
await validateSecretFile(options.envFile);
const secretEnvelope = await validateKeyringFile(options.keyringFile);
await validateNodeVersion(options.nodeBin);
await validateExecutable(options.runuserBin, "runuser");
await validateExecutable(options.setprivBin, "setpriv");
@@ -56,7 +60,7 @@ async function main(): Promise<void> {
await validateExecutable(options.pgIsReadyBin, "pg_isready");
await validateCph(result.cphBin);
await validatePostgres(options.pgIsReadyBin, result);
await validatePostgresQuery(result.databaseUrl);
await validatePostgresQuery(result.databaseUrl, secretEnvelope);
if (options.serviceUid !== undefined && options.serviceGid !== undefined) {
await validateProvisionedDirectories(canonicalInput, options.serviceUid, options.serviceGid);
@@ -125,6 +129,22 @@ async function validateSecretFile(path: string): Promise<void> {
}
}
async function validateKeyringFile(path: string): Promise<LocalSecretEnvelope> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error(`local secret keyring is not a regular non-symlink file: ${path}`);
}
if ((metadata.mode & 0o077) !== 0) {
throw new Error(`local secret keyring must not be accessible by group or others: ${path}`);
}
const keyring = await loadLocalSecretKeyring({
NODE_ENV: "preflight",
HUB_SECRET_KEYRING_FILE: path,
});
console.log("[preflight] local secret keyring is valid");
return new LocalSecretEnvelope(keyring);
}
async function validateNodeVersion(nodeBin: string): Promise<void> {
let stdout: string;
try {
@@ -189,14 +209,44 @@ async function validatePostgres(pgIsReadyBin: string, result: DeploymentPrefligh
console.log("[preflight] PostgreSQL is accepting connections");
}
async function validatePostgresQuery(databaseUrl: string): Promise<void> {
async function validatePostgresQuery(
databaseUrl: string,
secretEnvelope: LocalSecretEnvelope,
): Promise<void> {
const client = new PrismaClient({
datasources: { db: { url: databaseUrl } },
log: [],
});
let queryFailure: unknown;
let envelopeFailure: unknown;
try {
await client.$queryRawUnsafe("SELECT 1");
const migrationTable = await client.$queryRaw<Array<{ relation: string | null }>>`
SELECT to_regclass('public."_prisma_migrations"')::text AS "relation"
`;
if (migrationTable[0]?.relation === null || migrationTable[0]?.relation === undefined) {
console.log("[preflight] empty database has no migration table or stored provider envelopes");
} else {
const migration = await client.$queryRaw<Array<{ finished_at: Date | null; rolled_back_at: Date | null }>>`
SELECT "finished_at", "rolled_back_at"
FROM "_prisma_migrations"
WHERE "migration_name" = '20260710220000_org_provider_secret_envelopes'
ORDER BY "started_at" DESC
LIMIT 1
`;
const applied = migration[0]?.finished_at !== null && migration[0]?.finished_at !== undefined &&
migration[0]?.rolled_back_at === null;
if (!applied) {
console.log("[preflight] provider envelope migration is pending; no stored envelopes to authenticate");
} else {
try {
const verified = await verifyStoredProviderEnvelopes(client, secretEnvelope);
console.log(`[preflight] authenticated ${verified} stored provider envelope(s)`);
} catch (error) {
envelopeFailure = error;
}
}
}
} catch (error) {
queryFailure = error;
}
@@ -222,6 +272,12 @@ async function validatePostgresQuery(databaseUrl: string): Promise<void> {
{ cause: disconnectFailure },
);
}
if (envelopeFailure !== undefined) {
throw new Error(
`stored provider envelope verification failed (${formatFailure(envelopeFailure)})`,
{ cause: envelopeFailure },
);
}
console.log("[preflight] PostgreSQL authenticated query succeeded");
}
@@ -383,6 +439,7 @@ function parseArgs(argv: readonly string[]): Options {
const allowed = new Set([
"--env-file",
"--keyring-file",
"--node-bin",
"--runuser-bin",
"--setpriv-bin",
@@ -411,6 +468,7 @@ function parseArgs(argv: readonly string[]): Options {
return {
envFile: required(values, "--env-file"),
keyringFile: required(values, "--keyring-file"),
nodeBin: required(values, "--node-bin"),
runuserBin: required(values, "--runuser-bin"),
setprivBin: required(values, "--setpriv-bin"),
+4 -20
View File
@@ -83,12 +83,11 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
problems.push("DATABASE_URL must be a valid postgresql:// or postgres:// URL");
}
const providerBaseUrl = readRequired(input.env, "ANTHROPIC_BASE_URL", problems);
if (providerBaseUrl !== undefined && !isHttpUrl(providerBaseUrl)) {
problems.push("ANTHROPIC_BASE_URL must be a valid http(s) URL");
for (const name of ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const) {
if (Object.hasOwn(input.env, name)) {
problems.push(`${name} must not be set; configure Organization Provider Connections`);
}
}
readRequired(input.env, "ANTHROPIC_AUTH_TOKEN", problems);
requirePresentAndEmpty(input.env, "ANTHROPIC_API_KEY", problems);
readRequired(input.env, "FEISHU_APP_ID", problems);
readRequired(input.env, "FEISHU_APP_SECRET", problems);
@@ -139,12 +138,6 @@ function readRequired(env: DeploymentEnv, name: string, problems: string[]): str
return value;
}
function requirePresentAndEmpty(env: DeploymentEnv, name: string, problems: string[]): void {
if (!Object.hasOwn(env, name) || env[name] === undefined || env[name]?.trim() !== "") {
problems.push(`${name} must be present and empty`);
}
}
function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
const raw = env[name]?.trim();
if (raw === undefined || raw === "") return;
@@ -184,15 +177,6 @@ function isPostgresUrl(value: string): boolean {
}
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && url.hostname !== "";
} catch {
return false;
}
}
function isHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { PrismaClient } from "@prisma/client";
import { rotateLocalKek } from "../security/localKekRotation.js";
const execFileAsync = promisify(execFile);
const SERVICE_UNIT = "cph-hub.service";
async function main(): Promise<void> {
if (process.getuid?.() !== 0) {
throw new Error("local KEK rotation must run as root");
}
await requireStoppedService();
const keyringFile = parseKeyringFile(process.argv.slice(2));
const databaseUrl = process.env["DATABASE_URL"]?.trim();
if (databaseUrl === undefined || databaseUrl === "") {
throw new Error("DATABASE_URL is required for local KEK rotation");
}
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
try {
const result = await rotateLocalKek({ keyringFile, prisma });
console.log(
`[secret-kek] active=${result.activeKeyId} rewrapped=${result.rewrapped} verified=${result.verified}`,
);
console.log("[secret-kek] back up the updated keyring separately before retiring any previous key");
} finally {
await prisma.$disconnect();
}
}
async function requireStoppedService(): Promise<void> {
let activeState: string;
try {
const { stdout } = await execFileAsync(
"systemctl",
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
{ timeout: 10_000 },
);
activeState = stdout.trim();
} catch {
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`);
}
if (activeState !== "inactive" && activeState !== "failed") {
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
}
}
function parseKeyringFile(argv: readonly string[]): string {
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
}
return argv[1];
}
main().catch((error: unknown) => {
console.error(`[secret-kek] failed: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
+66 -58
View File
@@ -410,63 +410,72 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
// stop the SDK query. Cleared in the finally below.
const abortController = new AbortController();
activeRuns.set(run.id, abortController);
const sdkStderr = createAgentSdkStderrSink({
logger: deps.logger,
runId: run.id,
projectId,
sensitiveValues: Object.values(provider.sdkEnv),
});
runAgent({
prompt: promptForAgent,
model,
project: projectCtx,
systemPrompt,
providerEnv: provider.sdkEnv,
resumeSessionId: sessionMetadata.claudeSessionId,
tools: roleTools,
mcpServers: { cph_hub: fileDeliveryMcpServer },
maxTurns: runPolicy.maxTurns,
runId: run.id,
sessionId: session.id,
prisma: deps.prisma,
abortController,
onSdkStderr: sdkStderr.write,
onStream: (event) => {
switch (event.type) {
case "text-delta":
card.appendText(event.text);
break;
case "thinking-delta":
card.appendReasoning(event.text);
break;
case "tool-start":
card.onToolStart(event.toolName, event.toolUseId);
break;
case "tool-end":
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: event.input,
result: undefined,
error: undefined,
durationMs: event.durationMs,
});
break;
case "tool-result":
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: undefined,
result: event.result,
error: event.isError ? event.result : undefined,
durationMs: event.durationMs,
});
break;
case "finish":
break;
}
},
})
const agentExecution = (async () => {
const providerLease = await provider.openAgentLease({ runId: run.id });
const sdkStderr = createAgentSdkStderrSink({
logger: deps.logger,
runId: run.id,
projectId,
sensitiveValues: providerLease.sensitiveValues,
});
try {
return await runAgent({
prompt: promptForAgent,
model,
project: projectCtx,
systemPrompt,
providerProxyEnv: { ...providerLease.sdkEnv },
resumeSessionId: sessionMetadata.claudeSessionId,
tools: roleTools,
mcpServers: { cph_hub: fileDeliveryMcpServer },
maxTurns: runPolicy.maxTurns,
runId: run.id,
sessionId: session.id,
prisma: deps.prisma,
abortController,
onSdkStderr: sdkStderr.write,
onStream: (event) => {
switch (event.type) {
case "text-delta":
card.appendText(event.text);
break;
case "thinking-delta":
card.appendReasoning(event.text);
break;
case "tool-start":
card.onToolStart(event.toolName, event.toolUseId);
break;
case "tool-end":
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: event.input,
result: undefined,
error: undefined,
durationMs: event.durationMs,
});
break;
case "tool-result":
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: undefined,
result: event.result,
error: event.isError ? event.result : undefined,
durationMs: event.durationMs,
});
break;
case "finish":
break;
}
},
});
} finally {
sdkStderr.flush();
await providerLease.close();
}
})();
agentExecution
.then(async (result) => {
const interrupted = result.status === "interrupted";
const finalText =
@@ -519,7 +528,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
})
.finally(async () => {
sdkStderr.flush();
try {
await releaseLock(deps.prisma, run.id);
} catch (e) {
+24 -3
View File
@@ -5,7 +5,10 @@ import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client
import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
import { triggerQueue } from "./feishu/triggerQueue.js";
import { createEnvRuntimeSettings } from "./settings/runtime.js";
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEnvelope.js";
import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
import { openProviderProxyLease } from "./connections/providerProxy.js";
import { readServerBinding } from "./settings/server.js";
function requireEnv(name: string): string {
@@ -31,8 +34,25 @@ export async function startHub(): Promise<void> {
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
const runtimeSettings = createEnvRuntimeSettings();
await runtimeSettings.provider("openrouter");
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope);
app.log.info({ verified: verifiedProviderEnvelopes }, "startup: authenticated stored provider envelopes");
const runtimeSettings = createDatabaseRuntimeSettings(
prisma,
secretEnvelope,
process.env,
(credential, context) => openProviderProxyLease(credential, {
onDiagnostic: (diagnostic) => {
app.log.error({
runId: context.runId,
projectId: context.projectId,
providerId: context.providerId,
errorCode: diagnostic.code,
failureCategory: diagnostic.category,
}, "provider proxy diagnostic");
},
}),
);
const feishuAppId = requireEnv("FEISHU_APP_ID");
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
@@ -58,6 +78,7 @@ export async function startHub(): Promise<void> {
feishuAppId,
feishuAppSecret,
projectWorkspaceRoot,
secretEnvelope,
});
const address = await app.listen(bind);
+204
View File
@@ -0,0 +1,204 @@
import { randomBytes } from "node:crypto";
import { lstat, open, rename, unlink, type FileHandle } from "node:fs/promises";
import { basename, dirname, isAbsolute, join } from "node:path";
import type { Prisma, PrismaClient } from "@prisma/client";
import { rewrapStoredProviderEnvelopes } from "../connections/providerConnections.js";
import {
LocalSecretEnvelope,
loadLocalSecretKeyringFile,
type LocalSecretKeyring,
} from "./secretEnvelope.js";
export interface RotateLocalKekInput {
readonly keyringFile: string;
readonly prisma: PrismaClient;
readonly now?: Date;
readonly generatedKey?: Buffer;
readonly expectedOwner?: { readonly uid: number; readonly gid: number };
}
export interface RotateLocalKekResult {
readonly previousActiveKeyId: string;
readonly activeKeyId: string;
readonly verified: number;
readonly rewrapped: number;
}
export const LOCAL_KEK_ROTATION_ADVISORY_LOCK = 7_310_579_862_737_525_041n;
/**
* Atomically installs a new active KEK while retaining all previous keys, then
* rewraps database DEKs. A crash after the file rename is recoverable because
* both old and new KEKs remain in the keyring and the operation is rerunnable.
*/
export async function rotateLocalKek(input: RotateLocalKekInput): Promise<RotateLocalKekResult> {
if (!isAbsolute(input.keyringFile)) {
throw new Error("local secret keyring path must be absolute");
}
const expectedOwner = input.expectedOwner ?? { uid: 0, gid: 0 };
const initialMetadata = await lstat(input.keyringFile);
assertKeyringMetadata(initialMetadata, expectedOwner);
return input.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ readonly locked: boolean }>>`
SELECT pg_try_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK}) AS "locked"
`;
if (rows[0]?.locked !== true) {
throw new Error("local KEK rotation lock is already held");
}
return rotateUnderLock(input, expectedOwner, tx);
}, { maxWait: 5_000, timeout: 300_000 });
}
async function rotateUnderLock(
input: RotateLocalKekInput,
expectedOwner: { readonly uid: number; readonly gid: number },
tx: Prisma.TransactionClient,
): Promise<RotateLocalKekResult> {
const metadata = await lstat(input.keyringFile);
assertKeyringMetadata(metadata, expectedOwner);
const previous = await loadLocalSecretKeyringFile(input.keyringFile);
const generatedKey = input.generatedKey === undefined
? randomBytes(32)
: Buffer.from(input.generatedKey);
if (generatedKey.byteLength !== 32) {
generatedKey.fill(0);
throw new Error("generated local KEK must contain exactly 32 bytes");
}
const keyId = nextKeyId(input.now ?? new Date());
if (previous.keys.has(keyId)) {
generatedKey.fill(0);
throw new Error(`generated local KEK identifier already exists: ${keyId}`);
}
const keys = new Map(previous.keys);
keys.set(keyId, generatedKey);
const keyring: LocalSecretKeyring = { activeKeyId: keyId, keys };
try {
await replaceKeyringAtomically(input.keyringFile, keyring, metadata.uid, metadata.gid);
const result = await rewrapStoredProviderEnvelopes(
tx,
new LocalSecretEnvelope(keyring),
);
return {
previousActiveKeyId: previous.activeKeyId,
activeKeyId: keyId,
...result,
};
} finally {
generatedKey.fill(0);
}
}
function assertKeyringMetadata(
metadata: {
readonly mode: number;
readonly uid: number;
readonly gid: number;
isFile(): boolean;
isSymbolicLink(): boolean;
},
expectedOwner: { readonly uid: number; readonly gid: number },
): void {
if (metadata.isSymbolicLink() || !metadata.isFile()) {
throw new Error("local secret keyring must be a regular non-symlink file");
}
if (metadata.uid !== expectedOwner.uid || metadata.gid !== expectedOwner.gid) {
throw new Error(
`local secret keyring must be owned by ${expectedOwner.uid}:${expectedOwner.gid}`,
);
}
if ((metadata.mode & 0o777) !== 0o600) {
throw new Error("local secret keyring must have mode 0600");
}
}
async function replaceKeyringAtomically(
file: string,
keyring: LocalSecretKeyring,
uid: number,
gid: number,
): Promise<void> {
const directory = dirname(file);
const temporary = join(directory, `.${basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
const encodedKeys = Object.fromEntries(
[...keyring.keys].map(([keyId, key]) => [keyId, key.toString("base64")]),
);
const contents = `${JSON.stringify({ version: 1, activeKeyId: keyring.activeKeyId, keys: encodedKeys })}\n`;
let handle: FileHandle | undefined;
try {
handle = await open(temporary, "wx", 0o600);
await handle.chown(uid, gid);
await handle.writeFile(contents, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, file);
await syncDirectory(directory);
} catch (error) {
const cleanupFailures: unknown[] = [];
if (handle !== undefined) {
try {
await handle.close();
} catch (cleanupError) {
cleanupFailures.push(cleanupError);
}
}
try {
await removeIfExists(temporary);
} catch (cleanupError) {
cleanupFailures.push(cleanupError);
}
if (cleanupFailures.length > 0) {
throw new AggregateError(
[error, ...cleanupFailures],
"local keyring replacement and cleanup both failed",
{ cause: error },
);
}
throw error;
}
}
async function syncDirectory(directory: string): Promise<void> {
const handle = await open(directory, "r");
let syncFailure: unknown;
try {
await handle.sync();
} catch (error) {
syncFailure = error;
}
let closeFailure: unknown;
try {
await handle.close();
} catch (error) {
closeFailure = error;
}
if (syncFailure !== undefined && closeFailure !== undefined) {
throw new AggregateError(
[syncFailure, closeFailure],
"keyring directory sync and handle close both failed",
{ cause: syncFailure },
);
}
if (syncFailure !== undefined) throw syncFailure;
if (closeFailure !== undefined) throw closeFailure;
}
async function removeIfExists(file: string): Promise<void> {
try {
await unlink(file);
} catch (error) {
if (isErrno(error, "ENOENT")) return;
throw error;
}
}
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === code;
}
function nextKeyId(now: Date): string {
const timestamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
return `local-${timestamp}-${randomBytes(4).toString("hex")}`;
}
+268
View File
@@ -0,0 +1,268 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
const KEY_BYTES = 32;
const NONCE_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const PRODUCTION_CREDENTIAL_NAME = "cph-secret-keyring";
export interface SecretBinding {
readonly purpose: string;
readonly organizationId: string;
readonly connectionId: string;
readonly secretVersionId: string;
}
export interface EncryptedPart {
readonly nonce: string;
readonly ciphertext: string;
readonly authTag: string;
}
export interface SecretEnvelopeV1 {
readonly version: 1;
readonly algorithm: "AES-256-GCM";
readonly keyId: string;
readonly wrappedDek: EncryptedPart;
readonly payload: EncryptedPart;
}
export interface LocalSecretKeyring {
readonly activeKeyId: string;
readonly keys: ReadonlyMap<string, Buffer>;
}
export type SecretKeyringEnv = Readonly<Record<string, string | undefined>>;
/**
* Loads the one local KEK source accepted by the current runtime. Production
* deliberately has no file-path fallback: systemd must materialize the fixed
* credential below CREDENTIALS_DIRECTORY.
*/
export async function loadLocalSecretKeyring(
env: SecretKeyringEnv = process.env,
): Promise<LocalSecretKeyring> {
const nodeEnv = env["NODE_ENV"]?.trim();
const configuredFile = env["HUB_SECRET_KEYRING_FILE"]?.trim();
let file: string;
if (nodeEnv === "production") {
if (configuredFile !== undefined && configuredFile !== "") {
throw new Error("production secret keyring must use the systemd credential");
}
const credentialsDirectory = env["CREDENTIALS_DIRECTORY"]?.trim();
if (credentialsDirectory === undefined || credentialsDirectory === "") {
throw new Error("missing required secret keyring setting: CREDENTIALS_DIRECTORY");
}
file = path.join(credentialsDirectory, PRODUCTION_CREDENTIAL_NAME);
} else {
if (configuredFile === undefined || configuredFile === "") {
throw new Error("missing required secret keyring setting: HUB_SECRET_KEYRING_FILE");
}
file = configuredFile;
}
return loadLocalSecretKeyringFile(file);
}
export async function loadLocalSecretKeyringFile(file: string): Promise<LocalSecretKeyring> {
let source: string;
try {
source = await readFile(file, "utf8");
} catch (error) {
throw new Error("local secret keyring is unavailable", { cause: error });
}
return parseKeyring(source);
}
export class LocalSecretEnvelope {
constructor(private readonly keyring: LocalSecretKeyring) {}
get activeKeyId(): string {
return this.keyring.activeKeyId;
}
encryptJson(binding: SecretBinding, value: unknown): SecretEnvelopeV1 {
validateBinding(binding);
const kek = requireKey(this.keyring, this.keyring.activeKeyId);
const dek = randomBytes(KEY_BYTES);
const payloadBytes = Buffer.from(JSON.stringify(value), "utf8");
try {
return {
version: 1,
algorithm: "AES-256-GCM",
keyId: this.keyring.activeKeyId,
wrappedDek: encryptPart(kek, dek, additionalData(binding, "dek")),
payload: encryptPart(dek, payloadBytes, additionalData(binding, "payload")),
};
} finally {
dek.fill(0);
payloadBytes.fill(0);
}
}
decryptJson<T = unknown>(binding: SecretBinding, envelope: SecretEnvelopeV1): T {
validateBinding(binding);
validateEnvelope(envelope);
const kek = requireKey(this.keyring, envelope.keyId);
const dek = decryptPart(kek, envelope.wrappedDek, additionalData(binding, "dek"));
try {
const plaintext = decryptPart(dek, envelope.payload, additionalData(binding, "payload"));
try {
return JSON.parse(plaintext.toString("utf8")) as T;
} catch (error) {
throw new Error("secret envelope payload is invalid", { cause: error });
} finally {
plaintext.fill(0);
}
} finally {
dek.fill(0);
}
}
rewrap(binding: SecretBinding, envelope: SecretEnvelopeV1): SecretEnvelopeV1 {
validateBinding(binding);
validateEnvelope(envelope);
const previousKek = requireKey(this.keyring, envelope.keyId);
const activeKek = requireKey(this.keyring, this.keyring.activeKeyId);
const dek = decryptPart(previousKek, envelope.wrappedDek, additionalData(binding, "dek"));
try {
return {
...envelope,
keyId: this.keyring.activeKeyId,
wrappedDek: encryptPart(activeKek, dek, additionalData(binding, "dek")),
};
} finally {
dek.fill(0);
}
}
}
function parseKeyring(source: string): LocalSecretKeyring {
let value: unknown;
try {
value = JSON.parse(source) as unknown;
} catch (error) {
throw new Error("invalid local secret keyring", { cause: error });
}
if (!isRecord(value) || value["version"] !== 1 || !isNonEmptyString(value["activeKeyId"]) ||
!isRecord(value["keys"])) {
throw new Error("invalid local secret keyring");
}
const keys = new Map<string, Buffer>();
for (const [keyId, encodedKey] of Object.entries(value["keys"])) {
if (!isNonEmptyString(keyId) || !isNonEmptyString(encodedKey)) {
throw new Error("invalid local secret keyring");
}
const key = decodeBase64(encodedKey, "invalid local secret keyring");
if (key.byteLength !== KEY_BYTES) {
throw new Error("invalid local secret keyring");
}
keys.set(keyId, key);
}
const activeKeyId = value["activeKeyId"];
if (!keys.has(activeKeyId)) {
throw new Error("local secret keyring active key is unavailable");
}
return { activeKeyId, keys };
}
function encryptPart(key: Buffer, plaintext: Buffer, aad: Buffer): EncryptedPart {
const nonce = randomBytes(NONCE_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
cipher.setAAD(aad);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
nonce: nonce.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
}
function decryptPart(key: Buffer, encrypted: EncryptedPart, aad: Buffer): Buffer {
try {
const nonce = decodeBase64(encrypted.nonce, "secret envelope is malformed");
const ciphertext = decodeBase64(encrypted.ciphertext, "secret envelope is malformed");
const authTag = decodeBase64(encrypted.authTag, "secret envelope is malformed");
if (nonce.byteLength !== NONCE_BYTES || authTag.byteLength !== AUTH_TAG_BYTES) {
throw new Error("secret envelope is malformed");
}
const decipher = createDecipheriv("aes-256-gcm", key, nonce, { authTagLength: AUTH_TAG_BYTES });
decipher.setAAD(aad);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch (error) {
if (error instanceof Error && error.message === "secret envelope is malformed") {
throw error;
}
throw new Error("secret envelope authentication failed", { cause: error });
}
}
function additionalData(binding: SecretBinding, layer: "dek" | "payload"): Buffer {
return Buffer.from(JSON.stringify([
"cph-secret-envelope",
1,
layer,
binding.purpose,
binding.organizationId,
binding.connectionId,
binding.secretVersionId,
]), "utf8");
}
function requireKey(keyring: LocalSecretKeyring, keyId: string): Buffer {
const key = keyring.keys.get(keyId);
if (key === undefined) {
throw new Error("secret envelope key is unavailable");
}
return key;
}
function validateBinding(binding: SecretBinding): void {
if (!isNonEmptyString(binding.purpose) || !isNonEmptyString(binding.organizationId) ||
!isNonEmptyString(binding.connectionId) || !isNonEmptyString(binding.secretVersionId)) {
throw new Error("secret envelope binding is invalid");
}
}
function validateEnvelope(envelope: SecretEnvelopeV1): void {
if (!isRecord(envelope) || envelope["version"] !== 1 || envelope["algorithm"] !== "AES-256-GCM" ||
!isNonEmptyString(envelope["keyId"]) || !isEncryptedPart(envelope["wrappedDek"]) ||
!isEncryptedPart(envelope["payload"])) {
throw new Error("secret envelope is malformed");
}
}
function isEncryptedPart(value: unknown): value is EncryptedPart {
return isRecord(value) && isNonEmptyString(value["nonce"]) &&
isNonEmptyString(value["ciphertext"]) && isNonEmptyString(value["authTag"]);
}
function decodeBase64(value: string, errorMessage: string): Buffer {
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error(errorMessage);
}
const decoded = Buffer.from(value, "base64");
if (decoded.toString("base64") !== value) {
throw new Error(errorMessage);
}
return decoded;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim() !== "";
}
+4 -2
View File
@@ -10,8 +10,10 @@
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
*
* Env (see .env.example):
* DATABASE_URL, ANTHROPIC_AUTH_TOKEN, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL
* DATABASE_URL, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL.
* Provider credentials are org-scoped encrypted records; production receives
* the local master-key keyring only through systemd CREDENTIALS_DIRECTORY.
*/
import "dotenv/config";
import { startHub } from "./hub.js";
+138 -32
View File
@@ -1,22 +1,37 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
import { lockActiveOrganization } from "../org/status.js";
import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
export type Env = Readonly<Record<string, string | undefined>>;
type EnvSource = Env | (() => Env);
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25;
export interface ProviderRuntimeSettings {
readonly id: string;
readonly baseUrl: string;
readonly authToken: string;
readonly anthropicApiKey: string;
readonly sdkEnv: Record<string, string | undefined>;
openAgentLease(input: { readonly runId: string }): Promise<AgentProviderLease>;
}
export interface ProviderLeaseContext {
readonly providerId: string;
readonly projectId: string;
readonly runId: string;
}
export type ProviderLeaseFactory = (
credential: ProviderUpstreamCredential,
context: ProviderLeaseContext,
) => Promise<AgentProviderLease>;
const DEFAULT_PROVIDER_LEASE_FACTORY: ProviderLeaseFactory = (credential) =>
openProviderProxyLease(credential);
export interface RuntimeScope {
readonly projectId?: string;
}
@@ -44,26 +59,11 @@ export class EnvRuntimeSettings implements RuntimeSettings {
}
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
void providerId;
void scope;
if (providerId !== "openrouter") {
throw new Error(`unknown provider runtime settings: ${providerId}`);
}
const env = this.envSource();
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
return {
id: providerId,
baseUrl,
authToken,
anthropicApiKey,
sdkEnv: {
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: authToken,
ANTHROPIC_API_KEY: anthropicApiKey,
},
};
throw new Error(
"process-global provider credentials are disabled; use the project-scoped provider resolver",
);
}
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
@@ -79,10 +79,124 @@ export class EnvRuntimeSettings implements RuntimeSettings {
}
}
export class DatabaseRuntimeSettings implements RuntimeSettings {
private readonly envSettings: EnvRuntimeSettings;
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
env: EnvSource = process.env,
private readonly leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
) {
this.envSettings = new EnvRuntimeSettings(env);
}
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
const projectId = scope?.projectId?.trim();
if (projectId === undefined || projectId === "") {
throw new Error("projectId is required to resolve provider credentials");
}
await this.prisma.$transaction(async (tx) => {
await loadActiveProviderSecret(tx, projectId, providerId);
});
return {
id: providerId,
openAgentLease: async ({ runId }) => this.prisma.$transaction(async (tx) => {
const resolved = await loadActiveProviderSecret(tx, projectId, providerId);
const secretVersion = resolved.connection.activeSecretVersion;
const credential = decryptStoredProviderCredential(this.secrets, {
organizationId: resolved.organizationId,
connectionId: resolved.connection.id,
providerId,
secretVersionId: secretVersion.id,
envelopeVersion: secretVersion.envelopeVersion,
keyId: secretVersion.keyId,
envelope: secretVersion.envelope,
});
return this.leaseFactory(
{
baseUrl: credential.baseUrl,
authToken: credential.authToken,
anthropicApiKey: credential.anthropicApiKey,
},
{ providerId, projectId, runId },
);
}),
};
}
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
return this.envSettings.modelRegistry(scope);
}
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
return this.envSettings.runPolicy(input);
}
}
async function loadActiveProviderSecret(
tx: Prisma.TransactionClient,
projectId: string,
providerId: string,
): Promise<{
readonly organizationId: string;
readonly connection: {
readonly id: string;
readonly activeSecretVersion: {
readonly id: string;
readonly connectionId: string;
readonly envelopeVersion: number;
readonly keyId: string;
readonly envelope: Prisma.JsonValue;
readonly retiredAt: Date | null;
};
};
}> {
const project = await tx.project.findUnique({
where: { id: projectId },
select: { organizationId: true, archivedAt: true },
});
if (project === null || project.archivedAt !== null) {
throw new Error(`active project not found: ${projectId}`);
}
await lockActiveOrganization(tx, project.organizationId);
const connection = await tx.organizationProviderConnection.findUnique({
where: {
organizationId_providerId: {
organizationId: project.organizationId,
providerId,
},
},
include: { activeSecretVersion: true },
});
const activeSecretVersion = connection?.activeSecretVersion;
if (connection === null || connection.status !== "ACTIVE" ||
activeSecretVersion === null || activeSecretVersion === undefined || activeSecretVersion.retiredAt !== null ||
activeSecretVersion.connectionId !== connection.id) {
throw new Error(`active provider connection not found: ${providerId} for project ${projectId}`);
}
return {
organizationId: project.organizationId,
connection: { id: connection.id, activeSecretVersion },
};
}
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
return new EnvRuntimeSettings(env);
}
export function createDatabaseRuntimeSettings(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
env: EnvSource = process.env,
leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
): RuntimeSettings {
return new DatabaseRuntimeSettings(prisma, secrets, env, leaseFactory);
}
export function defaultSonnetModel(env: Env = process.env): string {
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
}
@@ -109,14 +223,6 @@ function readEnv(env: Env, name: string): string | undefined {
return value === undefined || value === "" ? undefined : value;
}
function requireSetting(env: Env, name: string): string {
const value = readEnv(env, name);
if (value === undefined) {
throw new Error(`missing required runtime setting: ${name}`);
}
return value;
}
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
const raw = readEnv(env, name);
if (raw === undefined) return fallback;
+2 -1
View File
@@ -9,7 +9,7 @@ import {
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { OAUTH_STATE_COOKIE_NAME, signOAuthState } from "../../src/admin/auth/session.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
const PUBLIC_BASE = "http://127.0.0.1:8788";
@@ -23,6 +23,7 @@ async function buildApp(fetchImpl?: typeof fetch) {
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
secretEnvelope: testSecretEnvelope,
cookieSecure: false,
...(fetchImpl !== undefined ? { fetchImpl } : {}),
});
+2 -1
View File
@@ -18,7 +18,7 @@ import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
const workspaceRoots: string[] = [];
@@ -32,6 +32,7 @@ async function buildApp(workspaceRoot: string) {
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: workspaceRoot,
secretEnvelope: testSecretEnvelope,
cookieSecure: false,
});
await app.ready();
@@ -8,7 +8,7 @@ import {
mintSessionToken,
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
import { addOrgMember } from "../../src/org/members.js";
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
@@ -23,6 +23,7 @@ async function buildApp() {
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-ws",
secretEnvelope: testSecretEnvelope,
cookieSecure: false,
});
await app.ready();
@@ -0,0 +1,214 @@
import Fastify from "fastify";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
import { mintSessionToken, sessionCookieHeader } from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
const SESSION_SECRET = "integration-test-session-secret";
const secrets = testSecretEnvelope;
async function buildApp() {
const app = Fastify({ logger: false });
await registerAdminPlugin(app, {
prisma,
sessionSecret: SESSION_SECRET,
publicBaseUrl: "http://127.0.0.1:8788",
feishuAppId: "cli_test",
feishuAppSecret: "secret_test",
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
secretEnvelope: secrets,
providerReadinessProbe: async () => {},
cookieSecure: false,
});
await app.ready();
return app;
}
async function seedUser(role: "OWNER" | "ADMIN" | "MEMBER"): Promise<string> {
const id = `u-${role.toLowerCase()}`;
const openId = `ou_${role.toLowerCase()}`;
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: id, role },
});
return mintSessionToken({ userId: id, feishuOpenId: openId }, SESSION_SECRET);
}
describe("Organization BYOK provider connections", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("stores write-only credentials in an encrypted immutable version", async () => {
const token = await seedUser("OWNER");
const app = await buildApp();
const credential = {
baseUrl: "https://provider.example/api",
authToken: "org-a-auth-token-never-return",
anthropicApiKey: "org-a-api-key-never-return",
};
try {
const response = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connections/openrouter",
headers: {
cookie: sessionCookieHeader(token),
"content-type": "application/json",
},
payload: credential,
});
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
providerId: "openrouter",
mode: "BYOK",
status: "ACTIVE",
activeVersion: 1,
keyId: "test-active",
});
expect(response.body).not.toContain(credential.authToken);
expect(response.body).not.toContain(credential.anthropicApiKey);
expect(response.body).not.toContain(credential.baseUrl);
const persisted = await prisma.$queryRaw<Array<{
envelope: unknown;
activeSecretVersionId: string;
}>>`
SELECT v."envelope", c."activeSecretVersionId"
FROM "ProviderCredentialVersion" v
JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId"
WHERE c."organizationId" = ${DEFAULT_ORG_ID} AND c."providerId" = 'openrouter'
`;
expect(persisted).toHaveLength(1);
const serialized = JSON.stringify(persisted);
expect(serialized).not.toContain(credential.authToken);
expect(serialized).not.toContain(credential.anthropicApiKey);
expect(serialized).not.toContain(credential.baseUrl);
expect(persisted[0]?.activeSecretVersionId).toBeTruthy();
} finally {
await app.close();
}
});
it("rotates by appending a version and never exposes old or new plaintext", async () => {
const token = await seedUser("ADMIN");
const app = await buildApp();
const cookie = sessionCookieHeader(token);
try {
const first = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connections/openrouter",
headers: { cookie, "content-type": "application/json" },
payload: { baseUrl: "https://one.example", authToken: "first-secret" },
});
const second = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connections/openrouter",
headers: { cookie, "content-type": "application/json" },
payload: { baseUrl: "https://two.example", authToken: "second-secret" },
});
expect(first.statusCode).toBe(201);
expect(second.statusCode).toBe(200);
expect(second.json()).toMatchObject({ activeVersion: 2, status: "ACTIVE" });
const versions = await prisma.$queryRaw<Array<{ version: number; retiredAt: Date | null }>>`
SELECT v."version", v."retiredAt"
FROM "ProviderCredentialVersion" v
JOIN "OrganizationProviderConnection" c ON c."id" = v."connectionId"
WHERE c."organizationId" = ${DEFAULT_ORG_ID}
ORDER BY v."version"
`;
expect(versions).toEqual([
{ version: 1, retiredAt: expect.any(Date) },
{ version: 2, retiredAt: null },
]);
const read = await app.inject({
method: "GET",
url: "/api/org/test-default/provider-connections",
headers: { cookie },
});
expect(read.statusCode).toBe(200);
expect(read.json()).toEqual({ connections: [expect.objectContaining({ activeVersion: 2 })] });
expect(read.body).not.toContain("first-secret");
expect(read.body).not.toContain("second-secret");
} finally {
await app.close();
}
});
it("rejects members and refuses to overwrite a platform-managed connection", async () => {
const memberToken = await seedUser("MEMBER");
const app = await buildApp();
try {
const denied = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connections/openrouter",
headers: {
cookie: sessionCookieHeader(memberToken),
"content-type": "application/json",
},
payload: { baseUrl: "https://provider.example", authToken: "denied-secret" },
});
expect(denied.statusCode).toBe(403);
const service = new ProviderConnectionService(prisma, secrets, async () => {});
await expect(service.rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "another-provider",
actorUserId: "u-member",
baseUrl: "https://provider.example",
authToken: "service-bypass-secret",
})).rejects.toThrow("requires an active Organization OWNER or ADMIN");
await expect(prisma.organizationProviderConnection.count({
where: { organizationId: DEFAULT_ORG_ID, providerId: "another-provider" },
})).resolves.toBe(0);
await prisma.$executeRaw`
INSERT INTO "OrganizationProviderConnection"
("id", "organizationId", "providerId", "mode", "status", "createdAt", "updatedAt")
VALUES
('platform-connection', ${DEFAULT_ORG_ID}, 'openrouter', 'PLATFORM_MANAGED', 'DRAFT', NOW(), NOW())
`;
const ownerToken = await seedUser("OWNER");
const refused = await app.inject({
method: "PUT",
url: "/api/org/test-default/provider-connections/openrouter",
headers: {
cookie: sessionCookieHeader(ownerToken),
"content-type": "application/json",
},
payload: { baseUrl: "https://provider.example", authToken: "takeover-secret" },
});
expect(refused.statusCode).toBe(403);
expect(refused.json()).toMatchObject({
error: { code: "forbidden" },
});
} finally {
await app.close();
}
});
it("does not activate or persist a credential that fails readiness", async () => {
await seedUser("ADMIN");
const service = new ProviderConnectionService(prisma, secrets, async () => {
throw new Error("provider credential readiness check failed: status 401");
});
await expect(service.rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "openrouter",
actorUserId: "u-admin",
baseUrl: "https://provider.example/api",
authToken: "rejected-secret",
})).rejects.toThrow("readiness check failed");
await expect(prisma.organizationProviderConnection.count({
where: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" },
})).resolves.toBe(0);
await expect(prisma.providerCredentialVersion.count()).resolves.toBe(0);
});
});
@@ -125,10 +125,10 @@ describe("real Claude SDK sandbox boundary", () => {
workspaceDir: workspace,
},
systemPrompt: "Use the Bash tool exactly once, then report completion.",
providerEnv: {
providerProxyEnv: {
ANTHROPIC_BASE_URL: stub.baseUrl,
ANTHROPIC_AUTH_TOKEN: "provider-secret",
ANTHROPIC_API_KEY: "provider-api-secret",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
},
tools: ["bash"],
maxTurns: 3,
@@ -4,6 +4,15 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_ORG_ID,
prisma,
resetDb,
testSecretEnvelope,
TEST_SECRET_KEY,
TEST_SECRET_KEY_ID,
} from "./helpers.js";
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
@@ -16,6 +25,7 @@ describe("deployment preflight CLI", () => {
let marker: string;
beforeEach(async () => {
await resetDb();
root = await mkdtemp(join(tmpdir(), "cph-deployment-preflight-"));
baseDir = join(root, "deploy");
hubDir = join(baseDir, "current", "hub");
@@ -93,6 +103,23 @@ describe("deployment preflight CLI", () => {
expect(result.stderr).toContain("PostgreSQL authenticated query failed for DATABASE_URL");
});
it("rejects a structurally valid keyring that cannot authenticate stored envelopes", async () => {
await prismaActorForEnvelope();
const workspaceRoot = join(persistentDir, "workspaces");
const fixture = await createFixture({
workspaceRoot,
marker,
keyringKey: Buffer.alloc(32, "wrong"),
});
const result = await runCli(fixture.args);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("stored provider envelope verification failed");
expect(result.stderr).toContain("secret envelope authentication failed");
expect(result.stderr).not.toContain("stored-envelope-secret");
});
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
const result = await execFileAsync(
resolve("node_modules/.bin/tsx"),
@@ -138,6 +165,7 @@ describe("deployment preflight CLI", () => {
marker: string;
pgIsReadyScript?: string;
databaseUrl?: string;
keyringKey?: Buffer;
}): Promise<{ args: string[] }> {
const binDir = join(root, "bin");
const cphBin = join(binDir, "cph");
@@ -147,6 +175,7 @@ describe("deployment preflight CLI", () => {
const runuserBin = join(binDir, "runuser");
const setprivBin = join(binDir, "setpriv");
const envFile = join(root, "platform.env");
const keyringFile = join(root, "secret-keyring.json");
await Promise.all([
mkdir(binDir, { recursive: true }),
mkdir(join(hubDir, "dist"), { recursive: true }),
@@ -180,15 +209,17 @@ describe("deployment preflight CLI", () => {
`const { appendFileSync } = require("node:fs");\nappendFileSync(${JSON.stringify(options.marker)}, "service-prisma\\n");\n`,
),
writeFile(join(hubDir, "prisma", "schema.prisma"), "// fixture\n"),
writeFile(keyringFile, JSON.stringify({
version: 1,
activeKeyId: TEST_SECRET_KEY_ID,
keys: { [TEST_SECRET_KEY_ID]: (options.keyringKey ?? TEST_SECRET_KEY).toString("base64") },
}), { mode: 0o600 }),
]);
await writeFile(
envFile,
[
"NODE_ENV=production",
`DATABASE_URL=${options.databaseUrl ?? TEST_DATABASE_URL}`,
"ANTHROPIC_BASE_URL=https://openrouter.ai/api",
"ANTHROPIC_AUTH_TOKEN=provider-token",
"ANTHROPIC_API_KEY=",
"FEISHU_APP_ID=cli_app_id",
"FEISHU_APP_SECRET=feishu-app-secret",
"FEISHU_BOT_OPEN_ID=ou_bot",
@@ -207,6 +238,8 @@ describe("deployment preflight CLI", () => {
args: [
"--env-file",
envFile,
"--keyring-file",
keyringFile,
"--node-bin",
process.execPath,
"--runuser-bin",
@@ -236,6 +269,20 @@ describe("deployment preflight CLI", () => {
],
};
}
async function prismaActorForEnvelope(): Promise<void> {
await prisma.user.create({ data: { id: "preflight-admin", feishuOpenId: "ou_preflight", displayName: "Preflight" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "preflight-admin", role: "ADMIN" },
});
await new ProviderConnectionService(prisma, testSecretEnvelope, async () => {}).rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "openrouter",
actorUserId: "preflight-admin",
baseUrl: "https://provider.example/api",
authToken: "stored-envelope-secret",
});
}
});
async function executable(path: string, contents: string): Promise<void> {
+9
View File
@@ -17,9 +17,16 @@ import type {
} from "@ai-sdk/provider";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
export const testSecretEnvelope = new LocalSecretEnvelope({
activeKeyId: TEST_SECRET_KEY_ID,
keys: new Map([[TEST_SECRET_KEY_ID, TEST_SECRET_KEY]]),
});
export const prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
@@ -29,6 +36,8 @@ export const prisma = new PrismaClient({
export async function resetDb(): Promise<void> {
const tables = [
"FeishuEventReceipt",
"ProviderCredentialVersion",
"OrganizationProviderConnection",
"AgentFileChange",
"AgentMessage",
"AuditEntry",
@@ -0,0 +1,127 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const temporaryDirectories: string[] = [];
describe("local KEK rotation", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
await prisma.$disconnect();
});
it("atomically retains the old KEK, rewraps DEKs, and authenticates every envelope", async () => {
const oldKey = Buffer.alloc(32, "o");
const oldSecrets = new LocalSecretEnvelope({
activeKeyId: "local-old",
keys: new Map([["local-old", oldKey]]),
});
await prisma.user.create({ data: { id: "rotation-admin", feishuOpenId: "ou_rotation", displayName: "Rotation" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "rotation-admin", role: "OWNER" },
});
await new ProviderConnectionService(prisma, oldSecrets, async () => {}).rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "openrouter",
actorUserId: "rotation-admin",
baseUrl: "https://provider.example/api",
authToken: "credential-stays-encrypted",
});
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
include: { connection: true },
});
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
temporaryDirectories.push(directory);
const keyringFile = join(directory, "keyring.json");
await writeFile(keyringFile, JSON.stringify({
version: 1,
activeKeyId: "local-old",
keys: { "local-old": oldKey.toString("base64") },
}), { mode: 0o600 });
const expectedOwner = {
uid: process.getuid?.() ?? 0,
gid: process.getgid?.() ?? 0,
};
if (expectedOwner.uid !== 0 || expectedOwner.gid !== 0) {
await expect(rotateLocalKek({
keyringFile,
prisma,
generatedKey: Buffer.alloc(32, "x"),
})).rejects.toThrow("must be owned by 0:0");
}
let releaseLock!: () => void;
const releaseLockPromise = new Promise<void>((resolve) => {
releaseLock = resolve;
});
let lockAcquired!: () => void;
const lockAcquiredPromise = new Promise<void>((resolve) => {
lockAcquired = resolve;
});
const lockHolder = prisma.$transaction(async (tx) => {
await tx.$queryRaw<Array<{ readonly locked: string }>>`
SELECT pg_advisory_xact_lock(${LOCAL_KEK_ROTATION_ADVISORY_LOCK})::text AS "locked"
`;
lockAcquired();
await releaseLockPromise;
}, { timeout: 30_000 });
await lockAcquiredPromise;
try {
await expect(rotateLocalKek({
keyringFile,
prisma,
generatedKey: Buffer.alloc(32, "x"),
expectedOwner,
})).rejects.toThrow("rotation lock is already held");
} finally {
releaseLock();
await lockHolder;
}
const result = await rotateLocalKek({
keyringFile,
prisma,
now: new Date("2026-07-10T12:34:56.000Z"),
generatedKey: Buffer.alloc(32, "n"),
expectedOwner,
});
expect(result).toMatchObject({
previousActiveKeyId: "local-old",
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
rewrapped: 1,
verified: 1,
});
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
expect(after.keyId).toBe(result.activeKeyId);
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
const keyringSource = await readFile(keyringFile, "utf8");
expect(keyringSource).toContain("local-old");
expect(keyringSource).toContain(result.activeKeyId);
expect(keyringSource).not.toContain("credential-stays-encrypted");
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
expect(() => oldSecrets.decryptJson({
purpose: "provider-connection",
organizationId: before.connection.organizationId,
connectionId: before.connection.id,
secretVersionId: before.id,
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
await expect(prisma.auditEntry.count({
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
})).resolves.toBe(1);
});
});
@@ -0,0 +1,170 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
import type { ProviderUpstreamCredential } from "../../src/connections/providerProxy.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
const secrets = testSecretEnvelope;
describe("project-scoped provider runtime settings", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves two projects only through their own Organization connection", async () => {
await seedTestOrganization("org_other", "other");
await Promise.all([
prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } }),
prisma.user.create({ data: { id: "user-b", feishuOpenId: "ou_b", displayName: "B" } }),
prisma.project.create({
data: {
id: "project-a",
organizationId: DEFAULT_ORG_ID,
name: "A",
workspaceDir: "/tmp/project-a",
},
}),
prisma.project.create({
data: {
id: "project-b",
organizationId: "org_other",
name: "B",
workspaceDir: "/tmp/project-b",
},
}),
]);
await Promise.all([
prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" },
}),
prisma.organizationMembership.create({
data: { organizationId: "org_other", userId: "user-b", role: "OWNER" },
}),
]);
const service = new ProviderConnectionService(prisma, secrets, async () => {});
await service.rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "openrouter",
actorUserId: "user-a",
baseUrl: "https://provider-a.example/api",
authToken: "provider-a-token",
anthropicApiKey: "provider-a-api-key",
});
await service.rotateByok({
organizationId: "org_other",
providerId: "openrouter",
actorUserId: "user-b",
baseUrl: "https://provider-b.example/api",
authToken: "provider-b-token",
});
const leasedCredentials: ProviderUpstreamCredential[] = [];
const settings = new DatabaseRuntimeSettings(prisma, secrets, {
ANTHROPIC_AUTH_TOKEN: "forbidden-global-token",
ANTHROPIC_BASE_URL: "https://forbidden-global.example",
}, async (credential) => {
leasedCredentials.push(credential);
return {
sdkEnv: {
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
ANTHROPIC_AUTH_TOKEN: `run-capability-${leasedCredentials.length}`,
ANTHROPIC_API_KEY: "",
},
sensitiveValues: [`run-capability-${leasedCredentials.length}`],
async close() {},
};
});
const providerA = await settings.provider("openrouter", { projectId: "project-a" });
const providerB = await settings.provider("openrouter", { projectId: "project-b" });
expect(JSON.stringify([providerA, providerB])).not.toContain("provider-a-token");
expect(JSON.stringify([providerA, providerB])).not.toContain("provider-b-token");
const leaseA = await providerA.openAgentLease({ runId: "run-a" });
const leaseB = await providerB.openAgentLease({ runId: "run-b" });
expect(leaseA.sdkEnv).toMatchObject({
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
ANTHROPIC_AUTH_TOKEN: "run-capability-1",
ANTHROPIC_API_KEY: "",
});
expect(leaseB.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe("run-capability-2");
expect(leasedCredentials).toEqual([
{
baseUrl: "https://provider-a.example/api",
authToken: "provider-a-token",
anthropicApiKey: "provider-a-api-key",
},
{
baseUrl: "https://provider-b.example/api",
authToken: "provider-b-token",
anthropicApiKey: "",
},
]);
});
it("fails closed without project scope or for missing, inactive, and corrupt connections", async () => {
await prisma.user.create({ data: { id: "user-a", feishuOpenId: "ou_a", displayName: "A" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "user-a", role: "ADMIN" },
});
await prisma.project.create({
data: {
id: "project-a",
organizationId: DEFAULT_ORG_ID,
name: "A",
workspaceDir: "/tmp/project-a",
},
});
const settings = new DatabaseRuntimeSettings(prisma, secrets, {
ANTHROPIC_AUTH_TOKEN: "must-not-be-a-fallback",
});
await expect(settings.provider("openrouter")).rejects.toThrow("projectId is required");
await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow(
"active provider connection not found",
);
const service = new ProviderConnectionService(prisma, secrets, async () => {});
await service.rotateByok({
organizationId: DEFAULT_ORG_ID,
providerId: "openrouter",
actorUserId: "user-a",
baseUrl: "https://provider-a.example/api",
authToken: "provider-a-token",
});
await prisma.organization.update({
where: { id: DEFAULT_ORG_ID },
data: { status: "SUSPENDED" },
});
await expect(settings.provider("openrouter", { projectId: "project-a" })).rejects.toThrow(
`organization ${DEFAULT_ORG_ID} is SUSPENDED`,
);
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ACTIVE" } });
const connection = await prisma.organizationProviderConnection.findUniqueOrThrow({
where: {
organizationId_providerId: { organizationId: DEFAULT_ORG_ID, providerId: "openrouter" },
},
});
const version = await prisma.providerCredentialVersion.findUniqueOrThrow({
where: { id: connection.activeSecretVersionId ?? "missing" },
});
const envelope = structuredClone(version.envelope) as {
payload: { ciphertext: string };
};
const bytes = Buffer.from(envelope.payload.ciphertext, "base64");
bytes[0] = (bytes[0] ?? 0) ^ 1;
envelope.payload.ciphertext = bytes.toString("base64");
await prisma.providerCredentialVersion.update({
where: { id: version.id },
data: { envelope },
});
const corruptProvider = await settings.provider("openrouter", { projectId: "project-a" });
await expect(corruptProvider.openAgentLease({ runId: "run-corrupt" })).rejects.toThrow(
"secret envelope authentication failed",
);
});
});
+17 -4
View File
@@ -1,8 +1,10 @@
import { createServer } from "node:net";
import { once } from "node:events";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { startHub } from "../../src/hub.js";
import { TEST_DATABASE_URL } from "./helpers.js";
import { resetDb, TEST_DATABASE_URL, TEST_SECRET_KEY, TEST_SECRET_KEY_ID } from "./helpers.js";
const ENV_KEYS = [
"DATABASE_URL",
@@ -15,6 +17,7 @@ const ENV_KEYS = [
"HUB_SESSION_SECRET",
"HUB_PUBLIC_BASE_URL",
"HUB_FEISHU_LISTENER_ENABLED",
"HUB_SECRET_KEYRING_FILE",
"HOST",
"PORT",
] as const;
@@ -30,16 +33,23 @@ describe("Hub startup", () => {
});
it("rejects startup before the Feishu listener when the HTTP bind fails", async () => {
await resetDb();
const blocker = createServer();
blocker.listen(0, "127.0.0.1");
await once(blocker, "listening");
const address = blocker.address();
if (address === null || typeof address === "string") throw new Error("expected an INET test listener");
const keyringDirectory = await mkdtemp(join(tmpdir(), "cph-hub-start-keyring-"));
const keyringFile = join(keyringDirectory, "keyring.json");
await writeFile(keyringFile, JSON.stringify({
version: 1,
activeKeyId: TEST_SECRET_KEY_ID,
keys: { [TEST_SECRET_KEY_ID]: TEST_SECRET_KEY.toString("base64") },
}), { mode: 0o600 });
Object.assign(process.env, {
DATABASE_URL: TEST_DATABASE_URL,
ANTHROPIC_AUTH_TOKEN: "test-provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "test-app",
FEISHU_APP_SECRET: "test-secret",
FEISHU_BOT_OPEN_ID: "ou_test_bot",
@@ -47,15 +57,18 @@ describe("Hub startup", () => {
HUB_SESSION_SECRET: "integration-session-secret-with-32-bytes",
HUB_PUBLIC_BASE_URL: "https://hub.example.test",
HUB_FEISHU_LISTENER_ENABLED: "false",
HUB_SECRET_KEYRING_FILE: keyringFile,
HOST: "127.0.0.1",
PORT: String(address.port),
});
try {
const { startHub } = await import("../../src/hub.js");
await expect(startHub()).rejects.toMatchObject({ code: "EADDRINUSE" });
} finally {
blocker.close();
await once(blocker, "close");
await rm(keyringDirectory, { recursive: true, force: true });
}
});
});
+13 -10
View File
@@ -30,13 +30,16 @@ function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://openrouter.ai/api",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_API_KEY: "",
async openAgentLease() {
return {
sdkEnv: {
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
ANTHROPIC_API_KEY: "",
},
sensitiveValues: ["test-run-capability"],
async close() {},
};
},
};
},
@@ -153,9 +156,9 @@ describe("trigger full lifecycle (integration)", () => {
});
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.providerEnv).toMatchObject({
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
ANTHROPIC_AUTH_TOKEN: "test-run-capability",
ANTHROPIC_API_KEY: "",
});
expect(runAgentCalls[0]?.maxTurns).toBe(7);
+10 -10
View File
@@ -11,14 +11,14 @@ describe("agent subprocess security policy", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("passes only provider and safe runtime variables and protects provider credentials from tools", async () => {
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-secret",
providerProxyEnv: {
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
},
hostEnv: {
@@ -37,8 +37,8 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-secret",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
});
expect(policy.env).not.toHaveProperty("DATABASE_URL");
@@ -73,8 +73,8 @@ describe("agent subprocess security policy", () => {
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: workspace,
providerEnv: {
ANTHROPIC_AUTH_TOKEN: "provider-secret",
providerProxyEnv: {
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
DATABASE_URL: "must-not-cross-boundary",
},
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -109,7 +109,7 @@ describe("agent subprocess security policy", () => {
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: linked,
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("project workspace contains a symlink");
});
@@ -124,7 +124,7 @@ describe("agent subprocess security policy", () => {
await expect(createAgentSecurityPolicy({
workspaceRoot,
workspaceDir: linked,
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("symlink");
});
+3 -8
View File
@@ -8,9 +8,6 @@ import {
const VALID_ENV = {
NODE_ENV: "production",
DATABASE_URL: "postgresql://hub:secret@127.0.0.1:5432/hub",
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "provider-token",
ANTHROPIC_API_KEY: "",
FEISHU_APP_ID: "cli_app_id",
FEISHU_APP_SECRET: "feishu-app-secret",
FEISHU_BOT_OPEN_ID: "ou_bot",
@@ -82,7 +79,6 @@ describe("validateDeploymentPreflight", () => {
input({
env: {
...VALID_ENV,
ANTHROPIC_AUTH_TOKEN: "",
FEISHU_APP_SECRET: "",
HUB_PUBLIC_BASE_URL: "http://hub.example.com",
HUB_SESSION_SECRET: "short",
@@ -93,7 +89,6 @@ describe("validateDeploymentPreflight", () => {
expect(error).toBeInstanceOf(DeploymentPreflightError);
expect((error as Error).message).toMatchInlineSnapshot(`
"deployment preflight failed:
- ANTHROPIC_AUTH_TOKEN is required
- FEISHU_APP_SECRET is required
- HUB_PUBLIC_BASE_URL must use https
- HUB_SESSION_SECRET must contain at least 32 characters"
@@ -107,11 +102,11 @@ describe("validateDeploymentPreflight", () => {
);
});
it("rejects a non-empty ANTHROPIC_API_KEY that would conflict with auth-token mode", () => {
it("rejects legacy process-global provider settings instead of silently ignoring them", () => {
expect(() =>
validateDeploymentPreflight(
input({ env: { ...VALID_ENV, ANTHROPIC_API_KEY: "conflicting-api-key" } }),
input({ env: { ...VALID_ENV, ANTHROPIC_AUTH_TOKEN: "global-secret" } }),
),
).toThrow("ANTHROPIC_API_KEY must be present and empty");
).toThrow("ANTHROPIC_AUTH_TOKEN must not be set");
});
});
+7 -4
View File
@@ -212,10 +212,13 @@ function mockSettings(): RuntimeSettings {
async provider(providerId) {
return {
id: providerId,
baseUrl: "https://example.invalid",
authToken: "test-token",
anthropicApiKey: "",
sdkEnv: {},
async openAgentLease() {
return {
sdkEnv: {},
sensitiveValues: [],
async close() {},
};
},
};
},
async modelRegistry() {
+156
View File
@@ -0,0 +1,156 @@
import { createServer } from "node:http";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { openProviderProxyLease, type AgentProviderLease } from "../../src/connections/providerProxy.js";
const leases: AgentProviderLease[] = [];
const upstreamServers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(leases.splice(0).map((lease) => lease.close()));
await Promise.all(upstreamServers.splice(0).map(async (server) => {
server.closeAllConnections();
if (server.listening) {
server.close();
await once(server, "close");
}
}));
});
describe("run-scoped provider proxy", () => {
it("keeps the Organization provider credential out of the Agent environment", async () => {
const observed: Array<{ authorization: string | undefined; apiKey: string | undefined; body: string }> = [];
const upstream = createServer((request, response) => {
const chunks: Buffer[] = [];
request.on("data", (chunk: Buffer) => chunks.push(chunk));
request.on("end", () => {
observed.push({
authorization: request.headers.authorization,
apiKey: header(request.headers["x-api-key"]),
body: Buffer.concat(chunks).toString("utf8"),
});
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}/api`,
authToken: "customer-provider-auth-token",
anthropicApiKey: "customer-provider-api-key",
});
leases.push(lease);
const serializedAgentEnv = JSON.stringify(lease.sdkEnv);
expect(serializedAgentEnv).not.toContain("customer-provider-auth-token");
expect(serializedAgentEnv).not.toContain("customer-provider-api-key");
expect(serializedAgentEnv).not.toContain(String(address.port));
expect(lease.sdkEnv).toMatchObject({
ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/),
ANTHROPIC_AUTH_TOKEN: expect.any(String),
ANTHROPIC_API_KEY: "",
});
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
method: "POST",
headers: {
authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`,
"content-type": "application/json",
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({ model: "test" }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
expect(observed).toEqual([{
authorization: "Bearer customer-provider-auth-token",
apiKey: "customer-provider-api-key",
body: JSON.stringify({ model: "test" }),
}]);
});
it("rejects requests without the run capability before contacting upstream", async () => {
let upstreamRequests = 0;
const diagnostics: unknown[] = [];
const upstream = createServer((_request, response) => {
upstreamRequests += 1;
response.end("unexpected");
});
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: "",
},
{ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) },
);
leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
method: "POST",
body: "{}",
});
expect(response.status).toBe(401);
expect(upstreamRequests).toBe(0);
expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]);
});
it("does not forward provider credentials across an upstream redirect", async () => {
let redirectedRequests = 0;
const diagnostics: unknown[] = [];
const redirectTarget = createServer((_request, response) => {
redirectedRequests += 1;
response.end("leaked");
});
upstreamServers.push(redirectTarget);
redirectTarget.listen(0, "127.0.0.1");
await once(redirectTarget, "listening");
const targetAddress = redirectTarget.address();
if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected redirect target address");
const upstream = createServer((_request, response) => {
response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` });
response.end();
});
upstreamServers.push(upstream);
upstream.listen(0, "127.0.0.1");
await once(upstream, "listening");
const upstreamAddress = upstream.address();
if (upstreamAddress === null || typeof upstreamAddress === "string") throw new Error("expected upstream address");
const lease = await openProviderProxyLease(
{
baseUrl: `http://127.0.0.1:${upstreamAddress.port}`,
authToken: "customer-secret",
anthropicApiKey: "",
},
{ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic) },
);
leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
headers: { authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}` },
redirect: "manual",
});
expect(response.status).toBe(502);
await expect(response.text()).resolves.toBe("provider redirect refused");
expect(redirectedRequests).toBe(0);
expect(diagnostics).toEqual([{ code: "provider_proxy_redirect_refused", category: "redirect" }]);
});
});
function header(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
+128
View File
@@ -0,0 +1,128 @@
import { createServer } from "node:http";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import { ProviderReadinessError, probeOpenRouterCredential } from "../../src/connections/providerReadiness.js";
const servers: ReturnType<typeof createServer>[] = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map(async (server) => {
server.closeAllConnections();
if (server.listening) {
server.close();
await once(server, "close");
}
}));
});
describe("provider credential readiness", () => {
it("authenticates the OpenRouter key endpoint before activation", async () => {
const requests: Array<{ url: string | undefined; authorization: string | undefined }> = [];
const server = createServer((request, response) => {
requests.push({ url: request.url, authorization: request.headers.authorization });
response.writeHead(200, { "content-type": "application/json" });
response.end('{"data":{"label":"test"}}');
});
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string") throw new Error("expected TCP address");
await expect(probeOpenRouterCredential({
providerId: "openrouter",
baseUrl: `http://127.0.0.1:${address.port}/api`,
authToken: "readiness-secret",
anthropicApiKey: "",
})).resolves.toBeUndefined();
expect(requests).toEqual([{
url: "/api/v1/key",
authorization: "Bearer readiness-secret",
}]);
});
it("fails closed on rejected credentials without exposing them in the error", async () => {
const server = createServer((_request, response) => {
response.writeHead(401, { "content-type": "application/json" });
response.end('{"error":"token rejected"}');
});
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string") throw new Error("expected TCP address");
let message = "";
try {
await probeOpenRouterCredential({
providerId: "openrouter",
baseUrl: `http://127.0.0.1:${address.port}`,
authToken: "never-return-readiness-secret",
anthropicApiKey: "",
});
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toBe("provider credential readiness check failed: status 401");
expect(message).not.toContain("never-return-readiness-secret");
});
it("does not follow redirects with the provider credential", async () => {
let redirected = 0;
const target = createServer((_request, response) => {
redirected += 1;
response.end("leaked");
});
servers.push(target);
target.listen(0, "127.0.0.1");
await once(target, "listening");
const targetAddress = target.address();
if (targetAddress === null || typeof targetAddress === "string") throw new Error("expected target address");
const provider = createServer((_request, response) => {
response.writeHead(307, { location: `http://127.0.0.1:${targetAddress.port}/capture` });
response.end();
});
servers.push(provider);
provider.listen(0, "127.0.0.1");
await once(provider, "listening");
const providerAddress = provider.address();
if (providerAddress === null || typeof providerAddress === "string") throw new Error("expected provider address");
await expect(probeOpenRouterCredential({
providerId: "openrouter",
baseUrl: `http://127.0.0.1:${providerAddress.port}`,
authToken: "redirect-secret",
anthropicApiKey: "",
})).rejects.toThrow("status 307");
expect(redirected).toBe(0);
});
it("preserves a redacted network category for observability", async () => {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (address === null || typeof address === "string") throw new Error("expected provider address");
server.close();
await once(server, "close");
let failure: unknown;
try {
await probeOpenRouterCredential({
providerId: "openrouter",
baseUrl: `http://127.0.0.1:${address.port}`,
authToken: "network-secret",
anthropicApiKey: "",
});
} catch (error) {
failure = error;
}
expect(failure).toBeInstanceOf(ProviderReadinessError);
expect(failure).toMatchObject({
code: "provider_readiness_unreachable",
category: "connection",
message: "provider credential readiness check could not reach provider",
});
expect(JSON.stringify(failure)).not.toContain("network-secret");
});
});
+6 -6
View File
@@ -194,7 +194,7 @@ describe("runAgent", () => {
expect(result.costUsd).toBe(0.0042);
});
it("passes only provider and safe runtime env without mutating process env", async () => {
it("passes only the loopback provider proxy capability and safe runtime env without mutating process env", async () => {
process.env["DATABASE_URL"] = "postgresql://platform-secret";
process.env["FEISHU_APP_SECRET"] = "feishu-secret";
process.env["HUB_SESSION_SECRET"] = "session-secret";
@@ -205,9 +205,9 @@ describe("runAgent", () => {
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
providerEnv: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
providerProxyEnv: {
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
},
runId: "run-1",
@@ -219,8 +219,8 @@ describe("runAgent", () => {
expect(call).toMatchObject({
options: {
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
ANTHROPIC_AUTH_TOKEN: "test-token",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
},
sandbox: {
+4 -32
View File
@@ -15,44 +15,16 @@ describe("runtime settings", () => {
expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
});
it("returns provider SDK env without requiring global process env mutation", async () => {
it("refuses process-global provider credentials even when they are present", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
id: "openrouter",
baseUrl: "https://example.test/api",
authToken: "openrouter-token",
anthropicApiKey: "explicit-api-key",
sdkEnv: {
ANTHROPIC_BASE_URL: "https://example.test/api",
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
ANTHROPIC_API_KEY: "explicit-api-key",
},
});
});
it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => {
const settings = createEnvRuntimeSettings({
ANTHROPIC_AUTH_TOKEN: "openrouter-token",
});
await expect(settings.provider("openrouter")).resolves.toMatchObject({
baseUrl: "https://openrouter.ai/api",
anthropicApiKey: "",
sdkEnv: {
ANTHROPIC_API_KEY: "",
},
});
});
it("requires an auth token for the OpenRouter provider", async () => {
const settings = createEnvRuntimeSettings({});
await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN");
await expect(settings.provider("openrouter")).rejects.toThrow(
"process-global provider credentials are disabled",
);
});
it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => {
+188
View File
@@ -0,0 +1,188 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
LocalSecretEnvelope,
loadLocalSecretKeyring,
type SecretBinding,
} from "../../src/security/secretEnvelope.js";
const createdDirectories: string[] = [];
afterEach(async () => {
await Promise.all(createdDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("local secret envelope", () => {
it("encrypts with a per-secret DEK and authenticates the complete binding", async () => {
const keyring = await keyringFile({ active: key("a"), previous: key("b") }, "active");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: keyring,
}));
const binding = secretBinding();
const plaintext = {
schemaVersion: 1,
baseUrl: "https://provider.example/api",
authToken: "org-a-secret-token",
anthropicApiKey: "org-a-api-key",
};
const envelope = secrets.encryptJson(binding, plaintext);
expect(envelope).toMatchObject({
version: 1,
algorithm: "AES-256-GCM",
keyId: "active",
});
expect(JSON.stringify(envelope)).not.toContain(plaintext.authToken);
expect(envelope.wrappedDek.ciphertext).not.toBe(envelope.payload.ciphertext);
expect(secrets.decryptJson(binding, envelope)).toEqual(plaintext);
});
it.each([
["organization", { organizationId: "org-b" }],
["connection", { connectionId: "connection-b" }],
["version", { secretVersionId: "secret-version-b" }],
["purpose", { purpose: "feishu-application" }],
])("rejects ciphertext substituted across %s scope", async (_label, changed) => {
const keyring = await keyringFile({ active: key("a") }, "active");
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: keyring,
}));
const binding = secretBinding();
const envelope = secrets.encryptJson(binding, { token: "never-log-this-token" });
expect(() => secrets.decryptJson({ ...binding, ...changed }, envelope)).toThrow(
"secret envelope authentication failed",
);
});
it("fails closed for a missing KEK or corrupted ciphertext without including plaintext", async () => {
const encryptingKeyring = await keyringFile({ encrypting: key("a") }, "encrypting");
const decryptingKeyring = await keyringFile({ other: key("b") }, "other");
const encrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: encryptingKeyring,
}));
const decrypting = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: decryptingKeyring,
}));
const binding = secretBinding();
const envelope = encrypting.encryptJson(binding, { token: "never-log-this-token" });
expect(() => decrypting.decryptJson(binding, envelope)).toThrow("secret envelope key is unavailable");
const corrupted = structuredClone(envelope);
corrupted.payload.ciphertext = flipFirstByte(corrupted.payload.ciphertext);
let message = "";
try {
encrypting.decryptJson(binding, corrupted);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toBe("secret envelope authentication failed");
expect(message).not.toContain("never-log-this-token");
});
it("rewraps only the DEK when the active KEK rotates", async () => {
const firstFile = await keyringFile({ old: key("a") }, "old");
const rotatedFile = await keyringFile({ old: key("a"), current: key("c") }, "current");
const first = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: firstFile,
}));
const rotated = new LocalSecretEnvelope(await loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: rotatedFile,
}));
const binding = secretBinding();
const envelope = first.encryptJson(binding, { token: "rotatable" });
const rewrapped = rotated.rewrap(binding, envelope);
expect(rewrapped.keyId).toBe("current");
expect(rewrapped.payload).toEqual(envelope.payload);
expect(rewrapped.wrappedDek).not.toEqual(envelope.wrappedDek);
expect(rotated.decryptJson(binding, rewrapped)).toEqual({ token: "rotatable" });
});
it("loads production keys only from the fixed systemd credential", async () => {
const credentialsDirectory = await temporaryDirectory();
await writeKeyring(path.join(credentialsDirectory, "cph-secret-keyring"), { active: key("a") }, "active");
await expect(loadLocalSecretKeyring({
NODE_ENV: "production",
CREDENTIALS_DIRECTORY: credentialsDirectory,
})).resolves.toMatchObject({ activeKeyId: "active" });
await expect(loadLocalSecretKeyring({
NODE_ENV: "production",
CREDENTIALS_DIRECTORY: credentialsDirectory,
HUB_SECRET_KEYRING_FILE: "/tmp/untrusted-keyring.json",
})).rejects.toThrow("production secret keyring must use the systemd credential");
});
it("rejects absent, malformed, or incomplete keyrings", async () => {
await expect(loadLocalSecretKeyring({ NODE_ENV: "test" })).rejects.toThrow(
"missing required secret keyring setting",
);
const directory = await temporaryDirectory();
const malformed = path.join(directory, "malformed.json");
await writeFile(malformed, "not json", "utf8");
await expect(loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: malformed,
})).rejects.toThrow("invalid local secret keyring");
const missingActive = await keyringFile({ old: key("a") }, "current");
await expect(loadLocalSecretKeyring({
NODE_ENV: "test",
HUB_SECRET_KEYRING_FILE: missingActive,
})).rejects.toThrow("active key is unavailable");
});
});
function secretBinding(): SecretBinding {
return {
purpose: "provider-connection",
organizationId: "org-a",
connectionId: "connection-a",
secretVersionId: "secret-version-a",
};
}
function key(character: string): string {
return Buffer.alloc(32, character).toString("base64");
}
async function keyringFile(keys: Readonly<Record<string, string>>, activeKeyId: string): Promise<string> {
const directory = await temporaryDirectory();
const file = path.join(directory, "keyring.json");
await writeKeyring(file, keys, activeKeyId);
return file;
}
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "cph-secret-envelope-"));
createdDirectories.push(directory);
return directory;
}
async function writeKeyring(
file: string,
keys: Readonly<Record<string, string>>,
activeKeyId: string,
): Promise<void> {
await writeFile(file, JSON.stringify({ version: 1, activeKeyId, keys }), { encoding: "utf8", mode: 0o600 });
}
function flipFirstByte(value: string): string {
const bytes = Buffer.from(value, "base64");
bytes[0] = (bytes[0] ?? 0) ^ 1;
return bytes.toString("base64");
}