fix(hub): enable tenant Typst package resolution

This commit is contained in:
2026-07-22 17:47:56 +08:00
parent 6462e42823
commit 36660f72d6
6 changed files with 156 additions and 45 deletions
+11
View File
@@ -40,6 +40,17 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# Optional tenant-local Typst package roots. When configured, the agent
# sandbox forwards these exact paths to Typst. The preinstalled package path is
# read-only; the cache path is the only additional write location.
# Keep the preinstalled package path outside the release tree and provision it
# with the namespace layout expected by Typst, for example:
# <root>/paradigm/paradigm-templates/0.2.20/
# Use a separate service-writable cache path when runtime dependencies may be
# downloaded; do not make the immutable preinstalled directory the cache.
# TYPST_PACKAGE_PATH="/srv/curriculum-project-hub/typst-packages/org-a"
# TYPST_PACKAGE_CACHE_PATH="/var/cache/cph-hub/org-a/typst"
# This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
+5 -3
View File
@@ -85,7 +85,7 @@ REMOTE
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -95,8 +95,10 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0
fi
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev
npm ci --include=dev --prefix admin-web
test -x node_modules/.bin/tsc
test -x admin-web/node_modules/.bin/vite
npm run audit:production
npm run build
test -f admin-web/build/index.html
+4 -2
View File
@@ -60,10 +60,12 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# 2. Install deps (including build-time dev deps) for hub + admin-web,
# audit hub prod, build tsc + SPA, mark complete. NODE_ENV=production may be
# inherited by the remote shell, so --include=dev is intentional here.
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev && npm ci --include=dev --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+50 -38
View File
@@ -247,22 +247,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
"integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz",
"integrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -271,9 +271,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
"integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz",
"integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==",
"cpu": [
"arm64"
],
@@ -284,9 +284,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
"integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz",
"integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==",
"cpu": [
"x64"
],
@@ -297,12 +297,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
"integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz",
"integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -310,12 +313,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz",
"integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.217.tgz",
"integrity": "sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -323,12 +329,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
"integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz",
"integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -336,12 +345,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz",
"integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.217.tgz",
"integrity": "sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -349,9 +361,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
"integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz",
"integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==",
"cpu": [
"arm64"
],
@@ -362,9 +374,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
"integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz",
"integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==",
"cpu": [
"x64"
],
@@ -2685,9 +2697,9 @@
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
+25 -1
View File
@@ -33,6 +33,8 @@ const SAFE_HOST_ENV_KEYS = [
"all_proxy",
"no_proxy",
"NODE_USE_ENV_PROXY",
"TYPST_PACKAGE_PATH",
"TYPST_PACKAGE_CACHE_PATH",
] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
@@ -134,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0
? null
@@ -174,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
// Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write.
@@ -244,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
platformPaths.push(resolve(cphBin));
}
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
return [...new Set(platformPaths.map((path) => resolve(path)))];
}
function hostTypstCacheWritePaths(env: Readonly<Record<string, string | undefined>>): string[] {
return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]);
}
function configuredTypstPackagePaths(
env: Readonly<Record<string, string | undefined>>,
names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[],
): string[] {
const paths: string[] = [];
for (const name of names) {
const packagePath = env[name]?.trim();
if (packagePath === undefined || packagePath === "") continue;
if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`);
const canonical = resolve(packagePath);
if (canonical === "/") throw new Error(`${name} must not be the filesystem root`);
paths.push(canonical);
}
return paths;
}
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const home = homedir();
const paths = [
+61 -1
View File
@@ -32,6 +32,8 @@ describe("agent subprocess security policy", () => {
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
@@ -44,6 +46,8 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
@@ -72,7 +76,10 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [canonicalWorkspace],
allowWrite: expect.arrayContaining([
canonicalWorkspace,
"/srv/curriculum-project-hub/typst-packages/para-26071100",
]),
denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
},
@@ -85,6 +92,59 @@ describe("agent subprocess security policy", () => {
});
});
it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100";
const cacheRoot = "/var/cache/cph-hub/para-26071100/typst";
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
expect(policy.env).toMatchObject({
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
});
expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot);
});
it("rejects a relative Typst package root instead of silently losing package access", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: "typst-packages",
},
})).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute");
});
it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_CACHE_PATH: "/",
},
})).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root");
});
it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();