From 520e005465dd8bbb95e8fbe859e1df784590373c Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 9 Sep 2026 19:48:20 +0800
Subject: [PATCH 01/46] fix(studio): simplify MCP credential editing
---
frontend/src/App.tsx | 34 ++-
frontend/src/adk/client.ts | 52 +++++
frontend/src/create/CustomCreate.css | 61 +++---
frontend/src/create/CustomCreate.tsx | 207 +++++++-----------
frontend/src/create/mcpAuth.ts | 187 +++++-----------
frontend/src/create/types.ts | 11 +-
frontend/src/i18n/resources/en-US/adk.json | 2 +
frontend/src/i18n/resources/en-US/create.json | 12 +-
frontend/src/i18n/resources/zh-CN/adk.json | 2 +
frontend/src/i18n/resources/zh-CN/create.json | 12 +-
frontend/tests/agentWorkspace.test.mjs | 20 +-
frontend/tests/deploymentConfigUi.test.mjs | 5 +-
frontend/tests/mcpAuth.test.mjs | 161 +++++++-------
frontend/tests/runtimeAgentIdentity.test.mjs | 2 +-
tests/cli/test_studio_rbac.py | 50 +++++
veadk/cli/cli_frontend.py | 85 +++++++
veadk/cli/legacy_runtime_recovery.py | 51 +++++
17 files changed, 538 insertions(+), 416 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c638ba4bb..310d41e08 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -35,6 +35,7 @@ import {
getSession,
getStudioAccess,
getRuntimeStudioToolCapabilities,
+ getRuntimeMcpCredentials,
getRuntimes,
listApps,
listEnvironments,
@@ -156,7 +157,10 @@ import { WorkspaceCreate, WorkspaceCreateIcon } from "./create/WorkspaceCreate";
import { CodePackageCreate } from "./create/CodePackageCreate";
import { MigrationWorkspace } from "./migrations/MigrationWorkspace";
import type { AgentDraft } from "./create/types";
-import { configuredMcpEnvKeys } from "./create/mcpAuth";
+import {
+ configuredMcpEnvKeys,
+ hydrateMcpCredentialValues,
+} from "./create/mcpAuth";
import {
hydrateRuntimeModelSelection,
isRuntimeModelSelectionEnv,
@@ -7346,11 +7350,33 @@ export default function App() {
hydratedDraft,
arkModelIds,
);
+ let editorDraft = classifiedDraft;
+ if (configuredMcpEnvKeys(classifiedDraft).length > 0) {
+ try {
+ const credentials = await getRuntimeMcpCredentials({
+ runtimeId: capability.runtime.runtimeId,
+ region: capability.runtime.region,
+ appName: capability.agent.appName,
+ etag: capability.etag,
+ });
+ editorDraft = hydrateMcpCredentialValues(
+ classifiedDraft,
+ credentials,
+ );
+ } catch (credentialError) {
+ setError(
+ credentialError instanceof Error
+ ? credentialError.message
+ : appText("errors.runtimeDeploymentConfigUnavailable"),
+ );
+ return;
+ }
+ }
exitAgentDetailContext();
- setImportedDraft(classifiedDraft);
+ setImportedDraft(editorDraft);
setCustomCreateMode("custom");
setCustomCreationSurface(
- classifiedDraft.dynamicAgentDelegation === true
+ editorDraft.dynamicAgentDelegation === true
? "vulcan"
: "traditional",
);
@@ -7373,7 +7399,7 @@ export default function App() {
capability.editMode === "source-preserving"
? "source-preserving"
: "regenerate",
- configuredMcpEnvKeys: configuredMcpEnvKeys(classifiedDraft),
+ configuredMcpEnvKeys: configuredMcpEnvKeys(editorDraft),
configuredRuntimeEnvKeys:
capability.runtime.configuredEnvKeys,
});
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts
index 8fbd6d21c..0d1c1dc50 100644
--- a/frontend/src/adk/client.ts
+++ b/frontend/src/adk/client.ts
@@ -30,6 +30,7 @@ import {
import type { AgentProject } from "../create/project";
import type {
AgentDraft,
+ McpCredentialValue,
NetworkConfig,
SelectedSkill,
} from "../create/types";
@@ -4588,6 +4589,57 @@ export interface RuntimeUpdateCapability {
} | null;
}
+/** Fetch the exact MCP credentials for one authorized update snapshot. */
+export async function getRuntimeMcpCredentials({
+ runtimeId,
+ region,
+ appName,
+ etag,
+ signal,
+}: {
+ runtimeId: string;
+ region: string;
+ appName: string;
+ etag: string;
+ signal?: AbortSignal;
+}): Promise {
+ const res = await apiFetch("/web/runtime-mcp-credentials", {
+ method: "POST",
+ cache: "no-store",
+ signal,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ runtimeId, region, appName, etag }),
+ });
+ if (!res.ok) {
+ throw new Error(
+ await httpErrorMessage(res, adkT("client.loadMcpCredentialsFailed")),
+ );
+ }
+ const payload = (await res.json().catch(() => null)) as {
+ credentials?: unknown;
+ } | null;
+ if (!Array.isArray(payload?.credentials)) {
+ throw new Error(adkT("client.invalidMcpCredentials"));
+ }
+ return payload.credentials.map((item) => {
+ if (!item || typeof item !== "object") {
+ throw new Error(adkT("client.invalidMcpCredentials"));
+ }
+ const raw = item as Record;
+ const credential = {
+ agentName: raw.agentName,
+ name: raw.name,
+ url: raw.url,
+ authTokenEnv: raw.authTokenEnv,
+ value: raw.value,
+ };
+ if (Object.values(credential).some((value) => typeof value !== "string")) {
+ throw new Error(adkT("client.invalidMcpCredentials"));
+ }
+ return credential as McpCredentialValue;
+ });
+}
+
interface RuntimeUpdateCapabilityRequest {
runtimeId: string;
region: string;
diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css
index a8da4f158..f70d4f472 100644
--- a/frontend/src/create/CustomCreate.css
+++ b/frontend/src/create/CustomCreate.css
@@ -3752,50 +3752,39 @@
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
-.cw-mcp-auth-state {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-height: 32px;
- padding: 7px 10px;
- border: 1px solid hsl(var(--border));
- border-radius: 8px;
- background: hsl(var(--muted));
- color: hsl(var(--muted-foreground));
- font-size: 12px;
- line-height: 1.45;
+.cw-mcp-token-field {
+ position: relative;
}
-.cw-mcp-auth-state button {
- flex: 0 0 auto;
- min-height: 28px;
- padding: 0 9px;
- border: 1px solid hsl(var(--border));
+.cw-mcp-token-field .cw-input {
+ padding-right: 40px;
+}
+.cw-mcp-token-toggle {
+ position: absolute;
+ top: 50%;
+ right: 4px;
+ display: inline-grid;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
border-radius: 6px;
- background: hsl(var(--panel));
- color: hsl(var(--foreground));
+ background: transparent;
+ color: hsl(var(--muted-foreground));
cursor: pointer;
- font: inherit;
+ place-items: center;
+ transform: translateY(-50%);
}
-.cw-mcp-auth-state button:hover {
- background: hsl(var(--accent));
+.cw-mcp-token-toggle:hover {
+ background: hsl(var(--muted));
+ color: hsl(var(--foreground));
}
-.cw-mcp-auth-state button:focus-visible {
+.cw-mcp-token-toggle:focus-visible {
outline: 2px solid hsl(var(--primary));
outline-offset: 1px;
}
-.cw-mcp-auth-state.is-warning {
- align-items: flex-start;
- border-color: hsl(var(--destructive) / 0.35);
- background: hsl(var(--destructive) / 0.06);
- color: hsl(var(--foreground));
-}
-.cw-mcp-auth-actions {
- display: flex;
- flex: 0 0 auto;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 6px;
+.cw-mcp-token-toggle svg {
+ width: 16px;
+ height: 16px;
}
.cw-mcp-warning {
display: flex;
diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx
index db1a0f364..4d72a19a0 100644
--- a/frontend/src/create/CustomCreate.tsx
+++ b/frontend/src/create/CustomCreate.tsx
@@ -94,18 +94,12 @@ import {
import { localPickerMatches } from "./localPickerSearch";
import { draftToYaml } from "./configYaml";
import {
- confirmMcpCredentialReuse,
- clearMcpConfiguredAuth,
deploymentMcpSecretValues,
type McpConfigurationConflict,
mcpAuthTokenInputValue,
- mcpCredentialActionRequired,
mcpConfigurationConflict,
- mcpCredentialReuseValues,
mcpUrlNeedsPathWarning,
prepareMcpAuth,
- removeMcpCredentialForChangedUrl,
- replaceMcpCredentialForChangedUrl,
removedConfiguredMcpEnvKeys,
sourcePreservingMcpSecretValues,
updateMcpAuthTokenInput,
@@ -2118,6 +2112,34 @@ function VikingMemorySelect({
* (http / stdio) and shows the matching fields. http -> url + optional
* bearer token; stdio -> command + space-separated args. Optional name.
* ---------------------------------------------------------------- */
+function McpTokenVisibilityIcon({ hidden }: { hidden: boolean }) {
+ return (
+
+ );
+}
+
function McpToolEditor({
tools,
conflict,
@@ -2131,11 +2153,20 @@ function McpToolEditor({
}) {
const { t } = useTranslation("create");
const conflictErrorId = useId();
+ const [revealedTokenIndex, setRevealedTokenIndex] = useState(
+ null,
+ );
const visibleConflict = showConflict ? conflict : null;
const update = (i: number, p: Partial) =>
onChange(tools.map((tool, idx) => (idx === i ? { ...tool, ...p } : tool)));
- const remove = (i: number) => onChange(tools.filter((_, idx) => idx !== i));
+ const remove = (i: number) => {
+ setRevealedTokenIndex((current) => {
+ if (current == null || current < i) return current;
+ return current === i ? null : current - 1;
+ });
+ onChange(tools.filter((_, idx) => idx !== i));
+ };
const add = () =>
onChange([...tools, { name: "", transport: "http", url: "" }]);
@@ -2233,119 +2264,49 @@ function McpToolEditor({
)}
-
- onChange(
- tools.map((tool, index) =>
- index === i
- ? updateMcpAuthTokenInput(tool, e.target.value)
- : tool,
- ),
- )
- }
- />
- {tool.credentialUpdate === "pending" && (
-
+
+ onChange(
+ tools.map((tool, index) =>
+ index === i
+ ? updateMcpAuthTokenInput(tool, e.target.value)
+ : tool,
+ ),
+ )
+ }
+ />
+
- )}
- {tool.credentialUpdate === "reuse" && (
-
- {t("traditional.mcp.reuseHint")}
-
- onChange(
- tools.map((tool, index) =>
- index === i
- ? replaceMcpCredentialForChangedUrl(tool)
- : tool,
- ),
- )
- }
- >
- {t("traditional.mcp.changeToReplace")}
-
-
- )}
- {tool.credentialConfigured &&
- !tool.authToken &&
- !tool.credentialUpdate && (
-
- {t("traditional.mcp.credentialConfigured")}
-
- onChange(
- tools.map((tool, index) =>
- index === i
- ? clearMcpConfiguredAuth(tool)
- : tool,
- ),
- )
- }
- >
- {t("traditional.mcp.removeCredential")}
-
-
- )}
+
+
+
>
) : (
<>
@@ -2548,9 +2509,6 @@ function nodeProblem(
if (nameProblem) return nameProblem as NodeProblemCode;
if (duplicateNames.has(n.name)) return "duplicateName";
if (n.description.trim().length === 0) return "missingDescription";
- if ((n.mcpTools ?? []).some(mcpCredentialActionRequired)) {
- return "mcpAuthRequired";
- }
if (isOrchestratorType(n.agentType))
return n.subAgents.length === 0 ? "missingSubagent" : null;
return n.instruction.trim().length === 0 ? "missingPrompt" : null;
@@ -2564,7 +2522,6 @@ type NodeProblemCode =
| "name.characters"
| "duplicateName"
| "missingDescription"
- | "mcpAuthRequired"
| "mcpDuplicateName"
| "mcpDuplicateUrl"
| "missingSubagent"
@@ -4864,7 +4821,6 @@ export function CustomCreate({
? {
runtimeId: deploymentTarget.runtimeId,
region: deploymentTarget.region,
- mcpCredentialReuses: mcpCredentialReuseValues(variantDraft),
}
: undefined,
);
@@ -5160,9 +5116,6 @@ export function CustomCreate({
: mcpGatewayManaged
? deploymentMcpSecretValues(draft)
: undefined,
- mcpCredentialReuses: deploymentTarget
- ? mcpCredentialReuseValues(draft)
- : undefined,
removeRuntimeEnvKeys: deploymentTarget
? [
...removedConfiguredMcpEnvKeys(
diff --git a/frontend/src/create/mcpAuth.ts b/frontend/src/create/mcpAuth.ts
index 506f70011..873baf25a 100644
--- a/frontend/src/create/mcpAuth.ts
+++ b/frontend/src/create/mcpAuth.ts
@@ -1,4 +1,4 @@
-import type { AgentDraft, McpTool } from "./types";
+import type { AgentDraft, McpCredentialValue, McpTool } from "./types";
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const ENV_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
@@ -134,7 +134,6 @@ export function removedConfiguredMcpEnvKeys(
}
export function mcpAuthTokenInputValue(tool: McpTool): string {
- if (tool.credentialUpdate === "pending") return "";
if (tool.authToken) return tool.authToken;
if (tool.credentialConfigured) return "";
const envName = configuredEnvName(tool);
@@ -143,20 +142,11 @@ export function mcpAuthTokenInputValue(tool: McpTool): string {
export function updateMcpAuthTokenInput(tool: McpTool, value: string): McpTool {
if (!value) {
- if (tool.authToken) {
- const next = { ...tool, credentialConfigured: false };
- delete next.authToken;
- delete next.authTokenEnv;
- return next;
- }
- if (tool.credentialConfigured) {
- const next = { ...tool };
- delete next.authToken;
- return next;
- }
- const next = { ...tool };
+ const next = { ...tool, credentialConfigured: false };
delete next.authToken;
delete next.authTokenEnv;
+ delete next.credentialSourceUrl;
+ delete next.credentialSourceAuthTokenEnv;
return next;
}
const reference = value.trim().match(ENV_REFERENCE);
@@ -166,9 +156,6 @@ export function updateMcpAuthTokenInput(tool: McpTool, value: string): McpTool {
authTokenEnv: reference[1],
credentialConfigured:
tool.credentialConfigured && configuredEnvName(tool) === reference[1],
- ...(tool.credentialSourceUrl
- ? { credentialUpdate: "replace" as const }
- : {}),
};
delete next.authToken;
return next;
@@ -177,19 +164,9 @@ export function updateMcpAuthTokenInput(tool: McpTool, value: string): McpTool {
...tool,
authToken: value,
credentialConfigured: false,
- ...(tool.credentialSourceUrl
- ? { credentialUpdate: "replace" as const }
- : {}),
};
}
-export function clearMcpConfiguredAuth(tool: McpTool): McpTool {
- const next = { ...tool, credentialConfigured: false };
- delete next.authToken;
- delete next.authTokenEnv;
- return next;
-}
-
export function mcpUrlNeedsPathWarning(value: string): boolean {
if (!value.trim()) return false;
try {
@@ -212,86 +189,68 @@ export interface SourcePreservingMcpSecretValue {
value: string;
}
-export interface McpCredentialReuseValue {
- agentName: string;
- name: string;
- url: string;
- sourceAuthTokenEnv: string;
-}
-
function normalizedMcpIdentityUrl(value: string | undefined): string {
return (value ?? "").trim().replace(/\/+$/, "");
}
-/** Whether a changed published endpoint still needs an explicit auth choice. */
-export function mcpCredentialActionRequired(tool: McpTool): boolean {
- return tool.credentialUpdate === "pending";
-}
-
-/** Change an MCP URL without silently replaying a published credential. */
+/** Change an MCP URL while retaining the explicit token shown in the editor. */
export function updateMcpUrlInput(tool: McpTool, value: string): McpTool {
- const sourceUrl =
- tool.credentialSourceUrl ??
- (tool.credentialConfigured ? tool.url?.trim() ?? "" : "");
- const sourceAuthTokenEnv =
- tool.credentialSourceAuthTokenEnv ??
- (tool.credentialConfigured ? configuredEnvName(tool) : "");
- if (!sourceUrl || !sourceAuthTokenEnv) return { ...tool, url: value };
-
- if (
- normalizedMcpIdentityUrl(value) === normalizedMcpIdentityUrl(sourceUrl)
- ) {
- const restored: McpTool = {
- ...tool,
- url: value,
- authTokenEnv: sourceAuthTokenEnv,
- credentialConfigured: true,
- credentialSourceUrl: sourceUrl,
- credentialSourceAuthTokenEnv: sourceAuthTokenEnv,
- };
- delete restored.authToken;
- delete restored.credentialUpdate;
- return restored;
- }
-
- const changed: McpTool = {
- ...tool,
- url: value,
- authTokenEnv: sourceAuthTokenEnv,
- credentialConfigured: false,
- credentialSourceUrl: sourceUrl,
- credentialSourceAuthTokenEnv: sourceAuthTokenEnv,
- credentialUpdate: "pending",
- };
- delete changed.authToken;
- return changed;
+ return { ...tool, url: value };
}
-export function confirmMcpCredentialReuse(tool: McpTool): McpTool {
- if (!tool.credentialSourceAuthTokenEnv) return tool;
- return {
- ...tool,
- authTokenEnv: tool.credentialSourceAuthTokenEnv,
- credentialConfigured: false,
- credentialUpdate: "reuse",
- };
-}
-
-export function replaceMcpCredentialForChangedUrl(tool: McpTool): McpTool {
- const next: McpTool = {
- ...tool,
- credentialConfigured: false,
- credentialUpdate: "replace",
- };
- delete next.authToken;
- delete next.authTokenEnv;
- return next;
-}
+/** Add authorized credential values to the editor without mutating the source. */
+export function hydrateMcpCredentialValues(
+ root: AgentDraft,
+ credentials: readonly McpCredentialValue[],
+): AgentDraft {
+ const bySlot = new Map(
+ credentials.map((credential) => [
+ [
+ credential.agentName.trim(),
+ credential.authTokenEnv.trim(),
+ normalizedMcpIdentityUrl(credential.url),
+ ].join("\u0000"),
+ credential.value,
+ ]),
+ );
+
+ const visit = (node: AgentDraft): AgentDraft => ({
+ ...node,
+ mcpTools: (node.mcpTools ?? []).map((tool) => {
+ const reference = configuredEnvName(tool);
+ if (!reference) return tool;
+ const value = bySlot.get(
+ [
+ node.name.trim(),
+ reference,
+ normalizedMcpIdentityUrl(tool.url),
+ ].join("\u0000"),
+ );
+ return value
+ ? {
+ ...tool,
+ authToken: value,
+ credentialConfigured: true,
+ credentialSourceUrl: tool.url?.trim() ?? "",
+ credentialSourceAuthTokenEnv: reference,
+ }
+ : tool;
+ }),
+ subAgents: node.subAgents.map(visit),
+ ...(node.workflow
+ ? {
+ workflow: {
+ ...node.workflow,
+ nodes: node.workflow.nodes.map((workflowNode) => ({
+ ...workflowNode,
+ agent: visit(workflowNode.agent),
+ })),
+ },
+ }
+ : {}),
+ });
-export function removeMcpCredentialForChangedUrl(tool: McpTool): McpTool {
- const next = replaceMcpCredentialForChangedUrl(tool);
- next.credentialUpdate = "remove";
- return next;
+ return visit(root);
}
/** New deployment credentials resolved from the MCP editor's prior inputs. */
@@ -359,35 +318,6 @@ export function sourcePreservingMcpSecretValues(
return values;
}
-/** Explicitly confirmed reuse decisions; contains references, never secrets. */
-export function mcpCredentialReuseValues(
- root: AgentDraft,
-): McpCredentialReuseValue[] {
- const values: McpCredentialReuseValue[] = [];
- const visit = (node: AgentDraft) => {
- for (const tool of node.mcpTools ?? []) {
- const sourceAuthTokenEnv =
- tool.credentialSourceAuthTokenEnv?.trim() ?? "";
- if (
- tool.transport === "http" &&
- tool.credentialUpdate === "reuse" &&
- sourceAuthTokenEnv
- ) {
- values.push({
- agentName: node.name.trim(),
- name: tool.name.trim(),
- url: tool.url?.trim() ?? "",
- sourceAuthTokenEnv,
- });
- }
- }
- node.subAgents.forEach(visit);
- node.workflow?.nodes.forEach((workflowNode) => visit(workflowNode.agent));
- };
- visit(root);
- return values;
-}
-
/** Replace transient MCP tokens with stable environment-variable references. */
export function prepareMcpAuth(root: AgentDraft): PreparedMcpAuth {
const used = new Set();
@@ -415,7 +345,6 @@ export function prepareMcpAuth(root: AgentDraft): PreparedMcpAuth {
delete prepared.credentialConfigured;
delete prepared.credentialSourceUrl;
delete prepared.credentialSourceAuthTokenEnv;
- delete prepared.credentialUpdate;
if (envName) prepared.authTokenEnv = envName;
else delete prepared.authTokenEnv;
return prepared;
diff --git a/frontend/src/create/types.ts b/frontend/src/create/types.ts
index 72dd6493d..5086ebef3 100644
--- a/frontend/src/create/types.ts
+++ b/frontend/src/create/types.ts
@@ -29,14 +29,21 @@ export interface McpTool {
credentialSourceUrl?: string;
/** Original published credential reference. Editor-only and never a value. */
credentialSourceAuthTokenEnv?: string;
- /** Explicit decision required after the published endpoint identity changes. */
- credentialUpdate?: "pending" | "reuse" | "replace" | "remove";
/** stdio transport: the command to launch (e.g. "npx"). */
command?: string;
/** stdio transport: command args (e.g. ["-y", "@playwright/mcp@latest"]). */
args?: string[];
}
+/** One server-authorized MCP credential restored into the in-memory editor. */
+export interface McpCredentialValue {
+ agentName: string;
+ name: string;
+ url: string;
+ authTokenEnv: string;
+ value: string;
+}
+
// Import and re-export the multi-source SelectedSkill (and related types) from
// the skills module. Importing locally brings the names into scope so the
// AgentDraft interface below can reference SelectedSkill, while `export type`
diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json
index ad18b2830..893aadc87 100644
--- a/frontend/src/i18n/resources/en-US/adk.json
+++ b/frontend/src/i18n/resources/en-US/adk.json
@@ -510,6 +510,8 @@
"loadA2aCardFailed": "Failed to load the A2A Agent Card",
"loadRuntimeApiKeyFailed": "Failed to load the Runtime API key",
"runtimeApiKeyMissing": "The Runtime did not return a usable API key",
+ "loadMcpCredentialsFailed": "Failed to load MCP authentication",
+ "invalidMcpCredentials": "Studio returned invalid MCP authentication data",
"deleteFailed": "Delete failed ({{status}})",
"runtimeManageForbidden": "This account cannot manage the Runtime.",
"runtimeNotFound": "The Runtime does not exist or has been deleted.",
diff --git a/frontend/src/i18n/resources/en-US/create.json b/frontend/src/i18n/resources/en-US/create.json
index 940e5f46f..3b16fb089 100644
--- a/frontend/src/i18n/resources/en-US/create.json
+++ b/frontend/src/i18n/resources/en-US/create.json
@@ -695,7 +695,6 @@
},
"duplicateName": "Agent names must be unique within this structure",
"missingDescription": "Description is required",
- "mcpAuthRequired": "Confirm how to handle authentication after changing the MCP address",
"mcpDuplicateName": "MCP names must be unique",
"mcpDuplicateUrl": "Remove the duplicate MCP endpoint before publishing",
"missingSubagent": "A subagent is required",
@@ -1074,16 +1073,9 @@
"namePlaceholder": "Name (optional)",
"urlPlaceholder": "MCP service URL",
"pathWarning": "This is a gateway root address. Use it only when the root path is the MCP endpoint; otherwise include the complete service path.",
- "configuredPlaceholder": "Authentication is stored securely",
"tokenPlaceholder": "Bearer token (optional)",
- "changedUrlWarning": "The MCP address changed. Choose how to handle the stored authentication.",
- "reuseCredential": "Keep using it",
- "replaceCredential": "Replace authentication",
- "noAuth": "Use no authentication",
- "reuseHint": "The stored authentication will be reused when you publish.",
- "changeToReplace": "Replace instead",
- "credentialConfigured": "Studio securely stores the authentication.",
- "removeCredential": "Remove authentication",
+ "showToken": "Show Bearer token",
+ "hideToken": "Hide Bearer token",
"commandPlaceholder": "Command, such as npx",
"argsPlaceholder": "Arguments separated by spaces",
"stdioHint": "The stdio tool starts in the deployment environment. Make sure its command and dependencies are available.",
diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json
index 7f1298d56..a12976aca 100644
--- a/frontend/src/i18n/resources/zh-CN/adk.json
+++ b/frontend/src/i18n/resources/zh-CN/adk.json
@@ -510,6 +510,8 @@
"loadA2aCardFailed": "读取 A2A Agent Card 失败",
"loadRuntimeApiKeyFailed": "读取 Runtime API Key 失败",
"runtimeApiKeyMissing": "Runtime 未返回可用的 API Key",
+ "loadMcpCredentialsFailed": "读取 MCP 认证信息失败",
+ "invalidMcpCredentials": "Studio 返回的 MCP 认证信息格式无效",
"deleteFailed": "删除失败 ({{status}})",
"runtimeManageForbidden": "当前账号没有管理该 Runtime 的权限。",
"runtimeNotFound": "该 Runtime 不存在或已被删除。",
diff --git a/frontend/src/i18n/resources/zh-CN/create.json b/frontend/src/i18n/resources/zh-CN/create.json
index 45d5cf03c..916cd7ead 100644
--- a/frontend/src/i18n/resources/zh-CN/create.json
+++ b/frontend/src/i18n/resources/zh-CN/create.json
@@ -695,7 +695,6 @@
},
"duplicateName": "Agent 名称在当前结构中必须唯一",
"missingDescription": "描述为必填项",
- "mcpAuthRequired": "MCP 地址变化后需要确认认证方式",
"mcpDuplicateName": "MCP 名称重复,请为每个服务使用唯一名称",
"mcpDuplicateUrl": "MCP 地址重复,请删除重复服务后再发布",
"missingSubagent": "缺少子 Agent",
@@ -1074,16 +1073,9 @@
"namePlaceholder": "名称(可选)",
"urlPlaceholder": "MCP 服务地址",
"pathWarning": "当前填写的是网关根地址。仅当根路径就是 MCP Endpoint 时可直接使用;否则请补充完整服务路径。",
- "configuredPlaceholder": "已安全保存认证信息",
"tokenPlaceholder": "Bearer Token(可选)",
- "changedUrlWarning": "MCP 地址已变化,请确认如何处理已保存的认证信息。",
- "reuseCredential": "继续使用",
- "replaceCredential": "替换认证",
- "noAuth": "不使用认证",
- "reuseHint": "发布时将继续使用已保存的认证信息。",
- "changeToReplace": "改为替换",
- "credentialConfigured": "认证信息已由 Studio 安全保存。",
- "removeCredential": "移除认证",
+ "showToken": "显示 Bearer Token",
+ "hideToken": "隐藏 Bearer Token",
"commandPlaceholder": "命令,例如 npx",
"argsPlaceholder": "参数,以空格分隔",
"stdioHint": "stdio 工具在部署环境中启动,请确保命令和依赖可用。",
diff --git a/frontend/tests/agentWorkspace.test.mjs b/frontend/tests/agentWorkspace.test.mjs
index bec959b78..09c148bf7 100644
--- a/frontend/tests/agentWorkspace.test.mjs
+++ b/frontend/tests/agentWorkspace.test.mjs
@@ -700,6 +700,8 @@ test("runtime updates use the Agent selected in management instead of the active
assert.match(clientSource, /network: NetworkConfig/);
assert.match(clientSource, /agent\?:\s*\{[\s\S]*?\}\s*\| null/);
assert.match(clientSource, /export function getRuntimeUpdateCapability/);
+ assert.match(clientSource, /export async function getRuntimeMcpCredentials/);
+ assert.match(clientSource, /\/web\/runtime-mcp-credentials/);
assert.match(clientSource, /\/web\/runtime-update-capability\?\$\{params\.toString\(\)\}/);
assert.match(clientSource, /new URLSearchParams\(\{ runtimeId, region \}\)/);
assert.match(clientSource, /if \(appName\) params\.set\("appName", appName\)/);
@@ -733,14 +735,7 @@ test("runtime updates use the Agent selected in management instead of the active
customCreateSource,
/removeRuntimeEnvKeys:\s*deploymentTarget[\s\S]*?removedConfiguredMcpEnvKeys\([\s\S]*?deploymentTarget\.configuredMcpEnvKeys[\s\S]*?draft/,
);
- assert.match(
- customCreateSource,
- /onClick=\{\(\) =>[\s\S]*?onChange\([\s\S]*?tools\.map\([\s\S]*?clearMcpConfiguredAuth\(tool\)/,
- );
- assert.doesNotMatch(
- customCreateSource,
- /update\(i,\s*clearMcpConfiguredAuth\(/,
- );
+ assert.doesNotMatch(customCreateSource, /clearMcpConfiguredAuth/);
const handlerStart = appSource.indexOf("onUpdateAgent={async (capability) =>");
const handlerEnd = appSource.indexOf("onEditDraft=", handlerStart);
@@ -765,9 +760,16 @@ test("runtime updates use the Agent selected in management instead of the active
);
assert.doesNotMatch(handler, /draftEnvValues|selectedAgentUpdateDraft\?\.draft/);
assert.match(handler, /hydrateRuntimeModelSelection\(/);
+ assert.match(handler, /await getRuntimeMcpCredentials\(/);
+ assert.match(handler, /hydrateMcpCredentialValues\(/);
+ assert.ok(
+ handler.indexOf("hydrateMcpCredentialValues(") <
+ handler.indexOf("setImportedDraft("),
+ "MCP credentials must be restored before the editor opens",
+ );
assert.match(
handler,
- /setCustomCreationSurface\([\s\S]*?classifiedDraft\.dynamicAgentDelegation === true[\s\S]*?\? "vulcan"[\s\S]*?: "traditional"/,
+ /setCustomCreationSurface\([\s\S]*?editorDraft\.dynamicAgentDelegation === true[\s\S]*?\? "vulcan"[\s\S]*?: "traditional"/,
);
assert.match(handler, /network:\s*capability\.runtime\.network/);
assert.match(handler, /etag:\s*capability\.etag/);
diff --git a/frontend/tests/deploymentConfigUi.test.mjs b/frontend/tests/deploymentConfigUi.test.mjs
index ad8982e82..85aae3b09 100644
--- a/frontend/tests/deploymentConfigUi.test.mjs
+++ b/frontend/tests/deploymentConfigUi.test.mjs
@@ -61,10 +61,7 @@ test("offers code execution with its sandbox configuration", () => {
customCreateSource,
/createGeneratedAgentTestRun\([\s\S]*?debugRuntimeDraft\(variantDraft, transientModelSecretValues\)[\s\S]*?runtimeId: deploymentTarget\.runtimeId[\s\S]*?region: deploymentTarget\.region/,
);
- assert.match(
- customCreateSource,
- /createGeneratedAgentTestRun\([\s\S]*?debugRuntimeDraft\(variantDraft, transientModelSecretValues\)[\s\S]*?mcpCredentialReuseValues\(variantDraft\)/,
- );
+ assert.doesNotMatch(customCreateSource, /mcpCredentialReuseValues/);
assert.match(
customCreateSource,
/if \(isImeCompositionEvent\(e\.nativeEvent\)\) return;[\s\S]*?e\.key === "Enter"/,
diff --git a/frontend/tests/mcpAuth.test.mjs b/frontend/tests/mcpAuth.test.mjs
index 85748f1b1..f3f6d3813 100644
--- a/frontend/tests/mcpAuth.test.mjs
+++ b/frontend/tests/mcpAuth.test.mjs
@@ -35,19 +35,14 @@ function draft(overrides = {}) {
}
const {
- confirmMcpCredentialReuse,
- clearMcpConfiguredAuth,
configuredMcpEnvKeys,
deploymentMcpSecretValues,
- mcpCredentialActionRequired,
mcpConfigurationConflict,
- mcpCredentialReuseValues,
+ hydrateMcpCredentialValues,
mcpAuthTokenInputValue,
mcpUrlNeedsPathWarning,
prepareMcpAuth,
- replaceMcpCredentialForChangedUrl,
removedConfiguredMcpEnvKeys,
- removeMcpCredentialForChangedUrl,
sourcePreservingMcpSecretValues,
updateMcpAuthTokenInput,
updateMcpUrlInput,
@@ -136,36 +131,25 @@ test("shows recovered MCP credentials and clears their binding with the input",
assert.equal(replaced.authToken, "replacement-secret");
assert.equal(replaced.credentialConfigured, false);
- const removed = clearMcpConfiguredAuth({ ...tool, authToken: undefined });
- assert.equal(removed.authToken, undefined);
- assert.equal(removed.authTokenEnv, undefined);
- assert.equal(removed.credentialConfigured, false);
-
const prepared = prepareMcpAuth(draft({ mcpTools: [tool] }));
assert.equal(prepared.draft.mcpTools[0].authTokenEnv, tool.authTokenEnv);
assert.equal(prepared.draft.mcpTools[0].credentialConfigured, undefined);
assert.doesNotMatch(JSON.stringify(prepared.draft), /recovered-secret|configured.*true/i);
});
-test("wires the MCP URL-change credential choices into the editor and deploy payload", () => {
+test("uses one masked Bearer Token input without credential-mode choices", () => {
assert.match(
customCreateSource,
/onChange\(\s*tools\.map\([\s\S]*?updateMcpUrlInput\(tool, e\.target\.value\)/,
);
- assert.match(
- customCreateSource,
- /aria-invalid=\{mcpCredentialActionRequired\(tool\)\}/,
- );
- assert.match(customCreateSource, /t\("traditional\.mcp\.reuseCredential"\)/);
- assert.match(customCreateSource, /t\("traditional\.mcp\.replaceCredential"\)/);
- assert.match(customCreateSource, /t\("traditional\.mcp\.noAuth"\)/);
- assert.match(
- customCreateSource,
- /mcpCredentialReuses:\s*deploymentTarget[\s\S]*?mcpCredentialReuseValues\(draft\)/,
- );
+ assert.match(customCreateSource, /type=\{revealedTokenIndex === i \? "text" : "password"\}/);
+ assert.match(customCreateSource, /autoComplete="new-password"/);
+ assert.doesNotMatch(customCreateSource, /mcpCredentialActionRequired/);
+ assert.doesNotMatch(customCreateSource, /traditional\.mcp\.(reuseCredential|replaceCredential|noAuth)/);
+ assert.doesNotMatch(customCreateSource, /mcpCredentialReuses:/);
});
-test("requires an explicit credential decision when a published MCP URL changes", () => {
+test("keeps the populated credential when a published MCP URL changes", () => {
const published = {
name: "orders",
transport: "http",
@@ -181,60 +165,22 @@ test("requires an explicit credential decision when a published MCP URL changes"
published,
"https://new-mcp.example.com/orders/mcp",
);
- assert.equal(changed.authToken, undefined);
- assert.equal(changed.credentialUpdate, "pending");
- assert.equal(mcpCredentialActionRequired(changed), true);
- assert.equal(mcpAuthTokenInputValue(changed), "");
-
- const reused = confirmMcpCredentialReuse(changed);
- assert.equal(reused.credentialUpdate, "reuse");
- assert.equal(mcpCredentialActionRequired(reused), false);
- assert.deepEqual(mcpCredentialReuseValues(draft({ mcpTools: [reused] })), [
+ assert.equal(changed.authToken, "recovered-secret");
+ assert.equal(mcpAuthTokenInputValue(changed), "recovered-secret");
+ assert.deepEqual(sourcePreservingMcpSecretValues(draft({ mcpTools: [changed] })), [
{
agentName: "sales-agent",
name: "orders",
url: "https://new-mcp.example.com/orders/mcp",
- sourceAuthTokenEnv: "MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
+ value: "recovered-secret",
},
]);
const restored = updateMcpUrlInput(
- reused,
+ changed,
"https://mcp.example.com/orders/mcp/",
);
- assert.equal(restored.credentialUpdate, undefined);
- assert.equal(restored.credentialConfigured, true);
- assert.equal(
- restored.authTokenEnv,
- "MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
- );
-});
-
-test("submits an explicit reuse decision for an unnamed MCP without inventing a browser identity", () => {
- const published = {
- name: "",
- transport: "http",
- url: "https://old-mcp.example.com/vtrace",
- authTokenEnv: "MCP_SALES_AGENT_TOOL_1_AUTH_TOKEN",
- credentialConfigured: true,
- credentialSourceUrl: "https://old-mcp.example.com/vtrace",
- credentialSourceAuthTokenEnv: "MCP_SALES_AGENT_TOOL_1_AUTH_TOKEN",
- };
-
- const changed = updateMcpUrlInput(
- published,
- "https://new-mcp.example.com/mcp",
- );
- const reused = confirmMcpCredentialReuse(changed);
-
- assert.deepEqual(mcpCredentialReuseValues(draft({ mcpTools: [reused] })), [
- {
- agentName: "sales-agent",
- name: "",
- url: "https://new-mcp.example.com/mcp",
- sourceAuthTokenEnv: "MCP_SALES_AGENT_TOOL_1_AUTH_TOKEN",
- },
- ]);
+ assert.equal(restored.authToken, "recovered-secret");
});
test("describes the MCP root-path check without requiring a /mcp suffix", () => {
@@ -245,26 +191,23 @@ test("describes the MCP root-path check without requiring a /mcp suffix", () =>
}
});
-test("supports replacing or explicitly removing auth after an MCP URL change", () => {
+test("replaces or removes auth directly through the Bearer Token input", () => {
const changed = updateMcpUrlInput(
{
name: "orders",
transport: "http",
url: "https://mcp.example.com/orders/mcp",
authTokenEnv: "MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
+ authToken: "recovered-secret",
credentialConfigured: true,
},
"https://new-mcp.example.com/orders/mcp",
);
- const replacing = replaceMcpCredentialForChangedUrl(changed);
- assert.equal(replacing.authTokenEnv, undefined);
- assert.equal(replacing.credentialUpdate, "replace");
const replacement = updateMcpAuthTokenInput(
- replacing,
+ changed,
"replacement-secret",
);
- assert.equal(replacement.credentialUpdate, "replace");
assert.deepEqual(
deploymentMcpSecretValues(draft({ mcpTools: [replacement] })),
[
@@ -277,11 +220,59 @@ test("supports replacing or explicitly removing auth after an MCP URL change", (
],
);
- const removed = removeMcpCredentialForChangedUrl(changed);
- assert.equal(removed.credentialUpdate, "remove");
+ const removed = updateMcpAuthTokenInput(changed, "");
assert.equal(removed.authTokenEnv, undefined);
- assert.equal(mcpCredentialActionRequired(removed), false);
- assert.deepEqual(mcpCredentialReuseValues(draft({ mcpTools: [removed] })), []);
+ assert.equal(removed.authToken, undefined);
+ assert.equal(removed.credentialConfigured, false);
+});
+
+test("hydrates configured credentials recursively by exact published slot", () => {
+ const source = draft({
+ mcpTools: [
+ {
+ name: "orders",
+ transport: "http",
+ url: "https://mcp.example.com/orders",
+ authTokenEnv: "MCP_ORDERS_TOKEN",
+ credentialConfigured: true,
+ },
+ ],
+ subAgents: [
+ draft({
+ name: "worker",
+ mcpTools: [
+ {
+ name: "inventory",
+ transport: "http",
+ url: "https://mcp.example.com/inventory",
+ authTokenEnv: "MCP_INVENTORY_TOKEN",
+ credentialConfigured: true,
+ },
+ ],
+ }),
+ ],
+ });
+
+ const hydrated = hydrateMcpCredentialValues(source, [
+ {
+ agentName: "sales-agent",
+ name: "orders",
+ url: "https://mcp.example.com/orders",
+ authTokenEnv: "MCP_ORDERS_TOKEN",
+ value: "orders-secret",
+ },
+ {
+ agentName: "worker",
+ name: "inventory",
+ url: "https://mcp.example.com/inventory",
+ authTokenEnv: "MCP_INVENTORY_TOKEN",
+ value: "inventory-secret",
+ },
+ ]);
+
+ assert.equal(hydrated.mcpTools[0].authToken, "orders-secret");
+ assert.equal(hydrated.subAgents[0].mcpTools[0].authToken, "inventory-secret");
+ assert.equal(source.mcpTools[0].authToken, undefined);
});
test("finds duplicate MCP names and canonical endpoint URLs before deploy", () => {
@@ -395,7 +386,7 @@ test("shows and focuses duplicate MCP validation before any publish work", () =>
);
});
-test("keeps unchanged reference-only credentials eligible for server resolution", () => {
+test("keeps unchanged populated credentials explicit for every execution path", () => {
const unchanged = draft({
mcpTools: [
{
@@ -403,6 +394,7 @@ test("keeps unchanged reference-only credentials eligible for server resolution"
transport: "http",
url: "https://mcp.example.com/orders/mcp",
authTokenEnv: "MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
+ authToken: "recovered-secret",
credentialConfigured: true,
credentialSourceUrl: "https://mcp.example.com/orders/mcp",
credentialSourceAuthTokenEnv: "MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
@@ -410,16 +402,15 @@ test("keeps unchanged reference-only credentials eligible for server resolution"
],
});
- assert.equal(mcpCredentialActionRequired(unchanged.mcpTools[0]), false);
- assert.deepEqual(deploymentMcpSecretValues(unchanged), []);
- assert.deepEqual(mcpCredentialReuseValues(unchanged), []);
+ assert.equal(mcpAuthTokenInputValue(unchanged.mcpTools[0]), "recovered-secret");
+ assert.equal(deploymentMcpSecretValues(unchanged)[0].value, "recovered-secret");
+ assert.equal(sourcePreservingMcpSecretValues(unchanged)[0].value, "recovered-secret");
const prepared = prepareMcpAuth(unchanged).draft.mcpTools[0];
assert.equal(
prepared.authTokenEnv,
"MCP_SALES_AGENT_ORDERS_AUTH_TOKEN",
);
assert.equal(prepared.credentialSourceUrl, undefined);
- assert.equal(prepared.credentialUpdate, undefined);
});
test("derives configured and removed MCP keys across nested agent graphs", () => {
@@ -477,7 +468,9 @@ test("derives configured and removed MCP keys across nested agent graphs", () =>
...node,
agent: {
...node.agent,
- mcpTools: node.agent.mcpTools.map(clearMcpConfiguredAuth),
+ mcpTools: node.agent.mcpTools.map((tool) =>
+ updateMcpAuthTokenInput(tool, ""),
+ ),
},
})),
},
diff --git a/frontend/tests/runtimeAgentIdentity.test.mjs b/frontend/tests/runtimeAgentIdentity.test.mjs
index 97e8e45f0..f0ffca58c 100644
--- a/frontend/tests/runtimeAgentIdentity.test.mjs
+++ b/frontend/tests/runtimeAgentIdentity.test.mjs
@@ -630,5 +630,5 @@ test("Runtime update hydration starts from cloud configuration without local dra
/hydrateA2aRegistryFromRuntime\([\s\S]*?capability\.runtime\.envs/,
);
assert.doesNotMatch(handler, /\bnextDraft\b|draftEnvValues/);
- assert.match(handler, /setImportedDraft\(classifiedDraft\)/);
+ assert.match(handler, /setImportedDraft\(editorDraft\)/);
});
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index db287a703..d609345c4 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -3694,6 +3694,32 @@ async def request(
},
headers={"X-VeADK-Local-User": "developer"},
)
+ credential_request = {
+ "runtimeId": runtime.runtime_id,
+ "region": "cn-beijing",
+ "appName": "selected-agent",
+ "etag": response.json()["etag"],
+ }
+ credentials = client.post(
+ "/web/runtime-mcp-credentials",
+ json=credential_request,
+ headers={"X-VeADK-Local-User": "developer"},
+ )
+ stale_credentials = client.post(
+ "/web/runtime-mcp-credentials",
+ json={**credential_request, "etag": "stale-update-snapshot"},
+ headers={"X-VeADK-Local-User": "developer"},
+ )
+ forbidden_credentials = client.post(
+ "/web/runtime-mcp-credentials",
+ json=credential_request,
+ headers={"X-VeADK-Local-User": "other-developer"},
+ )
+ no_permission_credentials = client.post(
+ "/web/runtime-mcp-credentials",
+ json=credential_request,
+ headers={"X-VeADK-Local-User": "viewer"},
+ )
runtime.envs = [
*[
item
@@ -3766,6 +3792,30 @@ async def request(
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
+ assert credentials.status_code == 200
+ assert credentials.headers["cache-control"] == "no-store"
+ assert credentials.headers["pragma"] == "no-cache"
+ assert credentials.json() == {
+ "credentials": [
+ {
+ "agentName": "selected-agent",
+ "name": "orders",
+ "url": "https://mcp.example.com/mcp",
+ "authTokenEnv": "MCP_API_KEY",
+ "value": "mcp-secret",
+ },
+ {
+ "agentName": "selected-agent",
+ "name": "inventory",
+ "url": "https://mcp.example.com/inventory",
+ "authTokenEnv": "PUBLISHED_INVENTORY_TOKEN",
+ "value": "structured-secret",
+ },
+ ]
+ }
+ assert stale_credentials.status_code == 409
+ assert forbidden_credentials.status_code == 404
+ assert no_permission_credentials.status_code == 403
assert cached_response.status_code == 200
assert "authToken" not in (cached_response.json()["agent"]["draft"]["mcpTools"][0])
assert "mcp-secret-rotated" not in cached_response.text
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 785273a9c..bbe6f74bf 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -1269,6 +1269,15 @@ class _DeleteFeedbackCasesRequest(BaseModel):
item_ids: list[str] = Field(alias="itemIds", min_length=1, max_length=100)
+class _RuntimeMcpCredentialsRequest(BaseModel):
+ """Exact update snapshot whose MCP credentials should enter the editor."""
+
+ runtime_id: str = Field(alias="runtimeId", min_length=1, max_length=128)
+ region: str = Field(default="", min_length=0, max_length=64)
+ app_name: str = Field(alias="appName", min_length=1, max_length=128)
+ etag: str = Field(min_length=1, max_length=256)
+
+
def _mount_session_trace_route(app: Any, memory_exporter: Any) -> None:
"""Expose the session trace endpoint used by the VeADK frontend."""
@@ -4596,6 +4605,7 @@ async def _agentkit_proxy(request: Request, path: str):
ImageReference,
LegacyRecoveryError,
merge_mcp_recoveries,
+ mcp_editor_credential_values,
mcp_editor_draft_without_credentials,
mcp_reuse_supplied_credentials,
mcp_secret_values_for_draft_references,
@@ -12865,6 +12875,81 @@ async def _web_runtime_update_capability(
headers=no_store_headers,
)
+ @app.post("/web/runtime-mcp-credentials")
+ async def _web_runtime_mcp_credentials(
+ credential_request: _RuntimeMcpCredentialsRequest,
+ request: Request,
+ ) -> Response:
+ """Restore MCP values only for one authorized, immutable edit snapshot."""
+
+ _require_agent_management(request)
+ region = _coerce_cloud_region(credential_request.region)
+ try:
+ payload, runtime = await _runtime_update_capability_details(
+ request,
+ runtime_id=credential_request.runtime_id,
+ region=region,
+ app_name=credential_request.app_name,
+ )
+ if (
+ not payload.get("canUpdate")
+ or payload.get("recoveryStatus") not in {"complete", "draft-only"}
+ or payload.get("etag") != credential_request.etag
+ ):
+ raise HTTPException(
+ status_code=409,
+ detail="Runtime 更新快照已变化,请重新打开智能体详情。",
+ )
+ agent = payload.get("agent")
+ draft = agent.get("draft") if isinstance(agent, Mapping) else None
+ if not isinstance(draft, Mapping):
+ raise HTTPException(
+ status_code=409,
+ detail="Runtime 的 MCP 配置无法恢复,请重新打开智能体详情。",
+ )
+
+ references = mcp_auth_environment_keys(draft)
+ environment = _legacy_runtime_environment(runtime)
+ recovered_values = {
+ reference: environment[reference]
+ for reference in references
+ if environment.get(reference)
+ }
+ if set(references).difference(recovered_values):
+ recovery, legacy_values = _legacy_mcp_state(runtime, region)
+ recovered_values.update(
+ mcp_secret_values_for_draft_references(
+ draft=draft,
+ recovery=recovery,
+ recovered_values=legacy_values,
+ )
+ )
+ if set(references).difference(recovered_values):
+ raise LegacyRecoveryError("legacy_mcp_credential_missing")
+ credentials = mcp_editor_credential_values(
+ draft=draft,
+ recovered_values=recovered_values,
+ )
+ except HTTPException:
+ raise
+ except LegacyRecoveryError as error:
+ logger.info(
+ "MCP editor credential recovery unavailable runtime_id=%s "
+ "region=%s code=%s",
+ credential_request.runtime_id,
+ region,
+ error.code,
+ )
+ raise HTTPException(
+ status_code=409,
+ detail="Runtime 的 MCP 认证信息无法恢复,请重新配置 Key 后重试。",
+ ) from error
+
+ return JSONResponse(
+ {"credentials": credentials},
+ headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
+ )
+
@app.post("/web/evaluation/feedback")
async def _web_message_feedback(
feedback: _MessageFeedbackRequest,
diff --git a/veadk/cli/legacy_runtime_recovery.py b/veadk/cli/legacy_runtime_recovery.py
index c962443bd..88964f30a 100644
--- a/veadk/cli/legacy_runtime_recovery.py
+++ b/veadk/cli/legacy_runtime_recovery.py
@@ -48,6 +48,7 @@
"McpRecovery",
"merge_mcp_recoveries",
"mcp_reuse_supplied_credentials",
+ "mcp_editor_credential_values",
"mcp_editor_draft_without_credentials",
"mcp_secret_values_for_draft_references",
"mcp_secret_values_from_runtime_environment",
@@ -1130,6 +1131,56 @@ def mcp_editor_draft_without_credentials(
return sanitized
+def mcp_editor_credential_values(
+ *,
+ draft: Mapping[str, Any],
+ recovered_values: Mapping[str, str],
+) -> tuple[dict[str, str], ...]:
+ """Return only credentials bound to MCP slots in one trusted draft.
+
+ This helper is intentionally separate from the ordinary editor payload.
+ Callers must enforce Runtime ownership/update permissions and return the
+ result only from a non-cacheable response.
+ """
+
+ credentials: list[dict[str, str]] = []
+ seen_references: set[str] = set()
+ for agent_name, (_path, node) in _draft_node_index(draft).items():
+ raw_tools = node.get("mcpTools")
+ if raw_tools is None:
+ continue
+ if not isinstance(raw_tools, list):
+ raise LegacyRecoveryError("legacy_overlay_mcp_invalid")
+ for raw_tool in raw_tools:
+ if not isinstance(raw_tool, Mapping):
+ raise LegacyRecoveryError("legacy_overlay_mcp_invalid")
+ if str(raw_tool.get("transport") or "http") != "http":
+ continue
+ reference = str(raw_tool.get("authTokenEnv") or "").strip()
+ if not reference:
+ continue
+ if (
+ re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", reference) is None
+ or reference in seen_references
+ ):
+ raise LegacyRecoveryError("legacy_mcp_auth_reference_invalid")
+ seen_references.add(reference)
+ value = str(recovered_values.get(reference) or "")
+ if not value:
+ continue
+ url = _safe_mcp_url(raw_tool.get("url"))
+ credentials.append(
+ {
+ "agentName": agent_name,
+ "name": str(raw_tool.get("name") or "").strip(),
+ "url": url,
+ "authTokenEnv": reference,
+ "value": _validated_secret(value),
+ }
+ )
+ return tuple(credentials)
+
+
def _source_preserving_mcp_configuration(
draft: Mapping[str, Any],
) -> dict[str, list[dict[str, Any]]]:
From bb4b4c5f8f9bd024d92dcd68b257a7b08347aef3 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 9 Sep 2026 22:20:25 +0800
Subject: [PATCH 02/46] fix(studio): preserve explicit MCP credentials in debug
---
frontend/src/create/CustomCreate.tsx | 10 ++-
frontend/src/create/deploymentEnv.ts | 15 ++++
frontend/tests/deploymentEnv.test.mjs | 58 +++++++++++++++
...enerated_agent_backend_codegen_extended.py | 73 ++++++++++++++++++-
veadk/cli/cli_frontend.py | 62 ++++++++++------
5 files changed, 188 insertions(+), 30 deletions(-)
diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx
index 4d72a19a0..a8124b0b5 100644
--- a/frontend/src/create/CustomCreate.tsx
+++ b/frontend/src/create/CustomCreate.tsx
@@ -79,6 +79,7 @@ import {
firstMissingRuntimeEnv,
firstInvalidRuntimeEnv,
runtimeEnvConfiguration,
+ runtimeDebugEnvVars,
runtimeEnvJsonError,
runtimeEnvVars,
type RuntimeEnvConfiguration,
@@ -3196,10 +3197,11 @@ function debugRuntimeDraft(
modelApiKeyId: draft.deployment?.modelApiKeyId ?? "",
modelApiKeyName: draft.deployment?.modelApiKeyName ?? "",
envValues: Object.fromEntries(
- runtimeEnvVars(runtimeEnv.specs, values).map(({ key, value }) => [
- key,
- value,
- ]),
+ runtimeDebugEnvVars(
+ runtimeEnv.specs,
+ values,
+ prepareMcpAuth(draft).envValues,
+ ).map(({ key, value }) => [key, value]),
),
},
};
diff --git a/frontend/src/create/deploymentEnv.ts b/frontend/src/create/deploymentEnv.ts
index 8354c9395..00dede025 100644
--- a/frontend/src/create/deploymentEnv.ts
+++ b/frontend/src/create/deploymentEnv.ts
@@ -99,6 +99,21 @@ export function runtimeEnvVars(
return [...env].map(([key, value]) => ({ key, value }));
}
+/** Keep explicit MCP credentials in debug requests without exposing other managed secrets. */
+export function runtimeDebugEnvVars(
+ specs: RuntimeEnvSpec[],
+ values: Record,
+ explicitMcpEnvValues: Record,
+): { key: string; value: string }[] {
+ const env = new Map(
+ runtimeEnvVars(specs, values).map(({ key, value }) => [key, value]),
+ );
+ for (const [key, value] of Object.entries(explicitMcpEnvValues)) {
+ if (value.trim()) env.set(key, value);
+ }
+ return [...env].map(([key, value]) => ({ key, value }));
+}
+
export function firstMissingRuntimeEnv(
specs: RuntimeEnvSpec[],
values: Record,
diff --git a/frontend/tests/deploymentEnv.test.mjs b/frontend/tests/deploymentEnv.test.mjs
index 45d48b8f0..f090439fa 100644
--- a/frontend/tests/deploymentEnv.test.mjs
+++ b/frontend/tests/deploymentEnv.test.mjs
@@ -26,6 +26,7 @@ const {
runtimeEnvJsonError,
runtimeEnvMissingError,
runtimeEnvRequirementHint,
+ runtimeDebugEnvVars,
runtimeEnvVars,
} = await loadTypeScriptModule("../src/create/deploymentEnv.ts");
const {
@@ -280,6 +281,63 @@ test("server-managed secrets are displayed but excluded from browser deploy payl
assert.equal(firstMissingRuntimeEnv(specs, {}), undefined);
});
+test("debug payload restores only explicit MCP secrets hidden by resilience", () => {
+ const specs = [
+ { key: "PUBLIC_SETTING", required: false },
+ {
+ key: "MCP_AGENT_PRIMARY_AUTH_TOKEN",
+ required: false,
+ secret: true,
+ readOnly: true,
+ serverManaged: true,
+ hidden: true,
+ },
+ {
+ key: "MCP_AGENT_SECONDARY_AUTH_TOKEN",
+ required: false,
+ secret: true,
+ readOnly: true,
+ serverManaged: true,
+ hidden: true,
+ },
+ {
+ key: "MODEL_AGENT_API_KEY",
+ required: true,
+ secret: true,
+ readOnly: true,
+ serverManaged: true,
+ },
+ ];
+ const values = {
+ PUBLIC_SETTING: "enabled",
+ MCP_AGENT_PRIMARY_AUTH_TOKEN: "primary-test-secret",
+ MCP_AGENT_SECONDARY_AUTH_TOKEN: "secondary-test-secret",
+ MODEL_AGENT_API_KEY: "must-remain-server-managed",
+ };
+
+ assert.deepEqual(
+ runtimeDebugEnvVars(specs, values, {
+ MCP_AGENT_PRIMARY_AUTH_TOKEN: "primary-test-secret",
+ MCP_AGENT_SECONDARY_AUTH_TOKEN: "secondary-test-secret",
+ }),
+ [
+ { key: "PUBLIC_SETTING", value: "enabled" },
+ {
+ key: "MCP_AGENT_PRIMARY_AUTH_TOKEN",
+ value: "primary-test-secret",
+ },
+ {
+ key: "MCP_AGENT_SECONDARY_AUTH_TOKEN",
+ value: "secondary-test-secret",
+ },
+ ],
+ );
+ assert.match(
+ customCreateSource,
+ /runtimeDebugEnvVars\([\s\S]*?prepareMcpAuth\(draft\)\.envValues/,
+ );
+});
+
test("defaults knowledgebase creation to VikingDB collections", () => {
assert.equal(DEFAULT_KB_BACKEND, "viking");
assert.equal(KB_BACKENDS[0].id, "viking");
diff --git a/tests/cli/test_generated_agent_backend_codegen_extended.py b/tests/cli/test_generated_agent_backend_codegen_extended.py
index 3ed88532b..578ec5a01 100644
--- a/tests/cli/test_generated_agent_backend_codegen_extended.py
+++ b/tests/cli/test_generated_agent_backend_codegen_extended.py
@@ -1161,7 +1161,8 @@ def test_local_generated_debug_allows_private_mcp(
monkeypatch.delenv("VEADK_STUDIO_FUNCTION_ID", raising=False)
monkeypatch.delenv("_FAAS_FUNC_ID", raising=False)
- async def keep_mcp_endpoints(draft):
+ async def keep_mcp_endpoints(draft, env_values=None):
+ assert env_values == {}
return draft
monkeypatch.setattr(
@@ -1249,8 +1250,9 @@ def test_cloud_generated_debug_preserves_mcp_connection_error(
monkeypatch.setenv("_FAAS_FUNC_ID", "function-test")
original_detail = "MCP 工具 `offline` 连接失败:原始连接错误"
- async def fail_mcp_discovery(draft):
+ async def fail_mcp_discovery(draft, env_values=None):
del draft
+ assert env_values == {}
raise McpDebugConnectionError(original_detail)
monkeypatch.setattr(
@@ -1291,6 +1293,7 @@ async def fail_mcp_discovery(draft):
"expect_credential",
"explicit_reuse",
"has_published_draft",
+ "submitted_credential",
),
[
(
@@ -1302,6 +1305,18 @@ async def fail_mcp_discovery(draft):
True,
False,
True,
+ False,
+ ),
+ (
+ "reference-env",
+ "jvmdiag",
+ "https://8.8.8.8/mcp",
+ "https://8.8.8.8/mcp",
+ 200,
+ True,
+ False,
+ True,
+ True,
),
(
"reference-env",
@@ -1312,6 +1327,7 @@ async def fail_mcp_discovery(draft):
True,
False,
True,
+ False,
),
(
"servers-json",
@@ -1322,6 +1338,7 @@ async def fail_mcp_discovery(draft):
True,
False,
True,
+ False,
),
(
"servers-json",
@@ -1332,6 +1349,7 @@ async def fail_mcp_discovery(draft):
True,
False,
True,
+ False,
),
(
"servers-json",
@@ -1342,6 +1360,7 @@ async def fail_mcp_discovery(draft):
False,
False,
True,
+ False,
),
(
"missing",
@@ -1352,6 +1371,18 @@ async def fail_mcp_discovery(draft):
False,
False,
True,
+ False,
+ ),
+ (
+ "missing",
+ "",
+ "https://8.8.8.8/mysqldiag",
+ "https://8.8.8.8/mysqldiag",
+ 200,
+ True,
+ False,
+ True,
+ True,
),
(
"servers-json",
@@ -1362,6 +1393,7 @@ async def fail_mcp_discovery(draft):
True,
True,
True,
+ False,
),
(
"reference-env",
@@ -1372,6 +1404,7 @@ async def fail_mcp_discovery(draft):
False,
True,
False,
+ False,
),
(
"reference-env",
@@ -1382,6 +1415,7 @@ async def fail_mcp_discovery(draft):
False,
True,
True,
+ False,
),
],
)
@@ -1396,12 +1430,16 @@ def test_generated_debug_applies_published_mcp_credential_contract_before_discov
expect_credential: bool,
explicit_reuse: bool,
has_published_draft: bool,
+ submitted_credential: bool,
) -> None:
from agentkit.sdk.runtime.client import AgentkitRuntimeClient
from veadk.cli.generated_agent_mcp import McpDebugConnectionError
credential_reference = "MCP_LEGACY_AGENT_JVMDIAG_AUTH_TOKEN"
credential_value = "server-retained-debug-secret"
+ submitted_credential_value = "browser-submitted-debug-secret"
+ secondary_credential_reference = "MCP_LEGACY_AGENT_ATHENA_AUTH_TOKEN"
+ secondary_credential_value = "browser-submitted-secondary-secret"
published_draft = {
"name": "legacy_agent",
"description": "Existing Agent",
@@ -1512,6 +1550,22 @@ async def request(self, _method: str, url: str, **_kwargs: Any):
"runtimeId": runtime.runtime_id,
"runtimeRegion": "cn-shanghai",
}
+ if submitted_credential:
+ edited_draft["mcpTools"].append(
+ {
+ "name": "athena",
+ "transport": "http",
+ "url": "https://8.8.4.4/athena-mcp",
+ "authTokenEnv": secondary_credential_reference,
+ }
+ )
+ edited_draft["deployment"] = {
+ "envValues": {
+ credential_reference: submitted_credential_value,
+ secondary_credential_reference: secondary_credential_value,
+ "UNRELATED_SECRET": "must-not-reach-mcp-discovery",
+ }
+ }
if explicit_reuse:
payload["mcpCredentialReuses"] = [
{
@@ -1535,12 +1589,23 @@ async def request(self, _method: str, url: str, **_kwargs: Any):
assert "LegacyRecoveryError" not in response.text
assert "错误 ID" not in response.text
if expect_credential:
- assert captured_discovery_env[credential_reference] == credential_value
+ expected_credential = (
+ submitted_credential_value if submitted_credential else credential_value
+ )
+ assert captured_discovery_env[credential_reference] == expected_credential
+ if submitted_credential:
+ assert (
+ captured_discovery_env[secondary_credential_reference]
+ == secondary_credential_value
+ )
+ assert "UNRELATED_SECRET" not in captured_discovery_env
else:
assert credential_reference not in captured_discovery_env
- if credential_storage == "missing":
+ if credential_storage == "missing" and not submitted_credential:
assert "缺少可用凭证" in response.json()["detail"]
assert credential_value not in response.text
+ assert submitted_credential_value not in response.text
+ assert secondary_credential_value not in response.text
def test_debug_text_redacts_environment_and_inline_markers(
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index bbe6f74bf..0e760fb89 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -5225,18 +5225,29 @@ async def _generate_project_and_draft_from_request(
else _cloud_studio_private_networks
),
)
- if debug_mcp_env_values:
- draft = prepare_mcp_auth(draft)
- mcp_env_values = dict(draft.deployment.envValues)
- for key, value in debug_mcp_env_values.items():
- if value and not mcp_env_values.get(key):
- mcp_env_values[key] = value
- draft = await resolve_debug_mcp_endpoints(
- draft,
- mcp_env_values,
- )
- else:
- draft = await resolve_debug_mcp_endpoints(draft)
+ draft = prepare_mcp_auth(draft)
+ prepared_payload = draft.model_dump(
+ mode="json",
+ by_alias=True,
+ exclude_none=True,
+ )
+ prepared_references = mcp_auth_environment_keys(prepared_payload)
+ mcp_env_values = {
+ reference: draft.deployment.envValues[reference]
+ for reference in prepared_references
+ if draft.deployment.envValues.get(reference)
+ }
+ for key, value in (debug_mcp_env_values or {}).items():
+ if (
+ key in prepared_references
+ and value
+ and not mcp_env_values.get(key)
+ ):
+ mcp_env_values[key] = value
+ draft = await resolve_debug_mcp_endpoints(
+ draft,
+ mcp_env_values,
+ )
else:
validate_project_policy(draft)
project = generate_project_from_draft(draft)
@@ -5801,7 +5812,19 @@ async def _create_generated_agent_test_run(request: Request):
raise HTTPException(status_code=422, detail=error.errors()) from error
runtime_envs: dict[str, str] = {}
- debug_mcp_env_values: dict[str, str] = {}
+ edited_draft = test_request.draft.model_dump(
+ mode="json",
+ by_alias=True,
+ exclude_none=True,
+ )
+ requested_references = mcp_auth_environment_keys(edited_draft)
+ requested_env_values = test_request.draft.deployment.envValues
+ submitted_mcp_env_values = {
+ reference: requested_env_values[reference]
+ for reference in requested_references
+ if requested_env_values.get(reference)
+ }
+ debug_mcp_env_values = dict(submitted_mcp_env_values)
runtime_id = test_request.runtimeId.strip()
runtime_region = _coerce_cloud_region(test_request.runtimeRegion)
reuse_requests = tuple(
@@ -5814,13 +5837,6 @@ async def _create_generated_agent_test_run(request: Request):
detail="MCP credential reuse requires a Runtime update target",
)
if runtime_id:
- edited_draft = test_request.draft.model_dump(
- mode="json",
- by_alias=True,
- exclude_none=True,
- )
- requested_references = mcp_auth_environment_keys(edited_draft)
- requested_env_values = test_request.draft.deployment.envValues
stored_references = tuple(
reference
for reference in requested_references
@@ -5897,7 +5913,7 @@ async def _create_generated_agent_test_run(request: Request):
error.code,
)
try:
- debug_mcp_env_values = retained_mcp_secret_values(
+ recovered_mcp_env_values = retained_mcp_secret_values(
published_draft=published_draft,
edited_draft=edited_draft,
published_reference_values=(published_reference_values),
@@ -5909,12 +5925,14 @@ async def _create_generated_agent_test_run(request: Request):
published_reference_values=(published_reference_values),
reuse_requests=reuse_requests,
)
- debug_mcp_env_values.update(
+ recovered_mcp_env_values.update(
mcp_supplied_secret_values_by_reference(
edited_draft=edited_draft,
supplied_credentials=supplied_credentials,
)
)
+ recovered_mcp_env_values.update(submitted_mcp_env_values)
+ debug_mcp_env_values = recovered_mcp_env_values
except LegacyRecoveryError as error:
raise HTTPException(
status_code=409,
From 09b814188829117a14cb37dbf3a493ce919df039 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Thu, 10 Sep 2026 03:42:11 +0800
Subject: [PATCH 03/46] fix: resolve installed sidecar debug runtime
---
.../harness-sidecar-release-gate.yaml | 5 +
...enerated_agent_backend_codegen_extended.py | 101 ++++++++++++++++
.../test_generated_agent_sidecar_runtime.py | 110 ++++++++++++++++++
veadk/cli/cli_frontend.py | 27 ++++-
veadk/cli/generated_agent_sidecar_runtime.py | 67 +++++++++++
5 files changed, 309 insertions(+), 1 deletion(-)
create mode 100644 tests/cli/test_generated_agent_sidecar_runtime.py
create mode 100644 veadk/cli/generated_agent_sidecar_runtime.py
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index adeb7b300..bcba40f1c 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -10,12 +10,14 @@ on:
- 'tests/cli/test_managed_sidecar_source.py'
- 'tests/cli/test_legacy_runtime_recovery.py'
- 'tests/cli/test_generated_agent_backend_codegen_extended.py'
+ - 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
+ - 'veadk/cli/generated_agent_sidecar_runtime.py'
- 'veadk/cli/cli_frontend.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
@@ -30,12 +32,14 @@ on:
- 'tests/cli/test_managed_sidecar_source.py'
- 'tests/cli/test_legacy_runtime_recovery.py'
- 'tests/cli/test_generated_agent_backend_codegen_extended.py'
+ - 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
+ - 'veadk/cli/generated_agent_sidecar_runtime.py'
- 'veadk/cli/cli_frontend.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
@@ -76,6 +80,7 @@ jobs:
tests/extensions/harness/test_sidecar_public_api.py \
tests/extensions/harness/test_extension.py \
tests/cli/test_generated_agent_harness_sidecar.py \
+ tests/cli/test_generated_agent_sidecar_runtime.py \
tests/cli/test_managed_sidecar_source.py \
tests/integrations/agentkit/test_app.py \
tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \
diff --git a/tests/cli/test_generated_agent_backend_codegen_extended.py b/tests/cli/test_generated_agent_backend_codegen_extended.py
index 578ec5a01..6af8e7951 100644
--- a/tests/cli/test_generated_agent_backend_codegen_extended.py
+++ b/tests/cli/test_generated_agent_backend_codegen_extended.py
@@ -19,6 +19,7 @@
import io
import json
import secrets
+import shlex
import socket
import sys
import zipfile
@@ -2068,6 +2069,15 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
monkeypatch.setenv("VEADK_STUDIO_HARNESS_SIDECAR_DEBUG_ENABLED", "true")
monkeypatch.setenv("HARNESS_SIDECAR_APIG_ENDPOINT", runtime_endpoint)
monkeypatch.setenv("HARNESS_SIDECAR_APIG_API_KEY", runtime_key)
+ monkeypatch.setattr(
+ cli_frontend,
+ "installed_harness_sidecar_runtime_command",
+ lambda: (
+ sys.executable,
+ "-m",
+ "veadk.cli.generated_agent_sidecar_runtime",
+ ),
+ )
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr("platform.machine", lambda: "x86_64")
monkeypatch.setattr(
@@ -2143,6 +2153,11 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
assert run_response.status_code == 200
assert run_response.json()["planHash"] == "sha256:test-plan"
process_env = _FakeProcess.created[-1].env
+ assert shlex.split(process_env["AGENTKIT_HARNESS_RUNTIME_COMMAND"]) == [
+ sys.executable,
+ "-m",
+ "veadk.cli.generated_agent_sidecar_runtime",
+ ]
assert process_env["HARNESS_SIDECAR_TRANSPORT"] == "apig_runtime_port"
assert process_env["HARNESS_SIDECAR_APIG_ENDPOINT"] == runtime_endpoint
assert process_env["HARNESS_SIDECAR_APIG_API_KEY"] == runtime_key
@@ -2159,6 +2174,92 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
]
+def test_generated_agent_sidecar_debug_fails_before_runner_without_runtime(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if sys.version_info[:2] != (3, 12):
+ pytest.skip("managed Sidecar debug Runtime requires CPython 3.12")
+
+ from veadk.extensions.harness import sidecar
+
+ captured: dict[str, Any] = {}
+ _FakeProcess.created.clear()
+ monkeypatch.setenv("VEADK_STUDIO_HARNESS_SIDECAR_DEBUG_ENABLED", "true")
+ monkeypatch.setenv(
+ "HARNESS_SIDECAR_APIG_ENDPOINT",
+ "https://runtime.example.com",
+ )
+ monkeypatch.setenv("HARNESS_SIDECAR_APIG_API_KEY", "fixture-runtime-key")
+ monkeypatch.setattr(sys, "platform", "linux")
+ monkeypatch.setattr("platform.machine", lambda: "x86_64")
+
+ def missing_runtime() -> tuple[str, ...]:
+ raise cli_frontend.GeneratedAgentSidecarRuntimeUnavailable
+
+ monkeypatch.setattr(
+ cli_frontend,
+ "installed_harness_sidecar_runtime_command",
+ missing_runtime,
+ )
+ monkeypatch.setattr(
+ sidecar,
+ "studio_harness_runtime_env",
+ lambda *_args, **_kwargs: pytest.fail(
+ "missing runtime must fail before Sidecar environment generation"
+ ),
+ )
+ monkeypatch.setenv("VOLCENGINE_ACCESS_KEY", "test-ak")
+ monkeypatch.setenv("VOLCENGINE_SECRET_KEY", "test-sk")
+ monkeypatch.setattr("dotenv.find_dotenv", lambda *args, **kwargs: "")
+ monkeypatch.setattr(
+ "uvicorn.run",
+ lambda app, **kwargs: captured.setdefault("app", app),
+ )
+
+ _run_frontend_server(
+ agents_dir=str(tmp_path),
+ frontend_dir=None,
+ site_logo=None,
+ site_title=None,
+ host="127.0.0.1",
+ port=8765,
+ dev=True,
+ vite=True,
+ oauth2_user_pool=None,
+ oauth2_user_pool_client=None,
+ oauth2_user_pool_uid=None,
+ oauth2_user_pool_client_uid=None,
+ oauth2_redirect_uri=None,
+ oauth2_provider=None,
+ oauth2_provider_label=None,
+ auth_mode="frontend",
+ generated_agent_test_run_ttl=60,
+ open_browser=False,
+ )
+ monkeypatch.setattr("subprocess.Popen", _FakeProcess)
+
+ with TestClient(captured["app"]) as client:
+ run_response = client.post(
+ "/web/generated-agent-test-runs",
+ json={
+ "draft": {
+ "name": "sidecar-agent",
+ "instruction": "Answer briefly.",
+ "harnessSidecar": {
+ "componentOverrides": {"mcp_resilience": True},
+ },
+ }
+ },
+ )
+
+ assert run_response.status_code == 409
+ assert run_response.json() == {
+ "detail": "当前 Studio 环境未安装 Harness Sidecar 调试运行时。"
+ }
+ assert _FakeProcess.created == []
+
+
def test_generated_agent_debug_allows_large_skill_projects(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
diff --git a/tests/cli/test_generated_agent_sidecar_runtime.py b/tests/cli/test_generated_agent_sidecar_runtime.py
new file mode 100644
index 000000000..9756db3ce
--- /dev/null
+++ b/tests/cli/test_generated_agent_sidecar_runtime.py
@@ -0,0 +1,110 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from importlib import metadata
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+
+import pytest
+
+from veadk.cli.generated_agent_sidecar_runtime import (
+ GeneratedAgentSidecarRuntimeUnavailable,
+ installed_harness_sidecar_runtime_command,
+)
+
+
+def test_installed_runtime_command_executes_distribution_entry_point(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ site_packages = tmp_path / "site-packages"
+ site_packages.mkdir()
+ (site_packages / "sidecar_runtime_fixture.py").write_text(
+ """import json
+import sys
+
+
+def main():
+ print(json.dumps({"argv": sys.argv[1:]}))
+ return 0
+""",
+ encoding="utf-8",
+ )
+ dist_info = site_packages / "bytedance_agentkit_harness_sidecar-0.0.test.dist-info"
+ dist_info.mkdir()
+ (dist_info / "METADATA").write_text(
+ """Metadata-Version: 2.1
+Name: bytedance-agentkit-harness-sidecar
+Version: 0.0.test
+""",
+ encoding="utf-8",
+ )
+ (dist_info / "entry_points.txt").write_text(
+ """[console_scripts]
+agentkit-harness-sidecar-runtime = sidecar_runtime_fixture:main
+""",
+ encoding="utf-8",
+ )
+ monkeypatch.syspath_prepend(str(site_packages))
+
+ command = installed_harness_sidecar_runtime_command()
+ environment = os.environ.copy()
+ source_root = str(Path(__file__).resolve().parents[2])
+ environment["PYTHONPATH"] = os.pathsep.join(
+ value
+ for value in (
+ str(site_packages),
+ source_root,
+ environment.get("PYTHONPATH", ""),
+ )
+ if value
+ )
+ completed = subprocess.run(
+ [*command, "doctor", "--json"],
+ cwd=tmp_path,
+ env=environment,
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+
+ assert command == (
+ sys.executable,
+ "-m",
+ "veadk.cli.generated_agent_sidecar_runtime",
+ )
+ assert completed.returncode == 0
+ assert json.loads(completed.stdout) == {"argv": ["doctor", "--json"]}
+ assert completed.stderr == ""
+
+
+def test_installed_runtime_command_fails_closed_without_distribution(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def missing_distribution(_name: str) -> metadata.Distribution:
+ raise metadata.PackageNotFoundError
+
+ monkeypatch.setattr(metadata, "distribution", missing_distribution)
+
+ with pytest.raises(
+ GeneratedAgentSidecarRuntimeUnavailable,
+ match="debug runtime is not installed",
+ ):
+ installed_harness_sidecar_runtime_command()
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 0e760fb89..89f109993 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -30,6 +30,7 @@
import os
import queue
import re
+import shlex
import shutil
import sys
import tempfile
@@ -51,6 +52,10 @@
from frontend.server.agentkit_clients import create_agentkit_client
from veadk.cli.agentkit_sandbox_region import is_agentkit_resource_not_found
from veadk.cli.frontend_branding import normalize_site_title, resolve_site_logo
+from veadk.cli.generated_agent_sidecar_runtime import (
+ GeneratedAgentSidecarRuntimeUnavailable,
+ installed_harness_sidecar_runtime_command,
+)
from veadk.cli.managed_sidecar_source import (
ManagedSidecarSourceError,
stage_managed_sidecar_veadk_source,
@@ -4687,7 +4692,18 @@ def _harness_sidecar_debug_capability() -> dict[str, Any]:
"当前 Studio Runtime 尚未完成 Harness Sidecar APIG 自调用绑定。"
),
}
- return {"available": True, "reason": ""}
+ try:
+ runtime_command = installed_harness_sidecar_runtime_command()
+ except GeneratedAgentSidecarRuntimeUnavailable:
+ return {
+ "available": False,
+ "reason": "当前 Studio 环境未安装 Harness Sidecar 调试运行时。",
+ }
+ return {
+ "available": True,
+ "reason": "",
+ "runtimeCommand": runtime_command,
+ }
def _harness_sidecar_deployment_capability() -> dict[str, Any]:
from veadk.cli.agentkit_cli import AgentKitCliError, agentkit_cli_artifact
@@ -5982,6 +5998,15 @@ async def _create_generated_agent_test_run(request: Request):
],
}
)
+ runtime_command = capability.get("runtimeCommand")
+ if not isinstance(runtime_command, tuple) or not runtime_command:
+ raise HTTPException(
+ status_code=409,
+ detail="当前 Studio 环境未安装 Harness Sidecar 调试运行时。",
+ )
+ sidecar_env["AGENTKIT_HARNESS_RUNTIME_COMMAND"] = shlex.join(
+ str(item) for item in runtime_command
+ )
requested_plan_hash = draft.harnessSidecar.planHash or ""
if (
requested_plan_hash
diff --git a/veadk/cli/generated_agent_sidecar_runtime.py b/veadk/cli/generated_agent_sidecar_runtime.py
new file mode 100644
index 000000000..9606ecab8
--- /dev/null
+++ b/veadk/cli/generated_agent_sidecar_runtime.py
@@ -0,0 +1,67 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Launch the installed private Sidecar runtime for Studio debug runs."""
+
+from __future__ import annotations
+
+from importlib import metadata
+import sys
+from typing import Any
+
+
+_RUNTIME_DISTRIBUTION = "bytedance-agentkit-harness-sidecar"
+_RUNTIME_ENTRY_POINT = "agentkit-harness-sidecar-runtime"
+_RUNTIME_MODULE = "veadk.cli.generated_agent_sidecar_runtime"
+
+
+class GeneratedAgentSidecarRuntimeUnavailable(RuntimeError):
+ """The private debug runtime is not installed with a unique entry point."""
+
+
+def _installed_runtime_entry_point() -> metadata.EntryPoint:
+ try:
+ distribution = metadata.distribution(_RUNTIME_DISTRIBUTION)
+ except metadata.PackageNotFoundError as error:
+ raise GeneratedAgentSidecarRuntimeUnavailable(
+ "Harness Sidecar debug runtime is not installed"
+ ) from error
+ matches = [
+ entry_point
+ for entry_point in distribution.entry_points
+ if entry_point.group == "console_scripts"
+ and entry_point.name == _RUNTIME_ENTRY_POINT
+ ]
+ if len(matches) != 1:
+ raise GeneratedAgentSidecarRuntimeUnavailable(
+ "Harness Sidecar debug runtime entry point is unavailable"
+ )
+ return matches[0]
+
+
+def installed_harness_sidecar_runtime_command() -> tuple[str, ...]:
+ """Return a command that resolves the private entry point at execution time."""
+
+ _installed_runtime_entry_point()
+ return (sys.executable, "-m", _RUNTIME_MODULE)
+
+
+def main() -> Any:
+ """Delegate to the trusted console entry point from the private distribution."""
+
+ return _installed_runtime_entry_point().load()()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From c27fc407399594dea774a96466aaa9aa940f4113 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Thu, 10 Sep 2026 06:05:53 +0800
Subject: [PATCH 04/46] fix: propagate sidecar debug MCP upstreams
---
...enerated_agent_backend_codegen_extended.py | 42 +++++++++++++++++++
veadk/cli/cli_frontend.py | 40 +++++++++++++++++-
2 files changed, 81 insertions(+), 1 deletion(-)
diff --git a/tests/cli/test_generated_agent_backend_codegen_extended.py b/tests/cli/test_generated_agent_backend_codegen_extended.py
index 6af8e7951..25749ce50 100644
--- a/tests/cli/test_generated_agent_backend_codegen_extended.py
+++ b/tests/cli/test_generated_agent_backend_codegen_extended.py
@@ -2103,6 +2103,14 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
lambda app, **kwargs: captured.setdefault("app", app),
)
+ async def preserve_debug_mcp_endpoints(draft, env_values=None):
+ return draft
+
+ monkeypatch.setattr(
+ "veadk.cli.generated_agent_mcp.resolve_debug_mcp_endpoints",
+ preserve_debug_mcp_endpoints,
+ )
+
_run_frontend_server(
agents_dir=str(tmp_path),
frontend_dir=None,
@@ -2143,9 +2151,29 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
"draft": {
"name": "sidecar-agent",
"instruction": "Answer briefly.",
+ "mcpTools": [
+ {
+ "name": "catalog",
+ "transport": "http",
+ "url": "https://mcp.example.test/catalog/mcp",
+ "authTokenEnv": "MCP_CATALOG_AUTH_TOKEN",
+ },
+ {
+ "name": "fulfillment",
+ "transport": "http",
+ "url": "https://mcp.example.test/fulfillment/mcp",
+ "authTokenEnv": "MCP_FULFILLMENT_AUTH_TOKEN",
+ },
+ ],
"harnessSidecar": {
"componentOverrides": {"mcp_resilience": True},
},
+ "deployment": {
+ "envValues": {
+ "MCP_CATALOG_AUTH_TOKEN": "catalog-test-token",
+ "MCP_FULFILLMENT_AUTH_TOKEN": ("fulfillment-test-token"),
+ }
+ },
}
},
)
@@ -2161,6 +2189,20 @@ def test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan(
assert process_env["HARNESS_SIDECAR_TRANSPORT"] == "apig_runtime_port"
assert process_env["HARNESS_SIDECAR_APIG_ENDPOINT"] == runtime_endpoint
assert process_env["HARNESS_SIDECAR_APIG_API_KEY"] == runtime_key
+ assert json.loads(process_env["MCP_SERVERS_JSON"]) == [
+ {
+ "name": "catalog",
+ "url": "https://mcp.example.test/catalog/mcp",
+ "headers": {"Authorization": "Bearer catalog-test-token"},
+ },
+ {
+ "name": "fulfillment",
+ "url": "https://mcp.example.test/fulfillment/mcp",
+ "headers": {"Authorization": "Bearer fulfillment-test-token"},
+ },
+ ]
+ assert "MCP_CATALOG_AUTH_TOKEN" not in process_env
+ assert "MCP_FULFILLMENT_AUTH_TOKEN" not in process_env
assert runtime_key not in run_response.text
assert _FakeAsyncClient.gateway_requests == [
{
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 89f109993..4f2dae4ff 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -5972,8 +5972,10 @@ async def _create_generated_agent_test_run(request: Request):
validated_test_request=test_request,
debug_mcp_env_values=debug_mcp_env_values,
)
+ debug_runtime_env = debug_runtime_env_from_draft(draft)
sidecar_env: dict[str, str] = {}
sidecar_plan: dict[str, Any] | None = None
+ sidecar_mcp_references: tuple[str, ...] = ()
if draft.harnessSidecar and draft.harnessSidecar.enabled:
capability = _harness_sidecar_debug_capability()
if not capability["available"]:
@@ -6016,6 +6018,40 @@ async def _create_generated_agent_test_run(request: Request):
status_code=409,
detail="Harness Sidecar 配置已更新,请重新解析后再启动调试。",
)
+ effective_components = {
+ str(item) for item in sidecar_plan.get("effectiveComponents") or []
+ }
+ if "mcp_resilience" in effective_components:
+ sidecar_mcp_draft = draft.model_dump(
+ mode="json",
+ by_alias=True,
+ exclude_none=True,
+ )
+ try:
+ structured_mcp = build_sidecar_mcp_servers_json(
+ draft=sidecar_mcp_draft,
+ secret_values={
+ **debug_runtime_env,
+ **debug_mcp_env_values,
+ },
+ )
+ except LegacyRecoveryError as error:
+ raise HTTPException(
+ status_code=409,
+ detail=_mcp_deployment_error_detail(error.code),
+ ) from error
+ if not json.loads(structured_mcp):
+ raise HTTPException(
+ status_code=409,
+ detail=(
+ "已选择 MCP 稳定性治理,请配置至少一个 HTTP MCP "
+ "服务地址后重新启动调试。"
+ ),
+ )
+ sidecar_env["MCP_SERVERS_JSON"] = structured_mcp
+ sidecar_mcp_references = mcp_auth_environment_keys(
+ sidecar_mcp_draft
+ )
temp_dir = tempfile.mkdtemp(prefix="veadk_generated_agent_test_")
app_name = _write_generated_project(project, temp_dir)
staged_environment_skills = ""
@@ -6050,7 +6086,9 @@ async def _create_generated_agent_test_run(request: Request):
if key.startswith("HARNESS_"):
runner_env.pop(key)
runner_env.update(sidecar_env)
- runner_env.update(debug_runtime_env_from_draft(draft))
+ runner_env.update(debug_runtime_env)
+ for reference in sidecar_mcp_references:
+ runner_env.pop(reference, None)
selected_api_key_id = draft.deployment.modelApiKeyId.strip()
selected_api_key_name = draft.deployment.modelApiKeyName.strip()
From 2d437f09c86a6d2e219bc78ede4c364f1dfee925 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Thu, 10 Sep 2026 10:41:12 +0800
Subject: [PATCH 05/46] fix: extend generated debug startup timeout
---
frontend/src/adk/client.ts | 26 ++++++++++++++++----------
frontend/tests/timeout.test.mjs | 21 +++++++++++++++++++++
2 files changed, 37 insertions(+), 10 deletions(-)
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts
index 0d1c1dc50..69517b6ba 100644
--- a/frontend/src/adk/client.ts
+++ b/frontend/src/adk/client.ts
@@ -4982,6 +4982,7 @@ export interface GeneratedAgentDraftResult {
}
const GENERATED_AGENT_DRAFT_TIMEOUT_MS = 190_000;
+const GENERATED_AGENT_TEST_RUN_TIMEOUT_MS = 120_000;
export async function generateAgentDraftFromRequirement(
requirement: string,
@@ -5015,16 +5016,21 @@ export async function createGeneratedAgentTestRun(
}>;
},
): Promise {
- const res = await apiFetch("/web/generated-agent-test-runs", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- draft,
- runtimeId: runtime?.runtimeId,
- runtimeRegion: runtime?.region,
- mcpCredentialReuses: runtime?.mcpCredentialReuses,
- }),
- });
+ const res = await apiFetch(
+ "/web/generated-agent-test-runs",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ draft,
+ runtimeId: runtime?.runtimeId,
+ runtimeRegion: runtime?.region,
+ mcpCredentialReuses: runtime?.mcpCredentialReuses,
+ }),
+ },
+ {},
+ GENERATED_AGENT_TEST_RUN_TIMEOUT_MS,
+ );
if (!res.ok) {
throw new Error(await httpErrorMessage(res, adkT("client.createDebugRunFailed")));
}
diff --git a/frontend/tests/timeout.test.mjs b/frontend/tests/timeout.test.mjs
index 0c2a6ffa2..85bab7cfb 100644
--- a/frontend/tests/timeout.test.mjs
+++ b/frontend/tests/timeout.test.mjs
@@ -50,3 +50,24 @@ test("chat, deployment, and debug streams explicitly disable deadlines", () => {
assert.match(deployment, /\{\},\s+0,/);
assert.match(debug, /\{\},\s+0,/);
});
+
+test("generated-agent creation deadline exceeds both backend readiness windows", () => {
+ const declaration = clientSource.match(
+ /const GENERATED_AGENT_TEST_RUN_TIMEOUT_MS = ([\d_]+);/,
+ );
+ assert.ok(declaration, "generated-agent creation needs a dedicated deadline");
+ const timeoutMs = Number(declaration[1].replaceAll("_", ""));
+ assert.ok(
+ timeoutMs > 60_000,
+ "the client must outlive both sequential 30-second backend readiness windows",
+ );
+
+ const createRun = functionSource(
+ "export async function createGeneratedAgentTestRun",
+ "export async function createGeneratedAgentTestSession",
+ );
+ assert.match(
+ createRun,
+ /apiFetch\([\s\S]*?\},\s*\{\},\s*GENERATED_AGENT_TEST_RUN_TIMEOUT_MS,\s*\)/,
+ );
+});
From bb378060ca0c260191f07ba947c6a8d3d261e82d Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Thu, 10 Sep 2026 20:39:25 +0800
Subject: [PATCH 06/46] fix: harden studio update recovery
---
.../harness-sidecar-release-gate.yaml | 43 ++++--
.../studio_release_server/tos_store.py | 34 ++++-
frontend/src/App.tsx | 3 +-
frontend/src/adk/client.ts | 112 +++++++++++---
frontend/src/adk/deploymentStatus.ts | 47 ++++++
frontend/src/ui/ProjectPreview.tsx | 2 +-
frontend/tests/agentWorkspace.test.mjs | 18 ++-
.../tests/debugErrorPresentation.test.mjs | 8 +-
frontend/tests/deploymentStatus.test.mjs | 139 ++++++++++++++++++
tests/cli/test_studio_rbac.py | 138 ++++++++++++++++-
.../extensions/harness/test_studio_sidecar.py | 55 +++++++
tests/test_harness_sidecar_release_gate.py | 52 +++++++
tests/test_studio_release_server.py | 99 ++++++++++++-
veadk/cli/cli_frontend.py | 125 +++++++++++++++-
veadk/extensions/harness/sidecar.py | 27 ++++
15 files changed, 849 insertions(+), 53 deletions(-)
create mode 100644 tests/test_harness_sidecar_release_gate.py
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index bcba40f1c..38e053b53 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -15,6 +15,8 @@ on:
- 'tests/cli/test_studio_rbac.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
+ - 'tests/test_harness_sidecar_release_gate.py'
+ - 'tests/test_studio_release_server.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
@@ -37,6 +39,8 @@ on:
- 'tests/cli/test_studio_rbac.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
+ - 'tests/test_harness_sidecar_release_gate.py'
+ - 'tests/test_studio_release_server.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
@@ -51,9 +55,10 @@ permissions:
contents: read
jobs:
- python:
+ backend-gate:
+ name: Backend gate
runs-on: ubuntu-latest
- timeout-minutes: 30
+ timeout-minutes: 3
steps:
- uses: actions/checkout@v4
@@ -72,7 +77,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- checks=(coverage lifecycle credentials)
+ checks=(coverage lifecycle credentials release)
pids=()
(
python -m pytest -q \
@@ -100,12 +105,22 @@ jobs:
python -m pytest -q \
tests/cli/test_generated_agent_mcp.py \
tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_debug_applies_published_mcp_credential_contract_before_discovery \
+ tests/cli/test_studio_rbac.py::test_deployment_status_recovers_completed_update_from_fresh_instance \
tests/cli/test_studio_rbac.py::test_new_deployment_only_updates_non_default_instance_range \
tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \
+ tests/cli/test_studio_rbac.py::test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk \
tests/cli/test_studio_rbac.py::test_application_owned_mcp_update_routes_cover_reuse_and_additions \
tests/cli/test_studio_rbac.py::test_sidecar_update_resolves_or_explicitly_reuses_stored_mcp_credentials
) > "$RUNNER_TEMP/sidecar-credentials.log" 2>&1 &
pids+=("$!")
+ (
+ python -m pytest -q \
+ tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \
+ tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
+ tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
+ tests/test_harness_sidecar_release_gate.py
+ ) > "$RUNNER_TEMP/sidecar-release.log" 2>&1 &
+ pids+=("$!")
status=0
for index in "${!pids[@]}"; do
@@ -118,9 +133,10 @@ jobs:
done
exit "$status"
- frontend:
+ frontend-gate:
+ name: Frontend gate
runs-on: ubuntu-latest
- timeout-minutes: 30
+ timeout-minutes: 3
steps:
- uses: actions/checkout@v4
@@ -143,15 +159,18 @@ jobs:
run: npm test
gate:
- if: always()
- needs: [python, frontend]
+ name: Gate
+ if: ${{ always() }}
+ needs:
+ - backend-gate
+ - frontend-gate
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 1
steps:
- - name: Require every Sidecar check to pass
+ - name: Require both parallel gates
env:
- PYTHON_RESULT: ${{ needs.python.result }}
- FRONTEND_RESULT: ${{ needs.frontend.result }}
+ BACKEND_RESULT: ${{ needs.backend-gate.result }}
+ FRONTEND_RESULT: ${{ needs.frontend-gate.result }}
run: |
- test "$PYTHON_RESULT" = success
+ test "$BACKEND_RESULT" = success
test "$FRONTEND_RESULT" = success
diff --git a/frontend/service/studio_release_server/tos_store.py b/frontend/service/studio_release_server/tos_store.py
index 675627ef8..015cdbaa8 100644
--- a/frontend/service/studio_release_server/tos_store.py
+++ b/frontend/service/studio_release_server/tos_store.py
@@ -19,6 +19,8 @@
import hashlib
import json
import os
+import re
+import urllib.parse
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
@@ -34,13 +36,14 @@
_IAM_CREDENTIAL_PATH = Path("/var/run/secrets/iam/credential")
_MAX_DEPENDENCY_WHEEL_BYTES = 128 * 1024 * 1024
_PYPI_FILE_HOST = "https://files.pythonhosted.org"
-_AGENTKIT_CLI_ARCHIVE_URL_PREFIX = (
- "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/"
-)
_PYPI_MIRROR_HOSTS = (
"https://pypi.tuna.tsinghua.edu.cn",
"https://mirrors.aliyun.com/pypi",
)
+_AGENTKIT_CLI_RELEASE_HOST = "agentkit-cli.tos-cn-beijing.volces.com"
+_AGENTKIT_CLI_VERSION_PATTERN = re.compile(
+ r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)" r"(?:[-+][0-9A-Za-z.-]+)?$"
+)
@dataclass(frozen=True)
@@ -349,14 +352,35 @@ def _load_manifest(self, manifest: Path) -> tuple[tuple[str, str, str], ...]:
raise ValueError(
"Studio dependency manifest is invalid."
) from error
- if filename == "agentkit-linux-x64.tar.gz" and not url.startswith(
- _AGENTKIT_CLI_ARCHIVE_URL_PREFIX
+ if filename == "agentkit-linux-x64.tar.gz" and not (
+ self._valid_agentkit_cli_url(url, filename)
):
raise ValueError("Studio dependency manifest is invalid.")
filenames.add(filename)
dependencies.append((filename, url, sha256.lower()))
return tuple(dependencies)
+ @staticmethod
+ def _valid_agentkit_cli_url(url: str, filename: str) -> bool:
+ """Validate one manifest-pinned CLI source without pinning its version."""
+ try:
+ parsed = urllib.parse.urlsplit(url)
+ path_parts = parsed.path.split("/")
+ version = path_parts[1] if len(path_parts) == 3 else ""
+ return bool(
+ parsed.scheme == "https"
+ and parsed.netloc == _AGENTKIT_CLI_RELEASE_HOST
+ and parsed.username is None
+ and parsed.password is None
+ and parsed.port is None
+ and not parsed.query
+ and not parsed.fragment
+ and path_parts == ["", version, filename]
+ and _AGENTKIT_CLI_VERSION_PATTERN.fullmatch(version)
+ )
+ except ValueError:
+ return False
+
def _cache_key(self, filename: str, sha256: str) -> str:
prefix = self._settings.job_prefix.strip().strip("/")
return f"{prefix}/dependency-cache/{sha256}/{filename}"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 310d41e08..a5ec553ff 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2630,8 +2630,7 @@ export default function App() {
}));
}
updateDeploymentTask(linkedTask);
- openDeploymentDetail(linkedTask);
- }, [editingDraftId, flushPendingWorkspaceDraft, openDeploymentDetail, updateDeploymentTask]);
+ }, [editingDraftId, flushPendingWorkspaceDraft, updateDeploymentTask]);
const finishDeployment = useCallback(
async (result: DeployResult) => {
diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts
index 69517b6ba..c63033d03 100644
--- a/frontend/src/adk/client.ts
+++ b/frontend/src/adk/client.ts
@@ -21,6 +21,7 @@ import {
DeploymentStatusUnconfirmedError,
isDeploymentAbortError,
isDeploymentStatusUnconfirmedError,
+ pollDeploymentRecovery,
} from "./deploymentStatus";
import {
DEFAULT_REQUEST_TIMEOUT_MS,
@@ -3226,6 +3227,16 @@ interface DeployFrame extends Partial {
phase?: string;
}
+class DeploymentRecoveryHttpError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "DeploymentRecoveryHttpError";
+ }
+}
+
const deploymentControllers = new Map();
function parseGithubCicdErrorDetail(detail: unknown): GithubCicdPipelineErrorDetail | null {
@@ -3553,8 +3564,60 @@ export async function deployAgentkitProject(
deploymentControllers.delete(taskId);
}
};
+ const recoverFinal = async (cause?: unknown): Promise => {
+ if (isDeploymentAbortError(cause)) throw cause;
+ if (
+ !taskId ||
+ !opts?.runtimeId ||
+ typeof opts.baseRuntimeVersion !== "number"
+ ) {
+ throw new DeploymentStatusUnconfirmedError({ taskId, cause });
+ }
+ const recovered = await pollDeploymentRecovery({
+ signal: controller?.signal,
+ shouldRetry: (error) =>
+ !(error instanceof DeploymentRecoveryHttpError) ||
+ error.status === 404 ||
+ error.status === 408 ||
+ error.status === 429 ||
+ error.status >= 500,
+ load: async (signal) => {
+ const statusResponse = await apiFetch(
+ "/web/deploy-agentkit/status",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ signal,
+ body: JSON.stringify({
+ taskId,
+ runtimeId: opts.runtimeId,
+ runtimeName: opts.runtimeName,
+ appName: opts.appName ?? name,
+ region: config.region,
+ projectName: config.projectName,
+ baseRuntimeVersion: opts.baseRuntimeVersion,
+ }),
+ },
+ {},
+ 30_000,
+ );
+ if (!statusResponse.ok) {
+ throw new DeploymentRecoveryHttpError(
+ statusResponse.status,
+ await httpErrorMessage(statusResponse, adkT("client.deploymentFailed")),
+ );
+ }
+ return statusResponse.json() as Promise;
+ },
+ });
+ if (!recovered) {
+ throw new DeploymentStatusUnconfirmedError({ taskId, cause });
+ }
+ return recovered;
+ };
- let res: Response;
+ let res: Response | null = null;
+ let final: DeployFrame | null = null;
try {
const migrationSource = Boolean(opts?.migrationTaskId);
opts?.onStage?.({
@@ -3613,34 +3676,45 @@ export async function deployAgentkitProject(
pct: 100,
});
} catch (error) {
- clearController();
- if (isDeploymentAbortError(error)) throw error;
- throw new DeploymentStatusUnconfirmedError({ taskId, cause: error });
+ try {
+ final = await recoverFinal(error);
+ } finally {
+ clearController();
+ }
}
- if (!res.ok) {
+ if (res && !res.ok) {
const detail = await httpErrorMessage(res, adkT("client.deploymentFailed"));
clearController();
throw new Error(detail);
}
- let final: DeployFrame | null = null;
- try {
- for await (const raw of parseSSE(res)) {
- const ev = raw as DeployFrame & DeployStage;
- if (ev && ev.done) {
- final = ev;
- break;
+ if (res) {
+ try {
+ for await (const raw of parseSSE(res)) {
+ const ev = raw as DeployFrame & DeployStage;
+ if (ev && ev.done) {
+ final = ev;
+ break;
+ }
+ if (ev && ev.message) opts?.onStage?.(ev);
+ }
+ } catch (error) {
+ try {
+ final = await recoverFinal(error);
+ } finally {
+ clearController();
}
- if (ev && ev.message) opts?.onStage?.(ev);
}
- } catch (error) {
+ }
+ if (!final) {
+ try {
+ final = await recoverFinal();
+ } finally {
+ clearController();
+ }
+ } else {
clearController();
- if (isDeploymentAbortError(error)) throw error;
- throw new DeploymentStatusUnconfirmedError({ taskId, cause: error });
}
- clearController();
-
- if (!final) throw new DeploymentStatusUnconfirmedError({ taskId });
if (!final.success) {
const error = new Error(final.error || adkT("client.deploymentFailed"));
if (isDeploymentStatusUnconfirmedError(error)) {
diff --git a/frontend/src/adk/deploymentStatus.ts b/frontend/src/adk/deploymentStatus.ts
index 81c2a02f1..5d2cd69e4 100644
--- a/frontend/src/adk/deploymentStatus.ts
+++ b/frontend/src/adk/deploymentStatus.ts
@@ -30,3 +30,50 @@ export function isDeploymentAbortError(error: unknown): boolean {
error.name === "AbortError",
);
}
+
+export async function pollDeploymentRecovery({
+ load,
+ signal,
+ timeoutMs = 30 * 60_000,
+ intervalMs = 2_000,
+ shouldRetry = () => true,
+}: {
+ load: (signal?: AbortSignal) => Promise;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ intervalMs?: number;
+ shouldRetry?: (error: unknown) => boolean;
+}): Promise {
+ if (signal?.aborted) {
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
+ }
+ const deadline = Date.now() + Math.max(0, timeoutMs);
+ while (!signal?.aborted && Date.now() <= deadline) {
+ try {
+ const status = await load(signal);
+ if (status.done) return status;
+ } catch (error) {
+ if (signal?.aborted || isDeploymentAbortError(error)) throw error;
+ if (!shouldRetry(error)) throw error;
+ // Instance replacement can also interrupt an individual status read.
+ // Keep polling until the bounded recovery window expires.
+ }
+ if (signal?.aborted) {
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
+ }
+ if (intervalMs <= 0) continue;
+ await new Promise((resolve, reject) => {
+ const onAbort = () => {
+ globalThis.clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
+ };
+ const timer = globalThis.setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, intervalMs);
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+ }
+ return null;
+}
diff --git a/frontend/src/ui/ProjectPreview.tsx b/frontend/src/ui/ProjectPreview.tsx
index 7b204a439..fcbf917c1 100644
--- a/frontend/src/ui/ProjectPreview.tsx
+++ b/frontend/src/ui/ProjectPreview.tsx
@@ -675,7 +675,7 @@ export interface ProjectPreviewProps {
deploymentRuntimeNameCustomized?: boolean;
/** Updates the explicit Runtime name for a new deployment. */
onDeploymentRuntimeNameChange?: (runtimeName: string) => void;
- /** Opens the persistent Agent detail as soon as deployment starts. */
+ /** Persists the deployment task without forcing the active editor to navigate. */
onDeploymentStarted?: (task: DeploymentTaskUpdate) => void;
/** Mirrors deployment progress into the app shell so it survives page switches. */
onDeploymentTaskChange?: (task: DeploymentTaskUpdate) => void;
diff --git a/frontend/tests/agentWorkspace.test.mjs b/frontend/tests/agentWorkspace.test.mjs
index 09c148bf7..ae2686fff 100644
--- a/frontend/tests/agentWorkspace.test.mjs
+++ b/frontend/tests/agentWorkspace.test.mjs
@@ -501,10 +501,6 @@ test("workspace publish flow restores PR 748 deployment lifecycle hooks", () =>
projectPreviewSource,
/await onDeploymentComplete\?\.\(result\)[\s\S]*?catch \(error\)[\s\S]*?error instanceof RuntimeProbeError[\s\S]*?status: "success"[\s\S]*?label: t\("projectPreview\.task\.deployedNotConnected"\)[\s\S]*?message: error\.message/,
);
- assert.match(
- appSource,
- /const startDeployment = useCallback[\s\S]*?flushPendingWorkspaceDraft\(\)[\s\S]*?draftId: editingDraftId[\s\S]*?updateDeploymentTask\(linkedTask\)[\s\S]*?openDeploymentDetail\(linkedTask\)/,
- );
assert.doesNotMatch(workspaceSource, /aw-deployment-focus/);
assert.match(
workspaceSource,
@@ -588,6 +584,20 @@ test("workspace publish flow restores PR 748 deployment lifecycle hooks", () =>
);
});
+test("starting a deployment persists the task without leaving the active editor", () => {
+ const startDeploymentSource = appSource.slice(
+ appSource.indexOf("const startDeployment = useCallback"),
+ appSource.indexOf("const finishDeployment = useCallback"),
+ );
+
+ assert.match(
+ startDeploymentSource,
+ /flushPendingWorkspaceDraft\(\)[\s\S]*?draftId: editingDraftId[\s\S]*?updateDeploymentTask\(linkedTask\)/,
+ );
+ assert.doesNotMatch(startDeploymentSource, /openDeploymentDetail\(/);
+ assert.match(appSource, /onViewDeploymentTask=\{openDeploymentDetail\}/);
+});
+
test("runtime update deployments stay on the existing agent row", () => {
assert.match(workspaceSource, /const updateDraftByRuntimeId = useMemo/);
assert.match(workspaceSource, /const latestTaskByRuntimeId = useMemo/);
diff --git a/frontend/tests/debugErrorPresentation.test.mjs b/frontend/tests/debugErrorPresentation.test.mjs
index cd640866a..0ff30674d 100644
--- a/frontend/tests/debugErrorPresentation.test.mjs
+++ b/frontend/tests/debugErrorPresentation.test.mjs
@@ -77,11 +77,15 @@ test("creation and deployment keep friendly context and the original error", ()
);
assert.match(
clientSource,
- /catch \(error\) \{[\s\S]*?isDeploymentAbortError\(error\)[\s\S]*?new DeploymentStatusUnconfirmedError\(\{ taskId, cause: error \}\)/,
+ /const recoverFinal = async \(cause\?: unknown\)[\s\S]*?isDeploymentAbortError\(cause\)[\s\S]*?new DeploymentStatusUnconfirmedError\(\{ taskId, cause \}\)/,
);
assert.match(
clientSource,
- /if \(!final\) throw new DeploymentStatusUnconfirmedError\(\{ taskId \}\)/,
+ /catch \(error\) \{[\s\S]*?final = await recoverFinal\(error\)[\s\S]*?if \(!final\) \{[\s\S]*?final = await recoverFinal\(\)/,
+ );
+ assert.match(
+ clientSource,
+ /pollDeploymentRecovery[\s\S]*?\/web\/deploy-agentkit\/status[\s\S]*?baseRuntimeVersion: opts\.baseRuntimeVersion/,
);
assert.match(
projectPreviewSource,
diff --git a/frontend/tests/deploymentStatus.test.mjs b/frontend/tests/deploymentStatus.test.mjs
index 493988177..7fb2268d8 100644
--- a/frontend/tests/deploymentStatus.test.mjs
+++ b/frontend/tests/deploymentStatus.test.mjs
@@ -21,7 +21,9 @@ async function loadTypeScriptModule(relativePath) {
const {
DeploymentStatusUnconfirmedError,
isDeploymentStatusUnconfirmedError,
+ pollDeploymentRecovery,
} = await loadTypeScriptModule("../src/adk/deploymentStatus.ts");
+const { deployAgentkitProject } = await loadTypeScriptModule("../src/adk/client.ts");
test("classifies only explicitly ambiguous deployment outcomes as unconfirmed", () => {
const transport = new DeploymentStatusUnconfirmedError({
@@ -54,3 +56,140 @@ test("preserves task identity without exposing the transport detail", () => {
assert.equal(error.taskId, "task-2");
assert.doesNotMatch(error.message, /private upstream detail/);
});
+
+test("polls cloud-authoritative deployment status until a fresh instance confirms it", async () => {
+ const observations = [
+ { done: false, status: "running" },
+ { done: true, success: true, runtimeId: "runtime-1", version: 4 },
+ ];
+ let calls = 0;
+
+ const recovered = await pollDeploymentRecovery({
+ load: async () => observations[calls++],
+ timeoutMs: 1_000,
+ intervalMs: 0,
+ });
+
+ assert.equal(calls, 2);
+ assert.deepEqual(recovered, observations[1]);
+});
+
+test("fails fast when deployment status polling sees a non-retryable error", async () => {
+ const fatal = new Error("forbidden");
+ let calls = 0;
+
+ await assert.rejects(
+ pollDeploymentRecovery({
+ load: async () => {
+ calls += 1;
+ throw fatal;
+ },
+ shouldRetry: () => false,
+ timeoutMs: 1_000,
+ intervalMs: 0,
+ }),
+ fatal,
+ );
+ assert.equal(calls, 1);
+});
+
+test("propagates user cancellation while recovery is polling", async () => {
+ const controller = new AbortController();
+
+ await assert.rejects(
+ pollDeploymentRecovery({
+ signal: controller.signal,
+ load: async () => {
+ controller.abort();
+ return { done: false };
+ },
+ timeoutMs: 1_000,
+ intervalMs: 100,
+ }),
+ { name: "AbortError" },
+ );
+});
+
+test("recovers an accepted Runtime update when the initial fetch loses response headers", async () => {
+ const originalFetch = globalThis.fetch;
+ const originalWindow = globalThis.window;
+ const originalSessionStorage = globalThis.sessionStorage;
+ const originalLocalStorage = globalThis.localStorage;
+ const storage = {
+ getItem: () => null,
+ setItem: () => {},
+ removeItem: () => {},
+ };
+ globalThis.window = {
+ location: {
+ search: "",
+ origin: "https://studio.example.com",
+ pathname: "/",
+ hash: "",
+ },
+ history: { replaceState: () => {} },
+ };
+ globalThis.sessionStorage = storage;
+ globalThis.localStorage = storage;
+ let deployCalls = 0;
+ let statusCalls = 0;
+ globalThis.fetch = async (input, init) => {
+ const url = String(input);
+ if (url.endsWith("/web/deploy-agentkit")) {
+ deployCalls += 1;
+ throw new TypeError("network error before response headers");
+ }
+ if (url.endsWith("/web/deploy-agentkit/status")) {
+ statusCalls += 1;
+ assert.deepEqual(JSON.parse(String(init?.body)), {
+ taskId: "update-task-before-headers",
+ runtimeId: "runtime-1",
+ runtimeName: "runtime-name",
+ appName: "updated-agent",
+ region: "cn-shanghai",
+ projectName: "default",
+ baseRuntimeVersion: 3,
+ });
+ return new Response(
+ JSON.stringify({
+ done: true,
+ success: true,
+ agentName: "updated-agent",
+ runtimeName: "runtime-name",
+ runtimeId: "runtime-1",
+ region: "cn-shanghai",
+ version: 4,
+ url: "https://runtime.example.com",
+ apikey: "runtime-key",
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ throw new Error(`unexpected request: ${url}`);
+ };
+
+ try {
+ const result = await deployAgentkitProject(
+ "updated-agent",
+ [{ path: "app.py", content: "print('ok')\n" }],
+ { region: "cn-shanghai", projectName: "default" },
+ {
+ taskId: "update-task-before-headers",
+ runtimeId: "runtime-1",
+ runtimeName: "runtime-name",
+ appName: "updated-agent",
+ baseRuntimeVersion: 3,
+ },
+ );
+
+ assert.equal(deployCalls, 1, "the accepted deployment must not be replayed");
+ assert.equal(statusCalls, 1);
+ assert.equal(result.runtimeId, "runtime-1");
+ assert.equal(result.runtimeName, "runtime-name");
+ } finally {
+ globalThis.fetch = originalFetch;
+ globalThis.window = originalWindow;
+ globalThis.sessionStorage = originalSessionStorage;
+ globalThis.localStorage = originalLocalStorage;
+ }
+});
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index d609345c4..d53a18cab 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -15,6 +15,7 @@
"""Tests for Studio role and Runtime ownership policy."""
import base64
+import hashlib
import itertools
import json
import logging
@@ -5275,7 +5276,7 @@ def launch(*, config_file: str, config_dict: Any, **_kwargs: Any) -> Any:
assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == ("legacy")
-def test_source_preserving_sidecar_update_reuses_exact_image_via_sdk(
+def test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -5295,7 +5296,7 @@ def test_source_preserving_sidecar_update_reuses_exact_image_via_sdk(
)
runtime.envs = [
SimpleNamespace(key="HARNESS_SIDECAR_ENABLED", value="true"),
- SimpleNamespace(key="HARNESS_PROFILE", value="default"),
+ SimpleNamespace(key="HARNESS_PROFILE", value="ops"),
SimpleNamespace(
key="HARNESS_SIDECAR_CATALOG_VERSION",
value="2026.07.1",
@@ -5313,7 +5314,7 @@ def test_source_preserving_sidecar_update_reuses_exact_image_via_sdk(
),
SimpleNamespace(
key="HARNESS_SIDECAR_EXPECTED_PLAN_HASH",
- value="sha256:test-plan",
+ value="sha256:published-plan",
),
SimpleNamespace(
key="MCP_SERVERS_JSON",
@@ -5504,6 +5505,47 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
}
assert "authToken" not in draft["mcpTools"][0]
assert "sidecar-test-secret" not in capability.text
+ submitted_sidecar = {
+ **draft["harnessSidecar"],
+ "componentOverrides": {
+ "context_engine": True,
+ "compressor": False,
+ "verifier": True,
+ "long_run_control": True,
+ "mcp_resilience": True,
+ },
+ "catalogVersion": "2026.09.1",
+ "planHash": "sha256:test-plan",
+ }
+ draft["harnessSidecar"] = submitted_sidecar
+ changed_sidecar = {
+ **submitted_sidecar,
+ "componentOverrides": {
+ "context_engine": True,
+ "compressor": True,
+ "verifier": True,
+ "long_run_control": True,
+ "mcp_resilience": True,
+ },
+ }
+ changed_selection = client.post(
+ "/web/deploy-agentkit",
+ headers=headers,
+ json={
+ "name": "sidecar-agent",
+ "runtimeId": runtime.runtime_id,
+ "appName": "sidecar-agent",
+ "editMode": "source-preserving",
+ "draft": draft,
+ "harnessSidecar": changed_sidecar,
+ "updateEtag": capability.json()["etag"],
+ "baseRuntimeVersion": 9,
+ "minInstance": 1,
+ "maxInstance": 1,
+ "files": [{"path": "app.py", "content": "must-be-ignored\n"}],
+ "config": {"region": "cn-shanghai", "projectName": "default"},
+ },
+ )
with client.stream(
"POST",
"/web/deploy-agentkit",
@@ -5514,7 +5556,7 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
"appName": "sidecar-agent",
"editMode": "source-preserving",
"draft": draft,
- "harnessSidecar": draft["harnessSidecar"],
+ "harnessSidecar": submitted_sidecar,
"updateEtag": capability.json()["etag"],
"baseRuntimeVersion": 9,
"minInstance": 1,
@@ -5529,6 +5571,10 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
if line.startswith("data: ")
]
+ assert changed_selection.status_code == 409
+ assert changed_selection.json()["detail"] == (
+ "Harness Sidecar 组件选择已变化,请重新打开详情并确认后再更新。"
+ )
assert response.status_code == 200
assert frames[-1]["success"] is True
assert captured["dockerfile"].splitlines()[0].endswith("@sha256:" + "c" * 64)
@@ -5548,6 +5594,85 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == ("sidecar")
+def test_deployment_status_recovers_completed_update_from_fresh_instance(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ from agentkit.sdk.runtime.client import AgentkitRuntimeClient
+
+ task_id = "deploy-recovery-task"
+ runtime = _runtime_with_public_endpoint(
+ _runtime("runtime-developer", "developer", managed=True)
+ )
+ runtime.name = "recovered-runtime"
+ runtime.status = "Ready"
+ runtime.current_version_number = 4
+ runtime.tags.append(
+ SimpleNamespace(
+ key="veadk:deployment-task-sha256",
+ value=hashlib.sha256(task_id.encode()).hexdigest(),
+ )
+ )
+ monkeypatch.setattr(
+ AgentkitRuntimeClient,
+ "get_runtime",
+ lambda _self, _request: runtime,
+ )
+ monkeypatch.setattr(
+ "agentkit.toolkit.sdk.launch",
+ lambda **_kwargs: pytest.fail("status recovery must not replay deployment"),
+ )
+
+ # A newly constructed app has an empty process-local deployment task table.
+ app = _create_studio_app(monkeypatch, tmp_path, developers="developer")
+ with TestClient(app) as client:
+ response = client.post(
+ "/web/deploy-agentkit/status",
+ headers={"X-VeADK-Local-User": "developer"},
+ json={
+ "taskId": task_id,
+ "runtimeId": runtime.runtime_id,
+ "runtimeName": runtime.name,
+ "appName": "updated-agent",
+ "region": "cn-shanghai",
+ "baseRuntimeVersion": 3,
+ },
+ )
+ superseded = client.post(
+ "/web/deploy-agentkit/status",
+ headers={"X-VeADK-Local-User": "developer"},
+ json={
+ "taskId": "different-deployment-task",
+ "runtimeId": runtime.runtime_id,
+ "runtimeName": runtime.name,
+ "appName": "updated-agent",
+ "region": "cn-shanghai",
+ "baseRuntimeVersion": 3,
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json() == {
+ "done": True,
+ "success": True,
+ "agentName": "updated-agent",
+ "runtimeName": runtime.name,
+ "url": "https://runtime.example.com",
+ "apikey": "runtime-key",
+ "runtimeId": runtime.runtime_id,
+ "consoleUrl": (
+ "https://console.volcengine.com/agentkit/"
+ "region:agentkit+cn-shanghai/runtime?projectName=default"
+ ),
+ "region": "cn-shanghai",
+ "version": 4,
+ }
+ assert superseded.status_code == 200
+ assert superseded.json()["done"] is True
+ assert superseded.json()["success"] is False
+ assert "其他部署" in superseded.json()["error"]
+
+
@pytest.mark.parametrize(
"evaluation_error",
[None, "evaluation workspace unavailable"],
@@ -5819,6 +5944,7 @@ async def _mark_validated_oauth_token(request: Request, call_next):
headers={"X-VeADK-Local-User": "developer"},
json={
"name": "updated-agent",
+ "taskId": "update-deployment-task",
"description": "Updated\n description 🤖",
"runtimeId": runtime.runtime_id,
"appName": "updated-agent",
@@ -5968,6 +6094,10 @@ async def _mark_validated_oauth_token(request: Request, call_next):
assert "cp_pipeline_name" not in cloud
assert captured_config["common"]["description"] == "Updated description"
updated_tags = {tag.key: tag.value for tag in update_requests[-1].tags}
+ assert (
+ updated_tags["veadk:deployment-task-sha256"]
+ == hashlib.sha256(b"update-deployment-task").hexdigest()
+ )
assert updated_tags["veadk:environment-id"] == "default"
assert "veadk:environment-version" not in updated_tags
assert updated_tags["veadk:owner"] == "developer"
diff --git a/tests/extensions/harness/test_studio_sidecar.py b/tests/extensions/harness/test_studio_sidecar.py
index 6c22d9601..30152bc5b 100644
--- a/tests/extensions/harness/test_studio_sidecar.py
+++ b/tests/extensions/harness/test_studio_sidecar.py
@@ -147,6 +147,61 @@ def test_normalize_studio_intent_preserves_ops_profile() -> None:
assert all(intent.component_overrides.values())
+def test_selectable_intent_signature_canonicalizes_only_fixed_ops_defaults() -> None:
+ legacy_ops = sidecar.studio_harness_selectable_intent_signature(
+ {
+ "profile": "ops",
+ "componentOverrides": {"mcp_resilience": True},
+ }
+ )
+ canonical_ops = sidecar.studio_harness_selectable_intent_signature(
+ {
+ "profile": "ops",
+ "componentOverrides": {
+ "context_engine": True,
+ "compressor": False,
+ "verifier": True,
+ "long_run_control": True,
+ "mcp_resilience": True,
+ },
+ }
+ )
+ changed_ops = sidecar.studio_harness_selectable_intent_signature(
+ {
+ "profile": "ops",
+ "componentOverrides": {
+ "context_engine": True,
+ "compressor": True,
+ "verifier": True,
+ "long_run_control": True,
+ "mcp_resilience": True,
+ },
+ }
+ )
+ sparse_default = sidecar.studio_harness_selectable_intent_signature(
+ {
+ "profile": "default",
+ "componentOverrides": {"mcp_resilience": True},
+ }
+ )
+ expanded_default = sidecar.studio_harness_selectable_intent_signature(
+ {
+ "profile": "default",
+ "componentOverrides": {
+ "context_engine": True,
+ "compressor": False,
+ "verifier": True,
+ "long_run_control": True,
+ "mcp_resilience": True,
+ },
+ }
+ )
+
+ assert legacy_ops == canonical_ops
+ assert changed_ops != canonical_ops
+ assert sparse_default != expanded_default
+
+
def test_normalize_studio_intent_rejects_hidden_sql_override() -> None:
with pytest.raises(ValueError, match="sql_readonly"):
sidecar.normalize_studio_harness_intent(
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
new file mode 100644
index 000000000..8b36ee76d
--- /dev/null
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import yaml
+
+
+WORKFLOW = (
+ Path(__file__).parents[1] / ".github/workflows/harness-sidecar-release-gate.yaml"
+)
+
+
+def _run_script(job: dict[str, object]) -> str:
+ steps = job.get("steps")
+ assert isinstance(steps, list)
+ return "\n".join(
+ str(step.get("run") or "") for step in steps if isinstance(step, dict)
+ )
+
+
+def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
+ workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
+ jobs = workflow["jobs"]
+
+ assert set(jobs) == {"backend-gate", "frontend-gate", "gate"}
+ backend = jobs["backend-gate"]
+ frontend = jobs["frontend-gate"]
+ aggregate = jobs["gate"]
+ assert "needs" not in backend
+ assert "needs" not in frontend
+ assert backend["timeout-minutes"] == 3
+ assert frontend["timeout-minutes"] == 3
+ assert set(aggregate["needs"]) == {"backend-gate", "frontend-gate"}
+ assert aggregate["if"] == "${{ always() }}"
+
+ backend_run = _run_script(backend)
+ frontend_run = _run_script(frontend)
+ aggregate_run = _run_script(aggregate)
+ assert "test_studio_sidecar.py" in backend_run
+ assert "test_legacy_runtime_recovery.py" in backend_run
+ assert (
+ "test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk"
+ in backend_run
+ )
+ assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run
+ assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
+ backend_run
+ )
+ assert "npm run test:harness-sidecar-coverage" in frontend_run
+ assert "npm test" in frontend_run
+ assert "BACKEND_RESULT" in aggregate_run
+ assert "FRONTEND_RESULT" in aggregate_run
diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py
index 5f635315e..d99de71a5 100644
--- a/tests/test_studio_release_server.py
+++ b/tests/test_studio_release_server.py
@@ -63,9 +63,9 @@ def test_release_server_agentkit_cli_pin_matches_veadk() -> None:
assert release_publisher._AGENTKIT_CLI_ARCHIVE == artifact.filename
assert release_publisher._AGENTKIT_CLI_ARCHIVE_SHA256 == artifact.sha256
- assert (
- release_tos_store._AGENTKIT_CLI_ARCHIVE_URL_PREFIX
- == artifact.url.removesuffix(artifact.filename)
+ assert release_tos_store.TosDependencyStore._valid_agentkit_cli_url(
+ artifact.url,
+ artifact.filename,
)
@@ -1655,6 +1655,99 @@ def _urlopen(_url: str, *, timeout: int) -> io.BytesIO:
assert downloads == 2
+def test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version(
+ tmp_path: Path,
+) -> None:
+ manifest = tmp_path / "dependencies.json"
+ manifest.write_text(
+ json.dumps(
+ {
+ "wheels": [
+ {
+ "filename": "dependency.whl",
+ "url": "https://example.com/dependency.whl",
+ "sha256": "a" * 64,
+ }
+ ],
+ "artifacts": [
+ {
+ "filename": "agentkit-linux-x64.tar.gz",
+ "url": (
+ "https://agentkit-cli.tos-cn-beijing.volces.com/"
+ "0.52.18/agentkit-linux-x64.tar.gz"
+ ),
+ "sha256": "b" * 64,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ store = TosDependencyStore(
+ _settings(),
+ client_factory=lambda: _DependencyCacheClient(),
+ )
+
+ dependencies = store._load_manifest(manifest)
+
+ assert dependencies[-1] == (
+ "agentkit-linux-x64.tar.gz",
+ (
+ "https://agentkit-cli.tos-cn-beijing.volces.com/"
+ "0.52.18/agentkit-linux-x64.tar.gz"
+ ),
+ "b" * 64,
+ )
+
+
+@pytest.mark.parametrize(
+ "url",
+ (
+ "http://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz",
+ "https://example.com/0.52.18/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com.evil.invalid/0.52.18/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/latest/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/other.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/nested/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz?download=1",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz#fragment",
+ ),
+)
+def test_tos_dependency_store_rejects_untrusted_agentkit_cli_url(
+ tmp_path: Path,
+ url: str,
+) -> None:
+ manifest = tmp_path / "dependencies.json"
+ manifest.write_text(
+ json.dumps(
+ {
+ "wheels": [
+ {
+ "filename": "dependency.whl",
+ "url": "https://example.com/dependency.whl",
+ "sha256": "a" * 64,
+ }
+ ],
+ "artifacts": [
+ {
+ "filename": "agentkit-linux-x64.tar.gz",
+ "url": url,
+ "sha256": "b" * 64,
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ store = TosDependencyStore(
+ _settings(),
+ client_factory=lambda: _DependencyCacheClient(),
+ )
+
+ with pytest.raises(ValueError, match="manifest is invalid"):
+ store._load_manifest(manifest)
+
+
def test_builder_restores_manifest_dependencies_from_cache(tmp_path: Path) -> None:
prepared_root = tmp_path / ".studio-release"
prepared_root.mkdir()
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 4f2dae4ff..d5332e322 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -155,6 +155,8 @@ def _mcp_deployment_error_detail(code: str) -> str:
return "无法沿用原 MCP 凭证,请重新打开详情后重新确认或填写 Key。"
if code == "legacy_platform_mcp_read_only":
return "运行版本中的 Skill 或 MCP 配置已变化,请重新打开详情并确认最新配置后再更新。"
+ if code == "legacy_overlay_sidecar_intent_changed":
+ return "Harness Sidecar 组件选择已变化,请重新打开详情并确认后再更新。"
return "Harness Sidecar MCP 配置无效,请检查名称、地址与认证后重试。"
@@ -216,6 +218,7 @@ def _click_param_from_commandline(name: str) -> bool:
_CP_BUILD_LOG_ERROR_TAIL_CHECK_CHARS = 1024
_DEPLOY_STREAM_HEARTBEAT_SECONDS = 15.0
_DEPLOY_STREAM_POLL_SECONDS = 0.1
+_RUNTIME_DEPLOYMENT_TASK_TAG = "veadk:deployment-task-sha256"
_AGENTKIT_RUNTIME_READY_TIMEOUT_MS = "900000"
_DEPLOY_PHASE_ORDER = {"build": 0, "deploy": 1, "publish": 2, "update": 3}
_DEPLOY_PHASE_MARKERS = (
@@ -953,6 +956,12 @@ def _deployment_target_key(
)
+def _deployment_task_fingerprint(task_id: str) -> str:
+ """Return the non-secret Runtime marker used for cross-instance recovery."""
+
+ return hashlib.sha256(task_id.encode("utf-8")).hexdigest()
+
+
def _has_active_deployment_target(
tasks: Mapping[str, Mapping[str, Any]],
target_key: tuple[str, str, str],
@@ -4636,6 +4645,7 @@ async def _agentkit_proxy(request: Request, path: str):
resolve_studio_harness_sidecar_selection,
studio_harness_deployment_config,
studio_harness_runtime_env,
+ studio_harness_selectable_intent_signature,
)
from veadk.cli.studio_sidecar_prerequisites import (
SIDECAR_BASE_IMAGE_ENV,
@@ -7243,7 +7253,9 @@ def _update_identity(value: Any) -> tuple[Any, ...]:
published_sidecar_intent = normalize_studio_harness_intent(
published_draft.get("harnessSidecar")
)
- if published_sidecar_intent != sidecar_intent:
+ if studio_harness_selectable_intent_signature(
+ published_sidecar_intent
+ ) != studio_harness_selectable_intent_signature(sidecar_intent):
raise LegacyRecoveryError(
"legacy_overlay_sidecar_intent_changed"
)
@@ -7497,6 +7509,7 @@ def _update_identity(value: Any) -> tuple[Any, ...]:
runtime_tag_values.update(
{
"veadk:managed": "true",
+ _RUNTIME_DEPLOYMENT_TASK_TAG: _deployment_task_fingerprint(task_id),
**({"veadk:author": author} if author else {}),
**({"veadk:owner": owner_id} if owner_id else {}),
**deployment_resource_tag_values,
@@ -9602,6 +9615,116 @@ def _authorized_runtime_for_connection(
)
return runtime
+ @app.post("/web/deploy-agentkit/status")
+ async def _deployment_status(request: Request):
+ """Recover an accepted Runtime update through cloud-authoritative state."""
+
+ principal = _require_agent_management(request)
+ data = await request.json()
+ task_id = str(data.get("taskId") or "").strip()
+ runtime_id = str(data.get("runtimeId") or "").strip()
+ runtime_name = str(data.get("runtimeName") or "").strip()
+ app_name = str(data.get("appName") or "").strip()
+ region = _coerce_cloud_region(str(data.get("region") or ""))
+ project_name = str(data.get("projectName") or "default").strip() or "default"
+ base_version = data.get("baseRuntimeVersion")
+ if not task_id:
+ raise HTTPException(status_code=400, detail="taskId is required")
+
+ with _deploy_tasks_lock:
+ local_task = _deploy_tasks.get(task_id)
+ if local_task is not None:
+ local_owner_id = str(local_task.get("owner_id") or "")
+ if _request_role(request) != StudioRole.ADMIN and (
+ principal is None or local_owner_id != principal.owner_id
+ ):
+ raise HTTPException(
+ status_code=404,
+ detail="Deployment task not found",
+ )
+ return {
+ "done": False,
+ "status": "running",
+ "runtimeId": str(local_task.get("runtime_id") or runtime_id),
+ "runtimeName": str(local_task.get("runtime_name") or runtime_name),
+ "region": str(local_task.get("region") or region),
+ }
+
+ if (
+ not runtime_id
+ or not app_name
+ or isinstance(base_version, bool)
+ or not isinstance(base_version, int)
+ ):
+ raise HTTPException(
+ status_code=400,
+ detail="Runtime update recovery metadata is incomplete",
+ )
+
+ runtime = _authorized_runtime(
+ request,
+ runtime_id,
+ region,
+ coded_access_error=True,
+ )
+ tags = _runtime_tags(runtime)
+ current_version = getattr(runtime, "current_version_number", None)
+ current_status = str(getattr(runtime, "status", "") or "")
+ expected_fingerprint = _deployment_task_fingerprint(task_id)
+ actual_fingerprint = tags.get(_RUNTIME_DEPLOYMENT_TASK_TAG, "")
+ if (
+ isinstance(current_version, bool)
+ or not isinstance(current_version, int)
+ or current_version <= base_version
+ ):
+ return {
+ "done": False,
+ "status": "running",
+ "runtimeId": runtime_id,
+ "runtimeName": runtime_name,
+ "region": region,
+ }
+ if not secrets.compare_digest(actual_fingerprint, expected_fingerprint):
+ return {
+ "done": True,
+ "success": False,
+ "error": (
+ "Runtime 已被其他部署更新,无法确认本次部署结果,"
+ "请刷新详情核对线上版本。"
+ ),
+ "phase": "publish",
+ }
+ if current_status != "Ready":
+ return {
+ "done": False,
+ "status": "running",
+ "runtimeId": runtime_id,
+ "runtimeName": runtime_name,
+ "region": region,
+ }
+
+ _rt_conn_cache.pop((region, runtime_id), None)
+ endpoint, runtime_api_key, _auth_type, _network_type = _resolve_runtime_conn(
+ runtime_id,
+ region,
+ runtime,
+ )
+ return {
+ "done": True,
+ "success": True,
+ "agentName": app_name,
+ "runtimeName": str(getattr(runtime, "name", "") or runtime_name),
+ "url": endpoint,
+ "apikey": runtime_api_key,
+ "runtimeId": runtime_id,
+ "consoleUrl": (
+ "https://console.volcengine.com/agentkit/"
+ f"region:agentkit+{region}/runtime?projectName={project_name}"
+ ),
+ "region": region,
+ "version": current_version,
+ }
+
runtime_log_service = RuntimeLogService(
provider=provider,
resolve_credentials=_resolve_ve_credentials,
diff --git a/veadk/extensions/harness/sidecar.py b/veadk/extensions/harness/sidecar.py
index 61fad562e..0a93f150a 100644
--- a/veadk/extensions/harness/sidecar.py
+++ b/veadk/extensions/harness/sidecar.py
@@ -220,6 +220,32 @@ def normalize_studio_harness_intent(value: Any | None) -> StudioHarnessIntent:
)
+def studio_harness_selectable_intent_signature(
+ value: Any | None,
+) -> tuple[bool, str, tuple[tuple[str, bool], ...]]:
+ """Return the stable selectable meaning of one Studio Sidecar intent.
+
+ The legacy ``ops`` preset stored only its explicitly enabled gateway option,
+ while current Studio drafts expand the preset's fixed components. Those
+ representations mean the same thing. The default profile remains fully
+ explicit so custom/default-profile changes are never weakened.
+ """
+
+ intent = normalize_studio_harness_intent(value)
+ overrides = dict(intent.component_overrides)
+ if intent.enabled and intent.profile == "ops":
+ for component_id in STUDIO_HARNESS_PROFILE_DEFAULTS["ops"]:
+ overrides[component_id] = True
+ return (
+ intent.enabled,
+ intent.profile,
+ tuple(
+ (component_id, overrides[component_id])
+ for component_id in STUDIO_HARNESS_COMPONENT_IDS
+ ),
+ )
+
+
def studio_harness_intent_payload(value: Any | None) -> dict[str, Any]:
"""Return the stable camelCase Draft/YAML representation."""
@@ -682,5 +708,6 @@ def _truthy(value: str | None) -> bool:
"sidecar_config_from_env",
"studio_harness_env_example",
"studio_harness_intent_payload",
+ "studio_harness_selectable_intent_signature",
"studio_harness_runtime_env",
]
From 442f727f6d0c07014cf1a4918fee7572cabdf4e4 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Fri, 11 Sep 2026 08:53:40 +0800
Subject: [PATCH 07/46] fix(studio): migrate legacy update output repository
---
.../harness-sidecar-release-gate.yaml | 4 +-
tests/cli/test_studio_rbac.py | 149 +++++++++++++++++-
tests/test_harness_sidecar_release_gate.py | 2 +-
veadk/cli/cli_frontend.py | 37 ++++-
4 files changed, 186 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 38e053b53..340718b2a 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -108,7 +108,9 @@ jobs:
tests/cli/test_studio_rbac.py::test_deployment_status_recovers_completed_update_from_fresh_instance \
tests/cli/test_studio_rbac.py::test_new_deployment_only_updates_non_default_instance_range \
tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \
- tests/cli/test_studio_rbac.py::test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk \
+ tests/cli/test_studio_rbac.py::test_source_preserving_output_repository_migrates_only_tagless_runtimes \
+ tests/cli/test_studio_rbac.py::test_source_preserving_update_ignores_browser_source_and_keeps_secrets_out_of_build \
+ tests/cli/test_studio_rbac.py::test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk \
tests/cli/test_studio_rbac.py::test_application_owned_mcp_update_routes_cover_reuse_and_additions \
tests/cli/test_studio_rbac.py::test_sidecar_update_resolves_or_explicitly_reuses_stored_mcp_credentials
) > "$RUNNER_TEMP/sidecar-credentials.log" 2>&1 &
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index d53a18cab..9465b718e 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -19,6 +19,7 @@
import itertools
import json
import logging
+import re
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
@@ -45,6 +46,7 @@
_runtime_environment_from_runtime,
_runtime_environment_from_tags,
_runtime_environment_tags,
+ _source_preserving_output_repository,
studio,
)
@@ -202,6 +204,71 @@ def test_environment_registry_overrides_legacy_runtime_build_registry() -> None:
}
+def test_source_preserving_output_repository_migrates_only_tagless_runtimes() -> None:
+ identity = (
+ "runtime-legacy",
+ "registry-a",
+ "namespace-a",
+ "repository-a",
+ )
+ expected = (
+ "veadk-sp-" + hashlib.sha256("\0".join(identity).encode()).hexdigest()[:20]
+ )
+
+ assert (
+ _source_preserving_output_repository(
+ runtime_id=identity[0],
+ registry=identity[1],
+ namespace=identity[2],
+ source_repository=identity[3],
+ has_build_resource_tags=False,
+ )
+ == expected
+ )
+ assert (
+ _source_preserving_output_repository(
+ runtime_id=identity[0],
+ registry=identity[1],
+ namespace=identity[2],
+ source_repository=identity[3],
+ has_build_resource_tags=False,
+ )
+ == expected
+ )
+ assert (
+ _source_preserving_output_repository(
+ runtime_id="runtime-other",
+ registry=identity[1],
+ namespace=identity[2],
+ source_repository=identity[3],
+ has_build_resource_tags=False,
+ )
+ != expected
+ )
+ assert (
+ _source_preserving_output_repository(
+ runtime_id=identity[0],
+ registry=identity[1],
+ namespace=identity[2],
+ source_repository="repository-other",
+ has_build_resource_tags=False,
+ )
+ != expected
+ )
+ assert (
+ _source_preserving_output_repository(
+ runtime_id=identity[0],
+ registry=identity[1],
+ namespace=identity[2],
+ source_repository=identity[3],
+ has_build_resource_tags=True,
+ )
+ == identity[3]
+ )
+ assert len(expected) == 29
+ assert re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", expected)
+
+
def _create_studio_app(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -5049,9 +5116,15 @@ async def request(
assert update_calls == []
+@pytest.mark.parametrize(
+ "has_build_resource_tags",
+ [False, True],
+ ids=["legacy-tagless", "modern-tagged"],
+)
def test_source_preserving_update_ignores_browser_source_and_keeps_secrets_out_of_build(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+ has_build_resource_tags: bool,
) -> None:
from agentkit.sdk.runtime.client import AgentkitRuntimeClient
@@ -5064,6 +5137,27 @@ def test_source_preserving_update_ignores_browser_source_and_keeps_secrets_out_o
runtime.artifact_url = (
"example-registry-cn-shanghai.cr.volces.com/agentkit/legacy:v9"
)
+ runtime.tags = list(runtime.tags or []) + (
+ [
+ SimpleNamespace(key="veadk:build-resource:tos-mode", value="auto"),
+ SimpleNamespace(key="veadk:build-resource:cr-mode", value="create"),
+ SimpleNamespace(
+ key="veadk:build-resource:cr-instance",
+ value="example-registry",
+ ),
+ SimpleNamespace(
+ key="veadk:build-resource:cr-namespace",
+ value="agentkit",
+ ),
+ SimpleNamespace(
+ key="veadk:build-resource:cr-repository",
+ value="legacy",
+ ),
+ SimpleNamespace(key="veadk:build-resource:cp-mode", value="auto"),
+ ]
+ if has_build_resource_tags
+ else []
+ )
runtime.envs = [
SimpleNamespace(
key="MCP_SERVERS_JSON",
@@ -5081,12 +5175,19 @@ def test_source_preserving_update_ignores_browser_source_and_keeps_secrets_out_o
]
launched = False
captured: dict[str, Any] = {}
+ update_requests: list[Any] = []
def get_runtime(_self: Any, _request: Any) -> SimpleNamespace:
runtime.current_version_number = 10 if launched else 9
return runtime
monkeypatch.setattr(AgentkitRuntimeClient, "get_runtime", get_runtime)
+
+ def update_runtime(_self: Any, request: Any) -> SimpleNamespace:
+ update_requests.append(request)
+ return SimpleNamespace(runtime_id=runtime.runtime_id)
+
+ monkeypatch.setattr(AgentkitRuntimeClient, "update_runtime", update_runtime)
monkeypatch.setattr(
OciImageInspector,
"extract_skills",
@@ -5145,6 +5246,10 @@ def launch(*, config_file: str, config_dict: Any, **_kwargs: Any) -> Any:
captured["mcp"] = (base / ".veadk-studio-overlay/mcp.json").read_text()
captured["persisted_config"] = Path(config_file).read_text()
captured["config"] = config_dict
+ AgentkitRuntimeClient.update_runtime(
+ object(),
+ SimpleNamespace(tags=[], apmplus_enable=False),
+ )
launched = True
return SimpleNamespace(
success=True,
@@ -5273,10 +5378,33 @@ def launch(*, config_file: str, config_dict: Any, **_kwargs: Any) -> Any:
assert captured["config"]["launch_types"]["cloud"]["cr_namespace_name"] == (
"agentkit"
)
- assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == ("legacy")
+ expected_repository = (
+ "legacy"
+ if has_build_resource_tags
+ else "veadk-sp-"
+ + hashlib.sha256(
+ "\0".join(
+ (
+ runtime.runtime_id,
+ "example-registry",
+ "agentkit",
+ "legacy",
+ )
+ ).encode()
+ ).hexdigest()[:20]
+ )
+ assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == (
+ expected_repository
+ )
+ assert len(update_requests) == 1
+ update_tags = {item.key: item.value for item in update_requests[0].tags}
+ assert update_tags["veadk:build-resource:cr-mode"] == "create"
+ assert update_tags["veadk:build-resource:cr-instance"] == "example-registry"
+ assert update_tags["veadk:build-resource:cr-namespace"] == "agentkit"
+ assert update_tags["veadk:build-resource:cr-repository"] == expected_repository
-def test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk(
+def test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
@@ -5591,7 +5719,22 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
assert captured["config"]["launch_types"]["cloud"]["cr_namespace_name"] == (
"agentkit"
)
- assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == ("sidecar")
+ expected_repository = (
+ "veadk-sp-"
+ + hashlib.sha256(
+ "\0".join(
+ (
+ runtime.runtime_id,
+ "example-registry",
+ "agentkit",
+ "sidecar",
+ )
+ ).encode()
+ ).hexdigest()[:20]
+ )
+ assert captured["config"]["launch_types"]["cloud"]["cr_repo_name"] == (
+ expected_repository
+ )
def test_deployment_status_recovers_completed_update_from_fresh_instance(
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 8b36ee76d..216c17778 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -39,7 +39,7 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
assert "test_studio_sidecar.py" in backend_run
assert "test_legacy_runtime_recovery.py" in backend_run
assert (
- "test_source_preserving_legacy_ops_update_reuses_exact_image_via_sdk"
+ "test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk"
in backend_run
)
assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index d5332e322..a4fcbea9c 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -428,6 +428,34 @@ def _anchor_environment_registry(
)
+def _source_preserving_output_repository(
+ *,
+ runtime_id: str,
+ registry: str,
+ namespace: str,
+ source_repository: str,
+ has_build_resource_tags: bool,
+) -> str:
+ """Avoid rebuilding into legacy CR state while preserving the source image.
+
+ Runtimes created before Studio persisted build-resource tags can still use
+ their immutable image as a build input, but their original repository may
+ not be visible to the VeFaaS caller used by AgentKit Platform's BuildKit
+ component. Only that legacy path gets a deterministic Studio-owned output
+ repository in the same Registry/Namespace. Modern tagged Runtimes keep the
+ configured repository unchanged.
+ """
+ normalized = tuple(
+ value.strip() for value in (runtime_id, registry, namespace, source_repository)
+ )
+ if not all(normalized):
+ raise ValueError("Source-preserving repository identity is incomplete")
+ if has_build_resource_tags:
+ return normalized[3]
+ digest = hashlib.sha256("\0".join(normalized).encode("utf-8")).hexdigest()
+ return f"veadk-sp-{digest[:20]}"
+
+
def _studio_environment_resource_environment(
*,
cp_workspace: str | None,
@@ -7390,12 +7418,19 @@ def _update_identity(value: Any) -> tuple[Any, ...]:
"暂时无法执行保留源码更新。"
),
)
+ output_repository = _source_preserving_output_repository(
+ runtime_id=runtime_id,
+ registry=source_reference.registry_name,
+ namespace=namespace,
+ source_repository=repository,
+ has_build_resource_tags=tagged_resources is not None,
+ )
_anchor_environment_registry(
deployment_resource_config,
deployment_resource_tag_values,
registry=source_reference.registry_name,
namespace=namespace,
- repository=repository,
+ repository=output_repository,
)
elif canonical_requested_draft is not None:
try:
From d6ea8fbcfe94ccf4caa705dd62ea3bbbfa6825e1 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Fri, 11 Sep 2026 10:51:22 +0800
Subject: [PATCH 08/46] fix(studio): align sidecar runtime role policy
---
.../harness-sidecar-release-gate.yaml | 7 ++++
.../studio_release_server/publisher.py | 2 +-
tests/cli/test_frontend_branding.py | 16 ++++++++
tests/cli/test_studio_companion.py | 2 +-
.../cli/test_studio_deploy_serverless_iam.py | 17 +++++++++
tests/cli/test_studio_rbac.py | 23 +++++++++++-
tests/test_harness_sidecar_release_gate.py | 6 +++
tests/test_studio_release_server.py | 24 ++++++------
veadk/cli/agentkit_cli.py | 12 +++---
veadk/cli/cli_frontend.py | 37 +++++++++++++++++++
10 files changed, 125 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 340718b2a..ab1768d0b 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -13,6 +13,7 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
@@ -21,6 +22,8 @@ on:
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
- 'veadk/cli/cli_frontend.py'
+ - 'frontend/server/runtime_iam.py'
+ - 'veadk/cli/agentkit_cli.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/extensions/harness/**'
@@ -37,6 +40,7 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
@@ -45,6 +49,8 @@ on:
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
- 'veadk/cli/cli_frontend.py'
+ - 'frontend/server/runtime_iam.py'
+ - 'veadk/cli/agentkit_cli.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/extensions/harness/**'
@@ -103,6 +109,7 @@ jobs:
pids+=("$!")
(
python -m pytest -q \
+ tests/frontend/server/test_runtime_iam.py \
tests/cli/test_generated_agent_mcp.py \
tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_debug_applies_published_mcp_credential_contract_before_discovery \
tests/cli/test_studio_rbac.py::test_deployment_status_recovers_completed_update_from_fresh_instance \
diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py
index 02cd01b72..8e0e1b19b 100644
--- a/frontend/service/studio_release_server/publisher.py
+++ b/frontend/service/studio_release_server/publisher.py
@@ -71,7 +71,7 @@
_MAX_STUDIO_RELEASES = 50
_AGENTKIT_CLI_ARCHIVE = "agentkit-linux-x64.tar.gz"
_AGENTKIT_CLI_ARCHIVE_SHA256 = (
- "4439d14b4be6ccb90f6eea896adf959ffef4ab4983f41e449d80c79d4cd95de3"
+ "bedcf298933ec35f87888d52f2608fa1e48674a1dd946763326adbf60d297b22"
)
_STUDIO_RELEASE_CONTRACT = "agentkit-cli-v1"
_STUDIO_RUNTIME_MANIFEST = "studio-runtime.json"
diff --git a/tests/cli/test_frontend_branding.py b/tests/cli/test_frontend_branding.py
index 95b18fa27..94933cc85 100644
--- a/tests/cli/test_frontend_branding.py
+++ b/tests/cli/test_frontend_branding.py
@@ -28,6 +28,7 @@
resolve_site_logo,
)
from veadk.cli.cli_frontend import studio
+from veadk.cli.studio_dependencies import STUDIO_AGENTKIT_CLI_ARTIFACT
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUB"
@@ -35,6 +36,21 @@
)
+def _stage_test_agentkit_cli_archive(destination: Path, **_kwargs: object) -> Path:
+ destination.mkdir(parents=True, exist_ok=True)
+ archive = destination / STUDIO_AGENTKIT_CLI_ARTIFACT.filename
+ archive.write_bytes(b"test-agentkit-cli")
+ return archive
+
+
+@pytest.fixture(autouse=True)
+def _avoid_network_cli_archive_download(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(
+ "veadk.cli.studio_package.stage_studio_agentkit_cli_archive",
+ _stage_test_agentkit_cli_archive,
+ )
+
+
class _LogoResponse:
def raise_for_status(self) -> None:
pass
diff --git a/tests/cli/test_studio_companion.py b/tests/cli/test_studio_companion.py
index 2463f4e53..fd5ad65ae 100644
--- a/tests/cli/test_studio_companion.py
+++ b/tests/cli/test_studio_companion.py
@@ -137,7 +137,7 @@ def test_platform_mapping_rejects_unsupported_platform() -> None:
def test_required_version_is_owned_by_veadk_not_distribution_metadata() -> None:
- assert required_agentkit_cli_version() == "0.52.18"
+ assert required_agentkit_cli_version() == "0.52.19"
def test_companion_materializes_manifest_cli_for_provider(
diff --git a/tests/cli/test_studio_deploy_serverless_iam.py b/tests/cli/test_studio_deploy_serverless_iam.py
index f292606df..7ac29ff50 100644
--- a/tests/cli/test_studio_deploy_serverless_iam.py
+++ b/tests/cli/test_studio_deploy_serverless_iam.py
@@ -14,6 +14,7 @@
import importlib
import json
+from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, call
@@ -27,6 +28,22 @@
SYSTEM_POLICIES,
ensure_serverless_application_role,
)
+from veadk.cli.studio_dependencies import STUDIO_AGENTKIT_CLI_ARTIFACT
+
+
+def _stage_test_agentkit_cli_archive(destination: Path, **_kwargs: object) -> Path:
+ destination.mkdir(parents=True, exist_ok=True)
+ archive = destination / STUDIO_AGENTKIT_CLI_ARTIFACT.filename
+ archive.write_bytes(b"test-agentkit-cli")
+ return archive
+
+
+@pytest.fixture(autouse=True)
+def _avoid_network_cli_archive_download(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(
+ "veadk.cli.studio_package.stage_studio_agentkit_cli_archive",
+ _stage_test_agentkit_cli_archive,
+ )
def _install_iam_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> None:
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index 9465b718e..f7bfb3f26 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -6632,6 +6632,9 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace:
assert captured_config["launch_types"]["cloud"]["runtime_name"] == (
"stable-runtime-name"
)
+ assert captured_config["launch_types"]["cloud"]["runtime_role_name"] == (
+ "shared-runtime-role"
+ )
assert create_requests[0].apmplus_enable is True
assert {
item.key: item.value
@@ -6670,6 +6673,11 @@ def launch(*, config_file: str, **_kwargs: Any) -> SimpleNamespace:
session_token=None,
provider=provider,
)
+ assert not any(
+ "AgentKitFullAccess" in str(frame.get("message") or "")
+ or "快速模式 Runtime 已具备 AgentKit 资源访问权限" == frame.get("message")
+ for frame in frames
+ )
if expects_update:
request = update_requests[0]
assert request.runtime_id == runtime_id
@@ -6997,7 +7005,12 @@ def kill(self) -> None:
assert frames[-1]["agentName"] == agent_name
assert frames[-1]["runtimeName"] == runtime_name
assert captured["command"] == ["/fake/agentkit", "release", "--json"]
- _stub_studio_runtime_role.assert_not_called()
+ _stub_studio_runtime_role.assert_called_once_with(
+ access_key="test-ak",
+ secret_key="test-sk",
+ session_token=None,
+ provider="volcengine",
+ )
assert captured["managed_base_in_env"] is True
assert captured["create_only"] is True
assert captured["cli_env"]["AGENTKIT_RUNTIME_READY_TIMEOUT_MS"] == "900000"
@@ -7007,6 +7020,7 @@ def kill(self) -> None:
assert "agentkit-sdk-python==0.8.4" not in captured["requirements"]
assert captured["requirements"].splitlines().count("mcp==1.26.0") == 1
assert captured["config"]["name"] == runtime_name
+ assert captured["config"]["role_name"] == "shared-runtime-role"
assert captured["config"]["harness_sidecar"]["component_overrides"] == {
"context_engine": False,
"compressor": False,
@@ -7264,6 +7278,12 @@ def kill(self) -> None:
lambda **_kwargs: pytest.fail("Sidecar update must use AgentKit CLI"),
)
app = _create_studio_app(monkeypatch, tmp_path, developers="developer")
+ monkeypatch.setattr(
+ "frontend.server.runtime_iam.ensure_runtime_role",
+ lambda **_kwargs: pytest.fail(
+ "Runtime update must preserve its role without running IAM selection"
+ ),
+ )
monkeypatch.setattr("subprocess.Popen", FakeProcess)
headers = {"X-VeADK-Local-User": "developer"}
@@ -7344,6 +7364,7 @@ def kill(self) -> None:
]
assert frames[-1].get("error") is None
assert frames[-1]["success"] is True
+ assert captured["config"]["role_name"] == "runtime-role"
structured_value = captured["config"]["envs"]["MCP_SERVERS_JSON"]
structured_key = structured_value.removeprefix("${").removesuffix("}")
assert json.loads(captured["env"][structured_key]) == [
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 216c17778..a4b96b691 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -42,6 +42,12 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
"test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk"
in backend_run
)
+ assert "tests/frontend/server/test_runtime_iam.py" in backend_run
+ assert "test_new_deployment_only_updates_non_default_instance_range" in backend_run
+ assert (
+ "test_sidecar_update_resolves_or_explicitly_reuses_stored_mcp_credentials"
+ in (backend_run)
+ )
assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
backend_run
diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py
index d99de71a5..3fbece708 100644
--- a/tests/test_studio_release_server.py
+++ b/tests/test_studio_release_server.py
@@ -1623,7 +1623,7 @@ def test_tos_dependency_store_populates_and_reuses_cached_wheel(
"filename": "agentkit-linux-x64.tar.gz",
"url": (
"https://agentkit-cli.tos-cn-beijing.volces.com/"
- "0.52.18/agentkit-linux-x64.tar.gz"
+ "0.52.19/agentkit-linux-x64.tar.gz"
),
"sha256": digest,
}
@@ -1674,7 +1674,7 @@ def test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version(
"filename": "agentkit-linux-x64.tar.gz",
"url": (
"https://agentkit-cli.tos-cn-beijing.volces.com/"
- "0.52.18/agentkit-linux-x64.tar.gz"
+ "0.52.19/agentkit-linux-x64.tar.gz"
),
"sha256": "b" * 64,
}
@@ -1694,7 +1694,7 @@ def test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version(
"agentkit-linux-x64.tar.gz",
(
"https://agentkit-cli.tos-cn-beijing.volces.com/"
- "0.52.18/agentkit-linux-x64.tar.gz"
+ "0.52.19/agentkit-linux-x64.tar.gz"
),
"b" * 64,
)
@@ -1703,14 +1703,14 @@ def test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version(
@pytest.mark.parametrize(
"url",
(
- "http://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz",
- "https://example.com/0.52.18/agentkit-linux-x64.tar.gz",
- "https://agentkit-cli.tos-cn-beijing.volces.com.evil.invalid/0.52.18/agentkit-linux-x64.tar.gz",
+ "http://agentkit-cli.tos-cn-beijing.volces.com/0.52.19/agentkit-linux-x64.tar.gz",
+ "https://example.com/0.52.19/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com.evil.invalid/0.52.19/agentkit-linux-x64.tar.gz",
"https://agentkit-cli.tos-cn-beijing.volces.com/latest/agentkit-linux-x64.tar.gz",
- "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/other.tar.gz",
- "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/nested/agentkit-linux-x64.tar.gz",
- "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz?download=1",
- "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.18/agentkit-linux-x64.tar.gz#fragment",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.19/other.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.19/nested/agentkit-linux-x64.tar.gz",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.19/agentkit-linux-x64.tar.gz?download=1",
+ "https://agentkit-cli.tos-cn-beijing.volces.com/0.52.19/agentkit-linux-x64.tar.gz#fragment",
),
)
def test_tos_dependency_store_rejects_untrusted_agentkit_cli_url(
@@ -1813,7 +1813,7 @@ def capture_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProces
"filename": "agentkit-linux-x64.tar.gz",
"url": (
"https://agentkit-cli.tos-cn-beijing.volces.com/"
- "0.52.18/agentkit-linux-x64.tar.gz"
+ "0.52.19/agentkit-linux-x64.tar.gz"
),
"sha256": "b" * 64,
}
@@ -1863,7 +1863,7 @@ def test_tos_dependency_store_rejects_download_with_wrong_checksum(
"filename": "agentkit-linux-x64.tar.gz",
"url": (
"https://agentkit-cli.tos-cn-beijing.volces.com/"
- "0.52.18/agentkit-linux-x64.tar.gz"
+ "0.52.19/agentkit-linux-x64.tar.gz"
),
"sha256": "b" * 64,
}
diff --git a/veadk/cli/agentkit_cli.py b/veadk/cli/agentkit_cli.py
index bba1cd587..a7bc20bb1 100644
--- a/veadk/cli/agentkit_cli.py
+++ b/veadk/cli/agentkit_cli.py
@@ -37,7 +37,7 @@
import zipfile
from typing import Any
-AGENTKIT_CLI_VERSION = "0.52.18"
+AGENTKIT_CLI_VERSION = "0.52.19"
# Direct bootstrap uses the one verified public upstream. Provider-local Studio
# bundles materialize this archive through their signed runtime manifest instead.
AGENTKIT_CLI_RELEASE_HOST = "agentkit-cli.tos-cn-beijing.volces.com"
@@ -82,35 +82,35 @@ def url(self) -> str:
"linux-x64": AgentKitCliArtifact(
platform_key="linux-x64",
filename="agentkit-linux-x64.tar.gz",
- sha256="4439d14b4be6ccb90f6eea896adf959ffef4ab4983f41e449d80c79d4cd95de3",
+ sha256="bedcf298933ec35f87888d52f2608fa1e48674a1dd946763326adbf60d297b22",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-x64",
executable_name="ak",
),
"linux-arm64": AgentKitCliArtifact(
platform_key="linux-arm64",
filename="agentkit-linux-arm64.tar.gz",
- sha256="d5a0b5e2d08c78c1eda750f3b16ed7b3742bc041c0b67a66277a84d0b49bf5d8",
+ sha256="5a871ba705ff08ef4fdd199b47accaad591f9eb1e18be6ba7ed13a604ce4bb11",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-arm64",
executable_name="ak",
),
"darwin-x64": AgentKitCliArtifact(
platform_key="darwin-x64",
filename="agentkit-darwin-x64.tar.gz",
- sha256="42b4e2ddd4ba03b02fb73723048a002545dc1c0ec8e2bb741bcde16f1cf8c6f0",
+ sha256="801cf9718449cf9ee252d91b064d273c2816e2aaa80c75f67e6c0f3c5272fb38",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-x64",
executable_name="ak",
),
"darwin-arm64": AgentKitCliArtifact(
platform_key="darwin-arm64",
filename="agentkit-darwin-arm64.tar.gz",
- sha256="8dec4b59e3716cb616894ad118c667c2d44e477a500b0228b48681125b24c991",
+ sha256="4d7ec3327155a8049130d7c96d8f8661706408a68d3290f00f373c75d3bf6d16",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-arm64",
executable_name="ak",
),
"windows-x64": AgentKitCliArtifact(
platform_key="windows-x64",
filename="agentkit-windows-x64.zip",
- sha256="cd4fd09de40c4f34d231288b25771ef3d2785e4d6b02031e3c1205ff1ca682e6",
+ sha256="27562cbaec17a5305c4ee77c5da32f9f7cbd2f9f3da7689d391b6dc6c2487a5b",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-windows-x64",
executable_name="ak.exe",
),
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index a4fcbea9c..a7d9a2995 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -7997,6 +7997,22 @@ def _agentkit_sdk_credential_env():
)
runtime_envs.update(source_preserving_sidecar_env)
runtime_envs.update(existing_sidecar_binding)
+ if existing_runtime is not None:
+ selected_runtime_role_name = str(
+ getattr(existing_runtime, "role_name", "") or ""
+ ).strip()
+ if not selected_runtime_role_name:
+ shutil.rmtree(temp_dir, ignore_errors=True)
+ raise HTTPException(
+ status_code=409,
+ detail="当前 Runtime 缺少可沿用的 IAM 角色,请刷新详情后重试。",
+ )
+ else:
+ # Resolve the new Runtime role inside the deployment lock. This
+ # keeps concurrent Studio deployments from racing to create roles;
+ # both SDK and Sidecar CLI paths inject the resolved value before
+ # their first cloud mutation.
+ selected_runtime_role_name = ""
# TOS build-artifact buckets are region-scoped. The SDK default template
# ("agentkit-platform-") produces a single global name, which
# collides once a bucket exists in cn-beijing and the user targets
@@ -8015,6 +8031,8 @@ def _agentkit_sdk_credential_env():
"runtime_envs": runtime_envs,
"python_version": "3.12",
}
+ if selected_runtime_role_name:
+ cloud_config["runtime_role_name"] = selected_runtime_role_name
cloud_config.update(runtime_authentication)
if existing_runtime is not None:
cloud_config.update(
@@ -8107,6 +8125,7 @@ def _agentkit_sdk_credential_env():
}
sidecar_agentkit_config = {
"name": deployment_runtime_name,
+ "role_name": selected_runtime_role_name,
"description": _normalize_runtime_description(data.get("description")),
"cloud_provider": "volcengine",
"region": region,
@@ -8684,6 +8703,24 @@ def _run_cli() -> None:
try:
cli_env = os.environ.copy()
access_key, secret_key, session_token = _resolve_ve_credentials()
+ if not runtime_id:
+ from frontend.server.runtime_iam import ensure_runtime_role
+
+ selected_role = ensure_runtime_role(
+ access_key=access_key,
+ secret_key=secret_key,
+ session_token=session_token,
+ provider=provider,
+ )
+ sidecar_agentkit_config["role_name"] = selected_role
+ (base / ".agentkit" / "agentkit.yaml").write_text(
+ _yaml.safe_dump(
+ sidecar_agentkit_config,
+ allow_unicode=True,
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
cli_env["VOLCENGINE_ACCESS_KEY"] = access_key
cli_env["VOLCENGINE_SECRET_KEY"] = secret_key
cli_env["VOLCENGINE_REGION"] = region
From dcab71f39488df413cc80bb152d262a14f5452e8 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Sat, 12 Sep 2026 13:33:22 +0800
Subject: [PATCH 09/46] test: isolate studio backend gate workers
---
frontend/server/skills/versions.py | 8 ++++----
tests/cli/conftest.py | 13 +++++++++++++
.../server/skills/test_optimized_skill_versions.py | 7 +++++--
tests/frontend/server/skills/test_versions.py | 2 +-
4 files changed, 23 insertions(+), 7 deletions(-)
diff --git a/frontend/server/skills/versions.py b/frontend/server/skills/versions.py
index 6e3288964..cfa8fc148 100644
--- a/frontend/server/skills/versions.py
+++ b/frontend/server/skills/versions.py
@@ -18,11 +18,11 @@
import re
import tempfile
-import time
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
+from time import monotonic, sleep
from typing import TYPE_CHECKING, Any
from .archive import validate_skill_archive
@@ -154,7 +154,7 @@ def _wait_for_new_version(
*,
timeout_seconds: float = 90,
) -> Any:
- deadline = time.monotonic() + timeout_seconds
+ deadline = monotonic() + timeout_seconds
while True:
new = [
item
@@ -172,14 +172,14 @@ def _wait_for_new_version(
latest.error_message or "新版本创建失败,请检查文件后重试",
status_code=502,
)
- if time.monotonic() >= deadline:
+ if monotonic() >= deadline:
raise SkillRepositoryError(
"SKILL_VERSION_PENDING",
"新版本仍在处理中,请刷新版本列表查看结果",
status_code=504,
retryable=True,
)
- time.sleep(2)
+ sleep(2)
def upload(
self,
diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py
index 14457e2b1..17989616a 100644
--- a/tests/cli/conftest.py
+++ b/tests/cli/conftest.py
@@ -18,6 +18,19 @@
from unittest.mock import AsyncMock, MagicMock
+@pytest.fixture(autouse=True)
+def _stub_studio_local_scheduler(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Keep CLI tests independent of live Cronjob and TOS polling."""
+
+ async def _offline_local_scheduler(*_args: object, **_kwargs: object) -> None:
+ return None
+
+ monkeypatch.setattr(
+ "frontend.service.studio_scheduler.run_local_scheduler",
+ _offline_local_scheduler,
+ )
+
+
@pytest.fixture(autouse=True)
def _stub_skill_score_background_worker(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep CLI route tests independent of live Skill recovery scans"""
diff --git a/tests/frontend/server/skills/test_optimized_skill_versions.py b/tests/frontend/server/skills/test_optimized_skill_versions.py
index 2c6c6d9d1..b37006abd 100644
--- a/tests/frontend/server/skills/test_optimized_skill_versions.py
+++ b/tests/frontend/server/skills/test_optimized_skill_versions.py
@@ -15,6 +15,7 @@
from __future__ import annotations
import io
+import time
import zipfile
from types import SimpleNamespace
from typing import Any
@@ -114,12 +115,14 @@ def test_optimized_skill_waits_through_old_version_readback_before_publishing(
):
service, cloud, publications = setup
waited = []
+ global_sleep = time.sleep
def reveal_new(_: float):
waited.append(True)
cloud.show_new = True
- monkeypatch.setattr("frontend.server.skills.versions.time.sleep", reveal_new)
+ monkeypatch.setattr("frontend.server.skills.versions.sleep", reveal_new)
+ assert time.sleep is global_sleep
result = service.publish(
"job",
"alice",
@@ -141,7 +144,7 @@ def test_failed_optimized_version_does_not_republish_old_successful_version(
service, cloud, publications = setup
cloud.new_status = "failed"
monkeypatch.setattr(
- "frontend.server.skills.versions.time.sleep",
+ "frontend.server.skills.versions.sleep",
lambda _: setattr(cloud, "show_new", True),
)
with pytest.raises(SkillRepositoryError) as error:
diff --git a/tests/frontend/server/skills/test_versions.py b/tests/frontend/server/skills/test_versions.py
index 8066e1f4e..d519f5484 100644
--- a/tests/frontend/server/skills/test_versions.py
+++ b/tests/frontend/server/skills/test_versions.py
@@ -227,7 +227,7 @@ def delayed(_: float):
calls.append(True)
cloud.items.append(version("v2"))
- monkeypatch.setattr("frontend.server.skills.versions.time.sleep", delayed)
+ monkeypatch.setattr("frontend.server.skills.versions.sleep", delayed)
latest = versions._wait_for_new_version(cloud, "skill", {"v1"})
assert latest.version == "v2"
assert calls == [True]
From b7d62bc34cf06b37a46201350817d06bed7f0a19 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 13:32:10 +0800
Subject: [PATCH 10/46] fix(studio): finalize sidecar runtime access rollout
---
frontend/server/runtime_iam.py | 122 ++++------
.../studio_release_server/publisher.py | 2 +-
tests/cli/conftest.py | 12 +
tests/cli/provider_environment.py | 36 +++
.../test_provider_environment_isolation.py | 44 ++++
tests/frontend/server/test_runtime_iam.py | 220 ++++++++++--------
veadk/cli/agentkit_cli.py | 10 +-
7 files changed, 277 insertions(+), 169 deletions(-)
create mode 100644 tests/cli/provider_environment.py
create mode 100644 tests/cli/test_provider_environment_isolation.py
diff --git a/frontend/server/runtime_iam.py b/frontend/server/runtime_iam.py
index 4d5751ee5..a9d485577 100644
--- a/frontend/server/runtime_iam.py
+++ b/frontend/server/runtime_iam.py
@@ -18,6 +18,8 @@
import json
import os
+import secrets
+import string
import threading
from typing import Any
@@ -29,20 +31,12 @@
DEFAULT_RUNTIME_POLICY = "AgentKitDefaultRuntimeAccess"
DEFAULT_RUNTIME_ROLE = "AgentKit_Runtime_Default_ServiceRole"
+_DEFAULT_RUNTIME_ROLE_PREFIX = f"{DEFAULT_RUNTIME_ROLE}_"
_ROLE_PAGE_SIZE = 100
+_ROLE_NAME_ATTEMPTS = 10
+_ROLE_SUFFIX_LENGTH = 7
+_ROLE_SUFFIX_CHARS = string.ascii_lowercase + string.digits
_ROLE_LOCK = threading.Lock()
-_LEGACY_RUNTIME_POLICIES = frozenset(
- {
- "cloudcontrolreadonlyaccess",
- "agentkittosaccess",
- "torchlightapifullaccess",
- "llmshieldprotectsdkaccess",
- "agentkittoolaccess",
- "idreadonlyaccess",
- "mem0readonlyaccess",
- "agentkitruntimeaccess",
- }
-)
def _result(response: dict[str, Any]) -> dict[str, Any]:
@@ -55,24 +49,11 @@ def _result(response: dict[str, Any]) -> dict[str, Any]:
return result
-def _role_policies(iam: Any, name: str) -> frozenset[str]:
- policies = _result(iam.list_attached_role_policies({"RoleName": name})).get(
- "AttachedPolicyMetadata"
- )
- if not isinstance(policies, list):
- raise RuntimeError("IAM returned an invalid role policy list")
- return frozenset(
- str(policy.get("PolicyName") or "").casefold()
- for policy in policies
- if policy.get("PolicyType") == "System"
- )
-
-
-def _is_runtime_role(policies: frozenset[str]) -> bool:
- return (
- DEFAULT_RUNTIME_POLICY.casefold() in policies
- or _LEGACY_RUNTIME_POLICIES <= policies
+def _generate_runtime_role_name() -> str:
+ suffix = "".join(
+ secrets.choice(_ROLE_SUFFIX_CHARS) for _ in range(_ROLE_SUFFIX_LENGTH)
)
+ return f"{_DEFAULT_RUNTIME_ROLE_PREFIX}{suffix}"
def _error_code(value: object) -> str:
@@ -105,22 +86,28 @@ def _get_role(iam: Any, name: str) -> dict[str, Any] | None:
def _find_reusable_role(iam: Any) -> str | None:
offset = 0
while True:
- page = _result(iam.list_roles({"Limit": _ROLE_PAGE_SIZE, "Offset": offset}))
- roles = page.get("RoleMetadata")
+ page = _result(
+ iam.list_entities_for_policy(
+ {
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
+ "Limit": _ROLE_PAGE_SIZE,
+ "Offset": offset,
+ }
+ )
+ )
+ roles = page.get("PolicyRoles")
total = page.get("Total")
if not isinstance(roles, list) or not isinstance(total, int) or total < 0:
- raise RuntimeError("IAM returned an invalid role list")
+ raise RuntimeError("IAM returned an invalid policy entity list")
for role in roles:
- name = role.get("RoleName")
+ name = role.get("RoleName") if isinstance(role, dict) else None
if not isinstance(name, str) or not name.strip():
raise RuntimeError("IAM role is missing RoleName")
- if _is_runtime_role(_role_policies(iam, name)):
- return name
- offset += len(roles)
+ return name
+ offset += _ROLE_PAGE_SIZE
if offset >= total:
return None
- if not roles:
- raise RuntimeError("IAM returned an incomplete role list")
def ensure_runtime_role(
@@ -130,10 +117,11 @@ def ensure_runtime_role(
session_token: str | None = None,
provider: CloudProvider = DEFAULT_CLOUD_PROVIDER,
) -> str:
- """Reuse a matching role, or create the shared default Runtime role.
+ """Reuse a minimum-policy role, or create a collision-free Runtime role.
- Both current and legacy AgentKit policy layouts are recognized so evaluation
- attempts never create one IAM role per temporary Runtime.
+ Existing roles are authoritative and never mutated. Legacy AgentKit policy
+ layouts are not sufficient for new Runtimes because the CLI requires the
+ platform-managed default Runtime policy on an explicitly selected role.
"""
with _ROLE_LOCK:
return _ensure_runtime_role(
@@ -161,21 +149,6 @@ def _ensure_runtime_role(
if session_token:
iam.set_session_token(session_token)
- default = _get_role(iam, DEFAULT_RUNTIME_ROLE)
- if default is not None:
- policies = _role_policies(iam, DEFAULT_RUNTIME_ROLE)
- if not _is_runtime_role(policies):
- _result(
- iam.attach_role_policy(
- {
- "RoleName": DEFAULT_RUNTIME_ROLE,
- "PolicyName": DEFAULT_RUNTIME_POLICY,
- "PolicyType": "System",
- }
- )
- )
- return DEFAULT_RUNTIME_ROLE
-
existing = _find_reusable_role(iam)
if existing is not None:
return existing
@@ -197,21 +170,26 @@ def _ensure_runtime_role(
}
]
}
- _result(
- iam.create_role(
- {
- "RoleName": DEFAULT_RUNTIME_ROLE,
- "TrustPolicyDocument": json.dumps(trust_policy),
- }
+ for _ in range(_ROLE_NAME_ATTEMPTS):
+ role_name = _generate_runtime_role_name()
+ if _get_role(iam, role_name) is not None:
+ continue
+ _result(
+ iam.create_role(
+ {
+ "RoleName": role_name,
+ "TrustPolicyDocument": json.dumps(trust_policy),
+ }
+ )
)
- )
- _result(
- iam.attach_role_policy(
- {
- "RoleName": DEFAULT_RUNTIME_ROLE,
- "PolicyName": DEFAULT_RUNTIME_POLICY,
- "PolicyType": "System",
- }
+ _result(
+ iam.attach_role_policy(
+ {
+ "RoleName": role_name,
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
+ }
+ )
)
- )
- return DEFAULT_RUNTIME_ROLE
+ return role_name
+ raise RuntimeError("Unable to generate a unique AgentKit Runtime role name")
diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py
index 8e0e1b19b..124a6b3ef 100644
--- a/frontend/service/studio_release_server/publisher.py
+++ b/frontend/service/studio_release_server/publisher.py
@@ -71,7 +71,7 @@
_MAX_STUDIO_RELEASES = 50
_AGENTKIT_CLI_ARCHIVE = "agentkit-linux-x64.tar.gz"
_AGENTKIT_CLI_ARCHIVE_SHA256 = (
- "bedcf298933ec35f87888d52f2608fa1e48674a1dd946763326adbf60d297b22"
+ "a85ec0d8aebac3d7cddcf53b34d06f5024f34dacca6c35c5131c83722e7e3200"
)
_STUDIO_RELEASE_CONTRACT = "agentkit-cli-v1"
_STUDIO_RUNTIME_MANIFEST = "studio-runtime.json"
diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py
index 17989616a..eebfac684 100644
--- a/tests/cli/conftest.py
+++ b/tests/cli/conftest.py
@@ -14,9 +14,21 @@
from __future__ import annotations
+from collections.abc import Iterator
+
import pytest
from unittest.mock import AsyncMock, MagicMock
+from tests.cli.provider_environment import preserve_cli_provider_environment
+
+
+@pytest.fixture(autouse=True)
+def _isolate_cli_provider_environment() -> Iterator[None]:
+ """Keep direct Studio server calls from leaking provider selection."""
+
+ with preserve_cli_provider_environment():
+ yield
+
@pytest.fixture(autouse=True)
def _stub_studio_local_scheduler(monkeypatch: pytest.MonkeyPatch) -> None:
diff --git a/tests/cli/provider_environment.py b/tests/cli/provider_environment.py
new file mode 100644
index 000000000..6ebd9d4e2
--- /dev/null
+++ b/tests/cli/provider_environment.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from collections.abc import Iterator
+from contextlib import contextmanager
+import os
+
+CLI_PROVIDER_ENV_KEYS = ("AGENTKIT_CLOUD_PROVIDER", "CLOUD_PROVIDER")
+
+
+@contextmanager
+def preserve_cli_provider_environment() -> Iterator[None]:
+ """Restore provider selection changed by direct CLI server calls."""
+
+ original = {name: os.environ.get(name) for name in CLI_PROVIDER_ENV_KEYS}
+ try:
+ yield
+ finally:
+ for name, value in original.items():
+ if value is None:
+ os.environ.pop(name, None)
+ else:
+ os.environ[name] = value
diff --git a/tests/cli/test_provider_environment_isolation.py b/tests/cli/test_provider_environment_isolation.py
new file mode 100644
index 000000000..32d11610f
--- /dev/null
+++ b/tests/cli/test_provider_environment_isolation.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import os
+
+import pytest
+
+from tests.cli.provider_environment import (
+ CLI_PROVIDER_ENV_KEYS,
+ preserve_cli_provider_environment,
+)
+
+
+@pytest.mark.parametrize("original", [None, "volcengine"])
+def test_direct_cli_provider_mutations_are_restored(
+ monkeypatch: pytest.MonkeyPatch,
+ original: str | None,
+) -> None:
+ for name in CLI_PROVIDER_ENV_KEYS:
+ if original is None:
+ monkeypatch.delenv(name, raising=False)
+ else:
+ monkeypatch.setenv(name, original)
+
+ with preserve_cli_provider_environment():
+ for name in CLI_PROVIDER_ENV_KEYS:
+ os.environ[name] = "byteplus"
+
+ assert {name: os.environ.get(name) for name in CLI_PROVIDER_ENV_KEYS} == {
+ name: original for name in CLI_PROVIDER_ENV_KEYS
+ }
diff --git a/tests/frontend/server/test_runtime_iam.py b/tests/frontend/server/test_runtime_iam.py
index afe99bfa6..279d0ffde 100644
--- a/tests/frontend/server/test_runtime_iam.py
+++ b/tests/frontend/server/test_runtime_iam.py
@@ -28,6 +28,9 @@
@pytest.fixture
def iam(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
service = MagicMock()
+ service.list_entities_for_policy.return_value = {
+ "Result": {"PolicyRoles": [], "Total": 0}
+ }
service.list_roles.return_value = {"Result": {"RoleMetadata": [], "Total": 0}}
service.list_attached_role_policies.return_value = {
"Result": {"AttachedPolicyMetadata": []}
@@ -60,29 +63,15 @@ def iam(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
("provider", "host"),
[("volcengine", "iam.volcengineapi.com"), ("byteplus", "iam.byteplusapi.com")],
)
-def test_reuses_matching_role_on_later_page_without_changing_permissions(
+def test_reuses_role_attached_to_default_policy_on_later_page_without_writes(
iam: MagicMock, provider, host: str
) -> None:
- iam.list_roles.side_effect = [
- {"Result": {"RoleMetadata": [{"RoleName": "old-role"}], "Total": 3}},
+ iam.list_entities_for_policy.side_effect = [
+ {"Result": {"PolicyRoles": [], "Total": 101}},
{
"Result": {
- "RoleMetadata": [
- {"RoleName": "shared-role"},
- {"RoleName": "another-role"},
- ],
- "Total": 3,
- }
- },
- ]
- iam.list_attached_role_policies.side_effect = [
- {"Result": {"AttachedPolicyMetadata": [{"PolicyName": "AgentKitFullAccess"}]}},
- {
- "Result": {
- "AttachedPolicyMetadata": [
- {"PolicyName": "OtherAccess", "PolicyType": "System"},
- {"PolicyName": DEFAULT_RUNTIME_POLICY, "PolicyType": "System"},
- ]
+ "PolicyRoles": [{"RoleName": "shared-role"}],
+ "Total": 101,
}
},
]
@@ -97,14 +86,26 @@ def test_reuses_matching_role_on_later_page_without_changing_permissions(
== "shared-role"
)
- assert iam.list_roles.call_args_list == [
- call({"Limit": 100, "Offset": 0}),
- call({"Limit": 100, "Offset": 1}),
- ]
- assert iam.list_attached_role_policies.call_args_list == [
- call({"RoleName": "old-role"}),
- call({"RoleName": "shared-role"}),
+ assert iam.list_entities_for_policy.call_args_list == [
+ call(
+ {
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
+ "Limit": 100,
+ "Offset": 0,
+ }
+ ),
+ call(
+ {
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
+ "Limit": 100,
+ "Offset": 100,
+ }
+ ),
]
+ iam.list_roles.assert_not_called()
+ iam.list_attached_role_policies.assert_not_called()
iam.set_host.assert_called_once_with(host)
iam.set_ak.assert_called_once_with("test-ak")
iam.set_sk.assert_called_once_with("test-sk")
@@ -115,65 +116,71 @@ def test_reuses_matching_role_on_later_page_without_changing_permissions(
iam.update_role.assert_not_called()
-def test_prefers_well_known_legacy_role_without_scanning_account(
+def test_prefers_well_known_role_with_default_policy_without_scanning_account(
iam: MagicMock,
) -> None:
- iam.get_role.return_value = {"Result": {"Role": {"RoleName": DEFAULT_RUNTIME_ROLE}}}
- iam.list_attached_role_policies.return_value = {
+ iam.list_entities_for_policy.return_value = {
"Result": {
- "AttachedPolicyMetadata": [
- {"PolicyName": name, "PolicyType": "System"}
- for name in (
- "CloudControlReadOnlyAccess",
- "AgentKitTosAccess",
- "TorchlightApiFullAccess",
- "LLMShieldProtectSdkAccess",
- "AgentKitToolAccess",
- "IDReadOnlyAccess",
- "Mem0ReadOnlyAccess",
- "AgentKitRuntimeAccess",
- )
- ]
+ "PolicyRoles": [{"RoleName": DEFAULT_RUNTIME_ROLE}],
+ "Total": 1,
}
}
assert ensure_runtime_role(access_key="ak", secret_key="sk") == (
DEFAULT_RUNTIME_ROLE
)
+ iam.get_role.assert_not_called()
iam.list_roles.assert_not_called()
+ iam.list_attached_role_policies.assert_not_called()
iam.create_role.assert_not_called()
iam.attach_role_policy.assert_not_called()
-@pytest.mark.parametrize("provider", ["volcengine", "byteplus"])
-def test_reuses_legacy_agentkit_role_when_role_quota_is_exhausted(
- iam: MagicMock, provider: str
+def test_well_known_legacy_role_is_not_reused_or_mutated(
+ iam: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
- iam.list_roles.return_value = {
- "Result": {
- "RoleMetadata": [
- {"RoleName": "AgentKit_Runtime_Default_ServiceRole_existing"}
- ],
- "Total": 1,
+ generated = f"{DEFAULT_RUNTIME_ROLE}_abc1234"
+ monkeypatch.setattr(
+ "frontend.server.runtime_iam._generate_runtime_role_name",
+ lambda: generated,
+ raising=False,
+ )
+
+ def get_role(request: dict[str, str]) -> dict[str, object]:
+ if request["RoleName"] == DEFAULT_RUNTIME_ROLE:
+ return {"Result": {"Role": {"RoleName": DEFAULT_RUNTIME_ROLE}}}
+ return {
+ "ResponseMetadata": {
+ "Error": {"Code": "RoleNotExist", "Message": "role does not exist"}
+ }
}
- }
- iam.list_attached_role_policies.return_value = {
- "Result": {
- "AttachedPolicyMetadata": [
- {"PolicyName": name, "PolicyType": "System"}
- for name in (
- "CloudControlReadOnlyAccess",
- "AgentKitTosAccess",
- "TorchlightApiFullAccess",
- "LLMShieldProtectSdkAccess",
- "AgentKitToolAccess",
- "IDReadOnlyAccess",
- "Mem0ReadOnlyAccess",
- "AgentKitRuntimeAccess",
- )
- ]
+
+ iam.get_role.side_effect = get_role
+ assert ensure_runtime_role(access_key="ak", secret_key="sk") == generated
+ iam.create_role.assert_called_once()
+ assert iam.create_role.call_args.args[0]["RoleName"] == generated
+ iam.attach_role_policy.assert_called_once_with(
+ {
+ "RoleName": generated,
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
}
- }
+ )
+ assert call({"RoleName": DEFAULT_RUNTIME_ROLE}) not in (
+ iam.attach_role_policy.call_args_list
+ )
+
+
+@pytest.mark.parametrize("provider", ["volcengine", "byteplus"])
+def test_legacy_role_is_not_reused_when_role_quota_is_exhausted(
+ iam: MagicMock, provider, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ generated = f"{DEFAULT_RUNTIME_ROLE}_quota123"
+ monkeypatch.setattr(
+ "frontend.server.runtime_iam._generate_runtime_role_name",
+ lambda: generated,
+ raising=False,
+ )
iam.create_role.return_value = {
"ResponseMetadata": {
"Error": {
@@ -183,35 +190,34 @@ def test_reuses_legacy_agentkit_role_when_role_quota_is_exhausted(
}
}
- assert (
+ with pytest.raises(RuntimeError, match="Exceeded RolesPerAccount quota"):
ensure_runtime_role(access_key="ak", secret_key="sk", provider=provider)
- == "AgentKit_Runtime_Default_ServiceRole_existing"
- )
- iam.create_role.assert_not_called()
+ assert iam.create_role.call_args.args[0]["RoleName"] == generated
iam.attach_role_policy.assert_not_called()
@pytest.mark.parametrize("provider", ["volcengine", "byteplus"])
@pytest.mark.parametrize("existing_roles", [False, True])
def test_creates_only_default_policy_when_no_role_matches(
- iam: MagicMock, provider, existing_roles: bool
+ iam: MagicMock,
+ provider,
+ existing_roles: bool,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ generated = f"{DEFAULT_RUNTIME_ROLE}_abc1234"
+ monkeypatch.setattr(
+ "frontend.server.runtime_iam._generate_runtime_role_name",
+ lambda: generated,
+ raising=False,
+ )
if existing_roles:
- iam.list_roles.return_value = {
- "Result": {"RoleMetadata": [{"RoleName": "unrelated"}], "Total": 1}
- }
- iam.list_attached_role_policies.return_value = {
- "Result": {
- "AttachedPolicyMetadata": [
- {"PolicyName": DEFAULT_RUNTIME_POLICY, "PolicyType": "Custom"},
- {"PolicyName": "AgentKitFullAccess", "PolicyType": "System"},
- ]
- }
+ iam.list_entities_for_policy.return_value = {
+ "Result": {"PolicyRoles": [], "Total": 0}
}
name = ensure_runtime_role(access_key="ak", secret_key="sk", provider=provider)
- assert name == DEFAULT_RUNTIME_ROLE
+ assert name == generated
created = iam.create_role.call_args.args[0]
assert created["RoleName"] == name
assert json.loads(created["TrustPolicyDocument"]) == {
@@ -229,6 +235,41 @@ def test_creates_only_default_policy_when_no_role_matches(
iam.set_session_token.assert_not_called()
+def test_generated_role_name_collision_is_retried_without_mutating_existing_role(
+ iam: MagicMock, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ collision = f"{DEFAULT_RUNTIME_ROLE}_same123"
+ generated = f"{DEFAULT_RUNTIME_ROLE}_fresh12"
+ names = iter((collision, generated))
+ monkeypatch.setattr(
+ "frontend.server.runtime_iam._generate_runtime_role_name",
+ lambda: next(names),
+ raising=False,
+ )
+
+ def get_role(request: dict[str, str]) -> dict[str, object]:
+ name = request["RoleName"]
+ if name == collision:
+ return {"Result": {"Role": {"RoleName": collision}}}
+ return {
+ "ResponseMetadata": {
+ "Error": {"Code": "RoleNotExist", "Message": "role does not exist"}
+ }
+ }
+
+ iam.get_role.side_effect = get_role
+
+ assert ensure_runtime_role(access_key="ak", secret_key="sk") == generated
+ assert iam.create_role.call_args.args[0]["RoleName"] == generated
+ iam.attach_role_policy.assert_called_once_with(
+ {
+ "RoleName": generated,
+ "PolicyName": DEFAULT_RUNTIME_POLICY,
+ "PolicyType": "System",
+ }
+ )
+
+
def test_keeps_staging_trust_service(iam: MagicMock, monkeypatch) -> None:
monkeypatch.setenv("VOLCENGINE_AGENTKIT_SERVICE", "agentkit_stg")
ensure_runtime_role(access_key="ak", secret_key="sk")
@@ -236,13 +277,10 @@ def test_keeps_staging_trust_service(iam: MagicMock, monkeypatch) -> None:
assert document["Statement"][0]["Principal"]["Service"] == ["vefaas_dev"]
-@pytest.mark.parametrize("operation", ["list_roles", "list_attached_role_policies"])
+@pytest.mark.parametrize("operation", ["list_entities_for_policy"])
def test_lookup_errors_never_create_a_fallback_role(
iam: MagicMock, operation: str
) -> None:
- iam.list_roles.return_value = {
- "Result": {"RoleMetadata": [{"RoleName": "existing"}], "Total": 1}
- }
getattr(iam, operation).return_value = {
"ResponseMetadata": {"Error": {"Code": "AccessDenied", "Message": "denied"}}
}
@@ -253,7 +291,7 @@ def test_lookup_errors_never_create_a_fallback_role(
def test_network_failure_never_creates_a_fallback_role(iam: MagicMock) -> None:
- iam.list_roles.side_effect = TimeoutError("IAM timeout")
+ iam.list_entities_for_policy.side_effect = TimeoutError("IAM timeout")
with pytest.raises(TimeoutError):
ensure_runtime_role(access_key="ak", secret_key="sk")
iam.create_role.assert_not_called()
@@ -261,10 +299,10 @@ def test_network_failure_never_creates_a_fallback_role(iam: MagicMock) -> None:
@pytest.mark.parametrize(
"result",
- [{}, {"RoleMetadata": [], "Total": 1}, {"RoleMetadata": "invalid", "Total": 0}],
+ [{}, {"PolicyRoles": [], "Total": -1}, {"PolicyRoles": "invalid", "Total": 0}],
)
def test_incomplete_role_list_never_creates_a_role(iam: MagicMock, result) -> None:
- iam.list_roles.return_value = {"Result": result}
+ iam.list_entities_for_policy.return_value = {"Result": result}
with pytest.raises(RuntimeError):
ensure_runtime_role(access_key="ak", secret_key="sk")
iam.create_role.assert_not_called()
diff --git a/veadk/cli/agentkit_cli.py b/veadk/cli/agentkit_cli.py
index a7bc20bb1..8a49da3f8 100644
--- a/veadk/cli/agentkit_cli.py
+++ b/veadk/cli/agentkit_cli.py
@@ -82,35 +82,35 @@ def url(self) -> str:
"linux-x64": AgentKitCliArtifact(
platform_key="linux-x64",
filename="agentkit-linux-x64.tar.gz",
- sha256="bedcf298933ec35f87888d52f2608fa1e48674a1dd946763326adbf60d297b22",
+ sha256="a85ec0d8aebac3d7cddcf53b34d06f5024f34dacca6c35c5131c83722e7e3200",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-x64",
executable_name="ak",
),
"linux-arm64": AgentKitCliArtifact(
platform_key="linux-arm64",
filename="agentkit-linux-arm64.tar.gz",
- sha256="5a871ba705ff08ef4fdd199b47accaad591f9eb1e18be6ba7ed13a604ce4bb11",
+ sha256="64983fd4a092c34653dc054bc2be9d582ab2dbce4cea383a4ca4fcecb992f3fe",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-arm64",
executable_name="ak",
),
"darwin-x64": AgentKitCliArtifact(
platform_key="darwin-x64",
filename="agentkit-darwin-x64.tar.gz",
- sha256="801cf9718449cf9ee252d91b064d273c2816e2aaa80c75f67e6c0f3c5272fb38",
+ sha256="79e01d2392fc69097c6db8a154426b24536e5c65f6432f8168461aed758a436e",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-x64",
executable_name="ak",
),
"darwin-arm64": AgentKitCliArtifact(
platform_key="darwin-arm64",
filename="agentkit-darwin-arm64.tar.gz",
- sha256="4d7ec3327155a8049130d7c96d8f8661706408a68d3290f00f373c75d3bf6d16",
+ sha256="43f65966cf27c3f0b6bd5fdb43fe9a4d8b6669eb365e3a3cc5fbac5c6f9fec02",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-arm64",
executable_name="ak",
),
"windows-x64": AgentKitCliArtifact(
platform_key="windows-x64",
filename="agentkit-windows-x64.zip",
- sha256="27562cbaec17a5305c4ee77c5da32f9f7cbd2f9f3da7689d391b6dc6c2487a5b",
+ sha256="47a22120aeb8de137f7761de2db45a58347586d11e8903a29594e6ceb0fc704b",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-windows-x64",
executable_name="ak.exe",
),
From 0b3639c332cdedc7d0f629aa9c2c814f7d0f87d3 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 15:34:17 +0800
Subject: [PATCH 11/46] perf(studio): defer generated cloud SDK imports
---
.../harness-sidecar-release-gate.yaml | 5 ++
frontend/server/skills/reviewer_profiles.py | 4 +-
frontend/server/user_management/directory.py | 21 ++++++--
tests/cli/test_studio_startup_imports.py | 54 +++++++++++++++++++
tests/frontend/server/test_user_management.py | 7 ++-
veadk/cli/studio_vpc_network.py | 17 ++++--
6 files changed, 96 insertions(+), 12 deletions(-)
create mode 100644 tests/cli/test_studio_startup_imports.py
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index ab1768d0b..2414bdbf2 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -13,6 +13,7 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
@@ -26,6 +27,7 @@ on:
- 'veadk/cli/agentkit_cli.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
+ - 'veadk/cli/studio_vpc_network.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -40,6 +42,7 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
@@ -53,6 +56,7 @@ on:
- 'veadk/cli/agentkit_cli.py'
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
+ - 'veadk/cli/studio_vpc_network.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -93,6 +97,7 @@ jobs:
tests/cli/test_generated_agent_harness_sidecar.py \
tests/cli/test_generated_agent_sidecar_runtime.py \
tests/cli/test_managed_sidecar_source.py \
+ tests/cli/test_studio_startup_imports.py \
tests/integrations/agentkit/test_app.py \
tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \
tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan \
diff --git a/frontend/server/skills/reviewer_profiles.py b/frontend/server/skills/reviewer_profiles.py
index 0dbf09b03..f43003538 100644
--- a/frontend/server/skills/reviewer_profiles.py
+++ b/frontend/server/skills/reviewer_profiles.py
@@ -20,8 +20,6 @@
from typing import Any, TypedDict
from urllib.parse import urlsplit
-import volcenginesdkid as sdk
-
from frontend.server.user_management.directory import IdentityDirectory
from frontend.server.user_management.errors import UserManagementError
@@ -89,6 +87,8 @@ def resolve(
self._cache.move_to_end(uid)
return {**cached[1], "name": cached[1]["name"] or fallback["name"]}
try:
+ import volcenginesdkid as sdk
+
# PoolUser intentionally omits profile fields; read the SDK response
# through the same authenticated adapter without listing other users
user = self.directory._call(
diff --git a/frontend/server/user_management/directory.py b/frontend/server/user_management/directory.py
index 7aefc1a30..56f3bded8 100644
--- a/frontend/server/user_management/directory.py
+++ b/frontend/server/user_management/directory.py
@@ -18,10 +18,7 @@
from dataclasses import dataclass
from typing import Any
-import volcenginesdkcore
-import volcenginesdkid as sdk
from urllib3.exceptions import HTTPError
-from volcenginesdkcore.rest import ApiException
from veadk.utils.cloud_provider import (
CloudProvider,
@@ -32,6 +29,13 @@
from .errors import UserManagementError
+def _identity_sdk() -> Any:
+ """Load generated Identity models only when a directory call is made."""
+ import volcenginesdkid
+
+ return volcenginesdkid
+
+
@dataclass(frozen=True)
class PoolUser:
uid: str
@@ -82,6 +86,10 @@ def __init__(
self.credentials = credentials
def _call(self, action: str, body: Any) -> Any:
+ import volcenginesdkcore
+ from volcenginesdkcore.rest import ApiException
+
+ sdk = _identity_sdk()
# Resolve credentials for each call so rotating cloud credentials remain valid
ak, sk, token = self.credentials()
# The generated SDK types host as None although it accepts URL strings
@@ -102,6 +110,7 @@ def _call(self, action: str, body: Any) -> Any:
raise UserManagementError(503, "identity_unavailable") from error
def users(self) -> list[PoolUser]:
+ sdk = _identity_sdk()
users: list[PoolUser] = []
page = 1
while True:
@@ -120,6 +129,7 @@ def users(self) -> list[PoolUser]:
page += 1
def user(self, uid: str) -> PoolUser:
+ sdk = _identity_sdk()
return PoolUser.from_sdk(
self._call(
"get_user",
@@ -131,6 +141,7 @@ def user(self, uid: str) -> PoolUser:
)
def groups(self) -> list[IdentityGroup]:
+ sdk = _identity_sdk()
groups: list[IdentityGroup] = []
page = 1
while True:
@@ -152,6 +163,7 @@ def groups(self) -> list[IdentityGroup]:
page += 1
def create_group(self, name: str, description: str) -> IdentityGroup:
+ sdk = _identity_sdk()
result = self._call(
"create_group",
sdk.CreateGroupRequest(
@@ -163,6 +175,7 @@ def create_group(self, name: str, description: str) -> IdentityGroup:
return IdentityGroup(result.uid, name, description)
def describe_group(self, uid: str, description: str) -> None:
+ sdk = _identity_sdk()
self._call(
"update_group",
sdk.UpdateGroupRequest(
@@ -173,6 +186,7 @@ def describe_group(self, uid: str, description: str) -> None:
)
def add(self, group_uid: str, user_uid: str) -> None:
+ sdk = _identity_sdk()
self._call(
"add_users_to_group",
sdk.AddUsersToGroupRequest(
@@ -183,6 +197,7 @@ def add(self, group_uid: str, user_uid: str) -> None:
)
def remove(self, group_uid: str, user_uid: str) -> None:
+ sdk = _identity_sdk()
self._call(
"remove_users_from_group",
sdk.RemoveUsersFromGroupRequest(
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
new file mode 100644
index 000000000..d2690d86e
--- /dev/null
+++ b/tests/cli/test_studio_startup_imports.py
@@ -0,0 +1,54 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+
+def test_studio_startup_modules_do_not_eagerly_load_generated_cloud_models() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import importlib
+import sys
+
+for module in (
+ "frontend.server.user_management.directory",
+ "frontend.server.skills.reviewer_profiles",
+ "veadk.cli.studio_vpc_network",
+):
+ importlib.import_module(module)
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name.startswith((
+ "volcenginesdkid",
+ "volcenginesdkvefaas",
+ "volcenginesdkvpc",
+ ))
+)
+if unexpected:
+ raise SystemExit("generated cloud SDKs loaded during Studio startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
diff --git a/tests/frontend/server/test_user_management.py b/tests/frontend/server/test_user_management.py
index c30534d38..8799eb37f 100644
--- a/tests/frontend/server/test_user_management.py
+++ b/tests/frontend/server/test_user_management.py
@@ -479,6 +479,7 @@ def access(request: Request):
def test_directory_uses_provider_host_and_fetches_all_pages(
monkeypatch, provider, region, host
):
+ import importlib
from types import SimpleNamespace
from frontend.server.user_management import directory as module
@@ -507,8 +508,10 @@ def list_users(self, body, **kwargs):
)
return SimpleNamespace(data=[value], total_count=2)
- monkeypatch.setattr(module.volcenginesdkcore, "ApiClient", api_client)
- monkeypatch.setattr(module.sdk, "IDApi", Api)
+ monkeypatch.setattr(
+ importlib.import_module("volcenginesdkcore"), "ApiClient", api_client
+ )
+ monkeypatch.setattr(module._identity_sdk(), "IDApi", Api)
monkeypatch.delenv("IDENTITY_OPENAPI_HOST", raising=False)
directory = module.IdentityDirectory(
"pool", provider, region, lambda: ("test-ak", "test-sk", None)
diff --git a/veadk/cli/studio_vpc_network.py b/veadk/cli/studio_vpc_network.py
index 57e27af8d..d6ed4255d 100644
--- a/veadk/cli/studio_vpc_network.py
+++ b/veadk/cli/studio_vpc_network.py
@@ -21,10 +21,6 @@
from collections.abc import Mapping
from typing import Any
-import volcenginesdkcore
-import volcenginesdkvefaas
-import volcenginesdkvpc
-
from veadk.utils.cloud_provider import CloudProvider, vefaas_openapi_host
IpNetwork = ipaddress.IPv4Network | ipaddress.IPv6Network
@@ -57,7 +53,9 @@ def _configuration(
session_token: str | None,
region: str,
host: str | None = None,
-) -> volcenginesdkcore.Configuration:
+) -> Any:
+ import volcenginesdkcore
+
configuration = volcenginesdkcore.Configuration()
configuration.ak = access_key
configuration.sk = secret_key
@@ -77,6 +75,9 @@ def _vefaas_client(
secret_key: str,
session_token: str | None,
) -> Any:
+ import volcenginesdkcore
+ import volcenginesdkvefaas
+
configuration = _configuration(
access_key=access_key,
secret_key=secret_key,
@@ -97,6 +98,9 @@ def _vpc_client(
secret_key: str,
session_token: str | None,
) -> Any:
+ import volcenginesdkcore
+ import volcenginesdkvpc
+
configuration = _configuration(
access_key=access_key,
secret_key=secret_key,
@@ -143,6 +147,9 @@ def discover_studio_vpc_networks(
raise StudioVpcDiscoveryError("The current VeFaaS function ID is unavailable.")
try:
+ import volcenginesdkvefaas
+ import volcenginesdkvpc
+
function_client = vefaas_client or _vefaas_client(
provider=provider,
region=region,
From a74802ef09d62b7985f401a808713abcf3ffb820 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 15:50:18 +0800
Subject: [PATCH 12/46] perf(agentkit): lazily expose server helpers
---
tests/cli/test_studio_startup_imports.py | 3 +++
veadk/integrations/agentkit/__init__.py | 30 ++++++++++++++++++++----
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index d2690d86e..65b01cf72 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -29,6 +29,7 @@ def test_studio_startup_modules_do_not_eagerly_load_generated_cloud_models() ->
"frontend.server.user_management.directory",
"frontend.server.skills.reviewer_profiles",
"veadk.cli.studio_vpc_network",
+ "veadk.integrations.agentkit.studio_routes.protocol",
):
importlib.import_module(module)
@@ -43,6 +44,8 @@ def test_studio_startup_modules_do_not_eagerly_load_generated_cloud_models() ->
)
if unexpected:
raise SystemExit("generated cloud SDKs loaded during Studio startup")
+if "veadk.integrations.agentkit.app" in sys.modules:
+ raise SystemExit("AgentKit application loaded by a protocol-only import")
"""
subprocess.run(
diff --git a/veadk/integrations/agentkit/__init__.py b/veadk/integrations/agentkit/__init__.py
index 6ae46c914..d3e71c869 100644
--- a/veadk/integrations/agentkit/__init__.py
+++ b/veadk/integrations/agentkit/__init__.py
@@ -14,9 +14,31 @@
"""Public helpers for serving VeADK agents on AgentKit."""
-from veadk.integrations.agentkit.app import (
- create_agentkit_app,
- run_agentkit_app,
-)
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from veadk.integrations.agentkit.app import (
+ create_agentkit_app,
+ run_agentkit_app,
+ )
__all__ = ["create_agentkit_app", "run_agentkit_app"]
+
+
+def __getattr__(name: str) -> Any:
+ """Keep protocol-only imports from initializing the full AgentKit server."""
+ if name not in __all__:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ from veadk.integrations.agentkit.app import (
+ create_agentkit_app,
+ run_agentkit_app,
+ )
+
+ return {
+ "create_agentkit_app": create_agentkit_app,
+ "run_agentkit_app": run_agentkit_app,
+ }[name]
+
+
+def __dir__() -> list[str]:
+ return sorted((*globals(), *__all__))
From d0acb46cd5a4b29bf94e0ae154e52a81a2fe0d23 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 17:00:22 +0800
Subject: [PATCH 13/46] chore: add release gate license header
---
tests/test_harness_sidecar_release_gate.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index a4b96b691..0c81f050e 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -1,3 +1,17 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
from __future__ import annotations
from pathlib import Path
From b678e1a05561155381cc987841c50edb1a21416c Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 17:36:26 +0800
Subject: [PATCH 14/46] fix(studio): preserve sidecar updates without user mcp
---
.../harness-sidecar-release-gate.yaml | 3 +-
tests/cli/test_legacy_runtime_recovery.py | 9 +++
tests/cli/test_studio_rbac.py | 79 +++++++++++++++----
.../harness/sidecar_runtime/test_sidecar.py | 22 ++++++
tests/test_harness_sidecar_release_gate.py | 2 +
veadk/cli/cli_frontend.py | 40 +++++-----
veadk/cli/legacy_runtime_recovery.py | 6 +-
.../harness/sidecar_runtime/sidecar.py | 10 ++-
8 files changed, 130 insertions(+), 41 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 2414bdbf2..7047c5125 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -109,7 +109,8 @@ jobs:
pids+=("$!")
(
python -m pytest -q \
- tests/cli/test_legacy_runtime_recovery.py -k 'six_case_ or changed_unnamed_mcp_url'
+ tests/cli/test_legacy_runtime_recovery.py -k 'six_case_ or changed_unnamed_mcp_url or zero_mcp_servers_json' \
+ tests/extensions/harness/sidecar_runtime/test_sidecar.py::test_zero_structured_mcp_is_valid_and_does_not_reactivate_legacy_upstream
) > "$RUNNER_TEMP/sidecar-lifecycle.log" 2>&1 &
pids+=("$!")
(
diff --git a/tests/cli/test_legacy_runtime_recovery.py b/tests/cli/test_legacy_runtime_recovery.py
index dbff74901..37862f661 100644
--- a/tests/cli/test_legacy_runtime_recovery.py
+++ b/tests/cli/test_legacy_runtime_recovery.py
@@ -316,6 +316,15 @@ def test_mcp_servers_json_recovers_public_shape_without_headers() -> None:
assert raw_secret not in json.dumps(editor_draft)
+def test_zero_mcp_servers_json_is_valid_no_user_mcp_state() -> None:
+ recovery = recover_mcp_from_runtime_environment({"MCP_SERVERS_JSON": "[]"})
+
+ assert recovery.format == "servers-json"
+ assert recovery.tools == ()
+ assert recovery.configured_reference_keys == ()
+ assert mcp_secret_values_from_runtime_environment({"MCP_SERVERS_JSON": "[]"}) == {}
+
+
def test_structured_mcp_servers_reject_shared_url_with_distinct_names() -> None:
with pytest.raises(LegacyRecoveryError, match="legacy_mcp_url_duplicate"):
build_sidecar_mcp_servers_json(
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index f7bfb3f26..de37d59a5 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -454,10 +454,13 @@ def test_migration_model_defaults_follow_studio_provider(
}
-def test_managed_sidecar_runtime_envs_fail_before_build_without_mcp_upstream() -> None:
+def test_managed_sidecar_runtime_envs_allow_zero_mcp_without_legacy_fallback() -> None:
runtime_envs = {
"MODEL_AGENT_API_BASE": "https://ark.cn-beijing.volces.com/api/v3",
"MODEL_AGENT_API_KEY": "model-key-from-test-fixture",
+ "MCP_SERVERS_JSON": "[]",
+ "MCP_URLS": "https://stale-mcp.example.com/mcp",
+ "MCP_API_KEY": "stale-test-key",
}
error = _prepare_managed_sidecar_runtime_envs(
@@ -466,12 +469,37 @@ def test_managed_sidecar_runtime_envs_fail_before_build_without_mcp_upstream() -
{"effectiveComponents": ["context_engine", "mcp_resilience"]},
)
- assert error == (
- "已选择 MCP 稳定性治理,请在“添加 MCP 工具”中配置至少一个 HTTP MCP "
- "服务地址后重新发布;Bearer Token 仅在该服务需要认证时配置。"
- )
+ assert error is None
assert runtime_envs["MODEL_AGENT_NAME"]
assert runtime_envs["MODEL_NAME"] == runtime_envs["MODEL_AGENT_NAME"]
+ assert runtime_envs["AGENTKIT_HARNESS_RUNTIME_COMMAND"]
+ assert "MCP_SERVERS_JSON" not in runtime_envs
+ assert "MCP_URLS" not in runtime_envs
+ assert "MCP_API_KEY" not in runtime_envs
+
+
+@pytest.mark.parametrize(
+ "raw_mcp",
+ ["not-json", "{}", json.dumps([{}] * 33)],
+ ids=["malformed", "not-list", "too-many"],
+)
+def test_managed_sidecar_runtime_envs_reject_invalid_mcp_state(
+ raw_mcp: str,
+) -> None:
+ runtime_envs = {
+ "MODEL_AGENT_API_BASE": "https://ark.cn-beijing.volces.com/api/v3",
+ "MODEL_AGENT_API_KEY": "model-key-from-test-fixture",
+ "MCP_SERVERS_JSON": raw_mcp,
+ }
+
+ error = _prepare_managed_sidecar_runtime_envs(
+ runtime_envs,
+ "volcengine",
+ {"effectiveComponents": ["mcp_resilience"]},
+ )
+
+ assert error == "Harness Sidecar MCP 配置无效,请检查名称、地址与认证后重试。"
+ assert "AGENTKIT_HARNESS_RUNTIME_COMMAND" not in runtime_envs
def test_migration_model_defaults_preserve_custom_endpoint() -> None:
@@ -5404,9 +5432,15 @@ def launch(*, config_file: str, config_dict: Any, **_kwargs: Any) -> Any:
assert update_tags["veadk:build-resource:cr-repository"] == expected_repository
+@pytest.mark.parametrize(
+ "has_user_mcp",
+ [False, True],
+ ids=["no-user-mcp", "stored-user-mcp"],
+)
def test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
+ has_user_mcp: bool,
) -> None:
from agentkit.sdk.runtime.client import AgentkitRuntimeClient
@@ -5422,6 +5456,17 @@ def test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk(
runtime.artifact_url = (
"example-registry-cn-shanghai.cr.volces.com/agentkit/sidecar:v9"
)
+ mcp_servers = (
+ [
+ {
+ "name": "orders",
+ "url": "https://mcp.example.com/orders",
+ "headers": {"Authorization": "Bearer sidecar-test-secret"},
+ }
+ ]
+ if has_user_mcp
+ else []
+ )
runtime.envs = [
SimpleNamespace(key="HARNESS_SIDECAR_ENABLED", value="true"),
SimpleNamespace(key="HARNESS_PROFILE", value="ops"),
@@ -5446,15 +5491,7 @@ def test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk(
),
SimpleNamespace(
key="MCP_SERVERS_JSON",
- value=json.dumps(
- [
- {
- "name": "orders",
- "url": "https://mcp.example.com/orders",
- "headers": {"Authorization": "Bearer sidecar-test-secret"},
- }
- ]
- ),
+ value=json.dumps(mcp_servers),
),
SimpleNamespace(
key="MODEL_AGENT_API_BASE",
@@ -5631,8 +5668,11 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
"long_run_control": False,
"mcp_resilience": True,
}
- assert "authToken" not in draft["mcpTools"][0]
- assert "sidecar-test-secret" not in capability.text
+ if has_user_mcp:
+ assert "authToken" not in draft["mcpTools"][0]
+ assert "sidecar-test-secret" not in capability.text
+ else:
+ assert draft["mcpTools"] == []
submitted_sidecar = {
**draft["harnessSidecar"],
"componentOverrides": {
@@ -5709,7 +5749,12 @@ def readiness_get(url: str, **_kwargs: Any) -> ReadyResponse:
assert json.loads(captured["mcp"]) == {}
assert "sidecar-test-secret" not in captured["persisted_config"]
runtime_envs = captured["config"]["launch_types"]["cloud"]["runtime_envs"]
- assert "sidecar-test-secret" in runtime_envs["MCP_SERVERS_JSON"]
+ if has_user_mcp:
+ assert "sidecar-test-secret" in runtime_envs["MCP_SERVERS_JSON"]
+ else:
+ assert "MCP_SERVERS_JSON" not in runtime_envs
+ assert "MCP_URLS" not in runtime_envs
+ assert "MCP_API_KEY" not in runtime_envs
assert runtime_envs["AGENTKIT_HARNESS_RUNTIME_COMMAND"]
assert runtime_envs["HARNESS_SIDECAR_ENABLED"] == "true"
assert runtime_envs["HARNESS_SIDECAR_EXPECTED_PLAN_HASH"] == ("sha256:test-plan")
diff --git a/tests/extensions/harness/sidecar_runtime/test_sidecar.py b/tests/extensions/harness/sidecar_runtime/test_sidecar.py
index 2650392ad..2ceca446b 100644
--- a/tests/extensions/harness/sidecar_runtime/test_sidecar.py
+++ b/tests/extensions/harness/sidecar_runtime/test_sidecar.py
@@ -28,6 +28,7 @@
import pytest
from veadk.extensions.harness.sidecar_runtime.sidecar import (
+ _configure_structured_mcp_upstreams,
HarnessSidecarError,
HarnessSidecarRuntimeUnavailable,
doctor_harness_sidecar,
@@ -40,6 +41,27 @@
)
+def test_zero_structured_mcp_is_valid_and_does_not_reactivate_legacy_upstream() -> None:
+ config = HarnessSidecarConfig(
+ profile="ops",
+ model_proxy={"enabled": False},
+ mcp_gateway={"enabled": True},
+ )
+ runtime_env = {
+ "MCP_SERVERS_JSON": "[]",
+ "MCP_URLS": "https://stale-mcp.example.com/mcp",
+ "MCP_API_KEY": "stale-test-key",
+ }
+
+ resolved, relays = _configure_structured_mcp_upstreams(config, runtime_env)
+
+ assert resolved is config
+ assert relays == []
+ assert "MCP_SERVERS_JSON" not in runtime_env
+ assert "MCP_URLS" not in runtime_env
+ assert "MCP_API_KEY" not in runtime_env
+
+
@pytest.fixture
def fake_runtime(tmp_path: Path) -> Path:
path = tmp_path / "fake_runtime.py"
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 0c81f050e..45e3eb254 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -52,6 +52,8 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
aggregate_run = _run_script(aggregate)
assert "test_studio_sidecar.py" in backend_run
assert "test_legacy_runtime_recovery.py" in backend_run
+ assert "zero_mcp_servers_json" in backend_run
+ assert "test_zero_structured_mcp_is_valid" in backend_run
assert (
"test_source_preserving_legacy_ops_update_migrates_output_repository_via_sdk"
in backend_run
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index a7d9a2995..f5ad57aa4 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -547,29 +547,27 @@ def _prepare_managed_sidecar_runtime_envs(
"请返回模型配置后重新发布。"
)
runtime_envs.setdefault("MODEL_NAME", runtime_envs["MODEL_AGENT_NAME"])
- effective_components = set(
- sidecar_plan.get("effectiveComponents") or []
- if isinstance(sidecar_plan, Mapping)
- else []
- )
raw_structured_mcp = runtime_envs.get("MCP_SERVERS_JSON", "").strip()
- try:
- structured_mcp = json.loads(raw_structured_mcp) if raw_structured_mcp else []
- except (TypeError, ValueError):
- structured_mcp = []
- has_structured_mcp = isinstance(structured_mcp, list) and bool(structured_mcp)
- has_legacy_mcp = all(
- runtime_envs.get(key, "").strip() for key in ("MCP_URLS", "MCP_API_KEY")
- )
- if (
- effective_components & {"mcp_resilience", "sql_readonly"}
- and not has_structured_mcp
- and not has_legacy_mcp
- ):
- return (
- "已选择 MCP 稳定性治理,请在“添加 MCP 工具”中配置至少一个 HTTP MCP "
- "服务地址后重新发布;Bearer Token 仅在该服务需要认证时配置。"
+ if raw_structured_mcp:
+ from veadk.cli.legacy_runtime_recovery import (
+ LegacyRecoveryError,
+ recover_mcp_from_runtime_environment,
)
+
+ try:
+ recovered_mcp = recover_mcp_from_runtime_environment(
+ {"MCP_SERVERS_JSON": raw_structured_mcp}
+ )
+ except LegacyRecoveryError as error:
+ return _mcp_deployment_error_detail(error.code)
+ if not recovered_mcp.tools:
+ # Historical managed Runtimes persist an explicit empty list when
+ # no user MCP exists. The list is a valid source state, but the
+ # Sidecar process does not need an upstream configuration for it.
+ # Treat it as authoritative and remove any stale legacy fallback
+ # values so an update cannot accidentally reactivate an old MCP.
+ for key in ("MCP_SERVERS_JSON", "MCP_URLS", "MCP_API_KEY"):
+ runtime_envs.pop(key, None)
from veadk.extensions.harness.sidecar import (
MANAGED_HARNESS_SIDECAR_RUNTIME_COMMAND,
)
diff --git a/veadk/cli/legacy_runtime_recovery.py b/veadk/cli/legacy_runtime_recovery.py
index 88964f30a..9991a4a67 100644
--- a/veadk/cli/legacy_runtime_recovery.py
+++ b/veadk/cli/legacy_runtime_recovery.py
@@ -1348,7 +1348,11 @@ def recover_mcp_from_runtime_environment(
payload = json.loads(raw_servers)
except (TypeError, ValueError) as error:
raise LegacyRecoveryError("legacy_mcp_servers_json_invalid") from error
- if not isinstance(payload, list) or not 1 <= len(payload) <= 32:
+ # Managed Sidecar persists an explicit empty list when MCP-capable
+ # components are enabled but the user configured no MCP servers. That
+ # is a valid, stable state and must survive later source-preserving
+ # updates without manufacturing a server or requiring new input.
+ if not isinstance(payload, list) or len(payload) > 32:
raise LegacyRecoveryError("legacy_mcp_servers_json_invalid")
tools: list[dict[str, Any]] = []
seen_names: set[str] = set()
diff --git a/veadk/extensions/harness/sidecar_runtime/sidecar.py b/veadk/extensions/harness/sidecar_runtime/sidecar.py
index 50fd5f6e7..a11df56cf 100644
--- a/veadk/extensions/harness/sidecar_runtime/sidecar.py
+++ b/veadk/extensions/harness/sidecar_runtime/sidecar.py
@@ -519,6 +519,14 @@ def _configure_structured_mcp_upstreams(
if not raw or not config.mcp_gateway.enabled:
return config, []
servers = _parse_structured_mcp_servers(raw)
+ if not servers:
+ # An explicit empty list is the canonical legacy representation of a
+ # managed Runtime with no user MCP. Do not manufacture credentials or
+ # fall back to stale legacy upstreams when starting that Runtime.
+ runtime_env.pop(_MCP_SERVERS_JSON_ENV, None)
+ runtime_env.pop("MCP_URLS", None)
+ runtime_env.pop("MCP_API_KEY", None)
+ return config, []
internal_api_key = token_urlsafe(32)
relays: list[ManagedMcpUpstreamRelay] = []
try:
@@ -559,7 +567,7 @@ def _parse_structured_mcp_servers(raw: str) -> list[_ManagedMcpServer]:
raise HarnessSidecarError(
"Managed MCP server configuration is invalid"
) from error
- if not isinstance(payload, list) or not 1 <= len(payload) <= 32:
+ if not isinstance(payload, list) or len(payload) > 32:
raise HarnessSidecarError("Managed MCP server configuration is invalid")
servers: list[_ManagedMcpServer] = []
seen_names: set[str] = set()
From a7de072060b521d0a44b92ae511edabdb9b73f9b Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Mon, 14 Sep 2026 19:38:56 +0800
Subject: [PATCH 15/46] chore(studio): pin released agentkit cli artifacts
---
frontend/service/studio_release_server/publisher.py | 2 +-
veadk/cli/agentkit_cli.py | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py
index 124a6b3ef..f64f6acdf 100644
--- a/frontend/service/studio_release_server/publisher.py
+++ b/frontend/service/studio_release_server/publisher.py
@@ -71,7 +71,7 @@
_MAX_STUDIO_RELEASES = 50
_AGENTKIT_CLI_ARCHIVE = "agentkit-linux-x64.tar.gz"
_AGENTKIT_CLI_ARCHIVE_SHA256 = (
- "a85ec0d8aebac3d7cddcf53b34d06f5024f34dacca6c35c5131c83722e7e3200"
+ "8cc62477c582a7b9c3f460d2558bed45ee4c81c0169eabc228ddb19387268473"
)
_STUDIO_RELEASE_CONTRACT = "agentkit-cli-v1"
_STUDIO_RUNTIME_MANIFEST = "studio-runtime.json"
diff --git a/veadk/cli/agentkit_cli.py b/veadk/cli/agentkit_cli.py
index 8a49da3f8..2dc2745fd 100644
--- a/veadk/cli/agentkit_cli.py
+++ b/veadk/cli/agentkit_cli.py
@@ -82,35 +82,35 @@ def url(self) -> str:
"linux-x64": AgentKitCliArtifact(
platform_key="linux-x64",
filename="agentkit-linux-x64.tar.gz",
- sha256="a85ec0d8aebac3d7cddcf53b34d06f5024f34dacca6c35c5131c83722e7e3200",
+ sha256="8cc62477c582a7b9c3f460d2558bed45ee4c81c0169eabc228ddb19387268473",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-x64",
executable_name="ak",
),
"linux-arm64": AgentKitCliArtifact(
platform_key="linux-arm64",
filename="agentkit-linux-arm64.tar.gz",
- sha256="64983fd4a092c34653dc054bc2be9d582ab2dbce4cea383a4ca4fcecb992f3fe",
+ sha256="04f6066caa7f19a79a0c9ebd9997976a333135d485ed168efeb83b273743d004",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-linux-arm64",
executable_name="ak",
),
"darwin-x64": AgentKitCliArtifact(
platform_key="darwin-x64",
filename="agentkit-darwin-x64.tar.gz",
- sha256="79e01d2392fc69097c6db8a154426b24536e5c65f6432f8168461aed758a436e",
+ sha256="e602cf1acbf394fdce53f86ee686c75aef88e6bfb77112dcc2c6823eca7e849f",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-x64",
executable_name="ak",
),
"darwin-arm64": AgentKitCliArtifact(
platform_key="darwin-arm64",
filename="agentkit-darwin-arm64.tar.gz",
- sha256="43f65966cf27c3f0b6bd5fdb43fe9a4d8b6669eb365e3a3cc5fbac5c6f9fec02",
+ sha256="4d97c149ef2cddda8b709ee9ddd534d49adf5ad4694f3aa47ccece4037c2bfea",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-darwin-arm64",
executable_name="ak",
),
"windows-x64": AgentKitCliArtifact(
platform_key="windows-x64",
filename="agentkit-windows-x64.zip",
- sha256="47a22120aeb8de137f7761de2db45a58347586d11e8903a29594e6ceb0fc704b",
+ sha256="6d162a528ce3d38e60234723a288a0efd6dfc239ae109833ee424f3eb5d3604f",
archive_root=f"agentkit-{AGENTKIT_CLI_VERSION}-windows-x64",
executable_name="ak.exe",
),
From 3a385690b9d0fda55f89bd3d6ecc7fd45a39d614 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 00:09:56 +0800
Subject: [PATCH 16/46] fix: harden Studio cold starts
---
.github/workflows/publish-studio-release.yaml | 83 +++++----
.../studio_release_server/offline_runtime.py | 168 +++++++++++++++++
.../studio_release_server/publisher.py | 25 ++-
...enerated_agent_backend_codegen_extended.py | 6 +-
tests/cli/test_studio_offline_runtime.py | 169 +++++++++++++++++-
tests/cli/test_studio_release.py | 40 ++++-
tests/test_studio_release_workflow.py | 28 +++
veadk/cli/studio_package.py | 4 +-
veadk/cli/studio_release.py | 1 +
veadk/cli/studio_start.py | 57 ++++++
10 files changed, 531 insertions(+), 50 deletions(-)
create mode 100644 veadk/cli/studio_start.py
diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml
index b1e756297..59c56bfa4 100644
--- a/.github/workflows/publish-studio-release.yaml
+++ b/.github/workflows/publish-studio-release.yaml
@@ -215,11 +215,26 @@ jobs:
VOLCENGINE_SECRET_KEY: smoke
run: |
set -euo pipefail
- package_dir="$RUNNER_TEMP/studio-release-package"
- runtime_venv="$RUNNER_TEMP/studio-release-runtime"
- export package_dir runtime_venv
+ test "$(uname -s)" = "Linux"
+ test "$(uname -m)" = "x86_64"
+ test "$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
+ COLD_START_DEADLINE_SECONDS=90
+ STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024))
+ bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-*.zip)
+ test "${#bundle[@]}" -eq 1
+ test "$(stat -c %s "${bundle[0]}")" -lt "$STUDIO_MAX_BUNDLE_BYTES"
- uv run --frozen --group dev python - <<'PY'
+ smoke_studio() {
+ provider="$1"
+ port="$2"
+ package_dir="$RUNNER_TEMP/studio-release-package-${provider}"
+ runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}"
+ log="$RUNNER_TEMP/studio-${provider}.log"
+ response="$RUNNER_TEMP/studio-${provider}-ui-config.json"
+ export package_dir
+ test ! -e "$package_dir"
+ test ! -e "$runtime_venv"
+ uv run --frozen --group dev python - <<'PY'
import os
from pathlib import Path
@@ -229,37 +244,11 @@ jobs:
bundle = next(output_dir.glob("studio-bundle-*.zip"))
extract_studio_bundle(bundle, Path(os.environ["package_dir"]))
PY
-
- uv venv "$runtime_venv"
- (
- cd "$package_dir"
- uv pip install --python "$runtime_venv/bin/python" -r requirements.txt
- )
-
- (
- cd "$package_dir"
- "$runtime_venv/bin/python" - <<'PY'
- from pathlib import Path
-
- import frontend
- import veadk
- import frontend.server.cronjobs
- import frontend.service.studio_release_server
- import frontend.service.studio_scheduler
-
- runtime_root = Path(veadk.__file__).resolve().parents[1]
- assert Path(frontend.__file__).resolve().is_relative_to(runtime_root)
- webui = Path(veadk.__file__).resolve().parent / "webui" / "index.html"
- assert webui.is_file()
- print(f"Validated installed Studio packages from {runtime_root}")
- PY
- )
-
- smoke_studio() {
- provider="$1"
- port="$2"
- log="$RUNNER_TEMP/studio-${provider}.log"
- response="$RUNNER_TEMP/studio-${provider}-ui-config.json"
+ uv venv --python 3.12 "$runtime_venv"
+ (
+ cd "$package_dir"
+ uv pip install --python "$runtime_venv/bin/python" -r requirements.txt
+ )
(
cd "$package_dir"
PATH="$runtime_venv/bin:$PATH" \
@@ -269,7 +258,8 @@ jobs:
./run.sh
) >"$log" 2>&1 &
pid=$!
- for _ in {1..60}; do
+ started=$SECONDS
+ while (( SECONDS - started < COLD_START_DEADLINE_SECONDS )); do
if ! kill -0 "$pid" 2>/dev/null; then
cat "$log"
return 1
@@ -298,6 +288,27 @@ jobs:
smoke_studio byteplus 18877
smoke_studio volcengine 18878
+ package_dir="$RUNNER_TEMP/studio-release-package-volcengine"
+ runtime_venv="$RUNNER_TEMP/studio-release-runtime-volcengine"
+ (
+ cd "$package_dir"
+ "$runtime_venv/bin/python" - <<'PY'
+ from pathlib import Path
+
+ import frontend
+ import veadk
+ import frontend.server.cronjobs
+ import frontend.service.studio_release_server
+ import frontend.service.studio_scheduler
+
+ runtime_root = Path(veadk.__file__).resolve().parents[1]
+ assert Path(frontend.__file__).resolve().is_relative_to(runtime_root)
+ webui = Path(veadk.__file__).resolve().parent / "webui" / "index.html"
+ assert webui.is_file()
+ print(f"Validated installed Studio packages from {runtime_root}")
+ PY
+ )
+
release-context:
if: >-
github.event_name == 'workflow_dispatch' &&
diff --git a/frontend/service/studio_release_server/offline_runtime.py b/frontend/service/studio_release_server/offline_runtime.py
index 454bb312b..ffce8c33f 100644
--- a/frontend/service/studio_release_server/offline_runtime.py
+++ b/frontend/service/studio_release_server/offline_runtime.py
@@ -16,10 +16,18 @@
from __future__ import annotations
+import base64
+import compileall
+import csv
+import io
import os
+import py_compile
+import re
import shutil
import subprocess
+import sys
import tempfile
+import zipfile
from collections.abc import Mapping, Sequence
from hashlib import sha256
from pathlib import Path
@@ -53,6 +61,32 @@
"PIP_INDEX_URL",
"PIP_EXTRA_INDEX_URL",
)
+_COLD_START_WHEEL_ROOTS = {
+ "veadk-python": ("veadk", "frontend"),
+ "google-adk": ("google/adk",),
+ "agentkit-sdk-python": ("agentkit",),
+ "google-genai": ("google/genai",),
+ "mcp": ("mcp",),
+ "dateparser": ("dateparser",),
+ "fastapi": ("fastapi",),
+ "sqlalchemy": ("sqlalchemy",),
+ "aiohttp": ("aiohttp",),
+ "tos": ("tos",),
+ "trafilatura": ("trafilatura",),
+ "htmldate": ("htmldate",),
+ "authlib": ("authlib",),
+ "volcengine-python-sdk": (
+ "volcenginesdkid",
+ "volcenginesdkvpc",
+ "volcenginesdkarkruntime",
+ "volcenginesdkvefaas",
+ "volcenginesdkark",
+ "volcenginesdkcore",
+ ),
+}
+_COLD_START_SOURCE_EXCLUSIONS = re.compile(
+ r"(?:template[/\\]\{\{|resources[/\\]samples)"
+)
def _lock_check_environment(environment: Mapping[str, str]) -> dict[str, str]:
@@ -71,6 +105,7 @@ def build_studio_offline_runtime(
veadk_wheel: Path,
dependency_sources: Sequence[Path],
environment: Mapping[str, str] | None = None,
+ optimize_cold_start: bool = False,
) -> str:
"""Bundle every locked Linux dependency and return offline requirements."""
lock_source = source_root / "uv.lock"
@@ -192,6 +227,10 @@ def build_studio_offline_runtime(
shutil.move(str(veadk_wheel), staged_veadk)
if not staged_veadk.is_file():
raise ValueError("Studio offline wheelhouse is incomplete.")
+ if optimize_cold_start:
+ if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12):
+ raise ValueError("Studio cold-start optimization requires CPython 3.12.")
+ _enhance_studio_cold_start_wheels(wheelhouse)
_pin_runtime_lock_to_wheelhouse(runtime_lock, wheelhouse, staged_veadk)
for wheel in sorted(wheelhouse.glob("*.whl")):
destination = package_dir / wheel.name
@@ -217,6 +256,135 @@ def build_studio_offline_runtime(
return requirements
+def _enhance_studio_cold_start_wheels(wheelhouse: Path) -> dict[str, int]:
+ """Add deterministic checked-hash CPython 3.12 bytecode to hot wheels."""
+ if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12):
+ raise ValueError("Studio cold-start optimization requires CPython 3.12.")
+ selected: dict[str, tuple[Path, tuple[str, ...]]] = {}
+ for wheel in sorted(wheelhouse.glob("*.whl")):
+ try:
+ name, _version, _build, _tags = parse_wheel_filename(wheel.name)
+ except InvalidWheelFilename as error:
+ raise ValueError("Studio wheelhouse contains an invalid wheel.") from error
+ distribution = canonicalize_name(name)
+ roots = _COLD_START_WHEEL_ROOTS.get(distribution)
+ if roots is None:
+ continue
+ if distribution in selected:
+ raise ValueError(
+ "Studio cold-start wheelhouse contains duplicate distributions."
+ )
+ selected[distribution] = (wheel, roots)
+ if set(selected) != set(_COLD_START_WHEEL_ROOTS):
+ raise ValueError("Studio cold-start wheelhouse is incomplete.")
+
+ enhanced: dict[str, int] = {}
+ for distribution in _COLD_START_WHEEL_ROOTS:
+ wheel, roots = selected[distribution]
+ enhanced[wheel.name] = _augment_checked_hash_wheel(wheel, roots)
+ return enhanced
+
+
+def _augment_checked_hash_wheel(wheel: Path, roots: tuple[str, ...]) -> int:
+ """Repack one wheel with checked-hash pyc files and a valid RECORD."""
+ with tempfile.TemporaryDirectory(prefix="veadk_studio_pyc_") as tmp:
+ extracted = Path(tmp) / "wheel"
+ extracted.mkdir()
+ try:
+ with zipfile.ZipFile(wheel) as source:
+ original = [(info, source.read(info)) for info in source.infolist()]
+ source.extractall(extracted)
+ except (OSError, zipfile.BadZipFile) as error:
+ raise ValueError("Studio cold-start wheel is invalid.") from error
+
+ pycs: list[Path] = []
+ for relative in roots:
+ source_root = extracted / relative
+ if not source_root.is_dir():
+ raise ValueError("Studio cold-start wheel is missing a hot path.")
+ if not compileall.compile_dir(
+ source_root,
+ quiet=1,
+ force=True,
+ rx=_COLD_START_SOURCE_EXCLUSIONS,
+ stripdir=str(extracted),
+ prependdir="/opt/application/site-packages",
+ invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH,
+ ):
+ raise ValueError("Studio cold-start bytecode compilation failed.")
+ pycs.extend(source_root.rglob("*.pyc"))
+ pycs = sorted(set(pycs))
+ if not pycs:
+ raise ValueError("Studio cold-start wheel produced no bytecode.")
+ for pyc in pycs:
+ header = pyc.read_bytes()[:8]
+ if len(header) != 8 or int.from_bytes(header[4:8], "little") != 3:
+ raise ValueError("Studio cold-start bytecode is not checked-hash.")
+
+ record_entries = [
+ (info, content)
+ for info, content in original
+ if info.filename.endswith(".dist-info/RECORD")
+ ]
+ if len(record_entries) != 1:
+ raise ValueError("Studio cold-start wheel RECORD is invalid.")
+ record_info, record_content = record_entries[0]
+ rows = [
+ row
+ for row in csv.reader(io.StringIO(record_content.decode("utf-8")))
+ if row
+ ]
+ pyc_names = {path.relative_to(extracted).as_posix() for path in pycs}
+ rows = [
+ row
+ for row in rows
+ if row[0] != record_info.filename and row[0] not in pyc_names
+ ]
+ for pyc in pycs:
+ content = pyc.read_bytes()
+ digest = base64.urlsafe_b64encode(sha256(content).digest())
+ rows.append(
+ [
+ pyc.relative_to(extracted).as_posix(),
+ "sha256=" + digest.rstrip(b"=").decode("ascii"),
+ str(len(content)),
+ ]
+ )
+ rows.append([record_info.filename, "", ""])
+ record_output = io.StringIO(newline="")
+ csv.writer(record_output, lineterminator="\n").writerows(rows)
+
+ rebuilt = Path(tmp) / wheel.name
+ with zipfile.ZipFile(
+ rebuilt,
+ "w",
+ compression=zipfile.ZIP_DEFLATED,
+ compresslevel=9,
+ ) as destination:
+ for info, content in original:
+ if info.filename in pyc_names:
+ continue
+ destination.writestr(
+ info,
+ (
+ record_output.getvalue().encode("utf-8")
+ if info.filename == record_info.filename
+ else content
+ ),
+ )
+ for pyc in pycs:
+ info = zipfile.ZipInfo(
+ pyc.relative_to(extracted).as_posix(),
+ date_time=(1980, 1, 1, 0, 0, 0),
+ )
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.create_system = 3
+ info.external_attr = 0o100644 << 16
+ destination.writestr(info, pyc.read_bytes())
+ os.replace(rebuilt, wheel)
+ return len(pycs)
+
+
def _write_linux_runtime_lock(exported_lock: Path, destination: Path) -> None:
"""Evaluate uv markers for the VeFaaS Linux/x86_64 Python 3.12 target."""
environment: dict[str, str] = {
diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py
index f64f6acdf..583965c39 100644
--- a/frontend/service/studio_release_server/publisher.py
+++ b/frontend/service/studio_release_server/publisher.py
@@ -988,6 +988,7 @@ def _build_local_requirements(
veadk_wheel=built_wheels[0],
dependency_sources=dependency_sources,
environment=env,
+ optimize_cold_start=True,
)
except ValueError as error:
raise StudioPublisherError(str(error)) from error
@@ -1009,6 +1010,12 @@ def _studio_run_script(*, thin: bool = False) -> str:
else "python3 -m veadk.cli.studio_companion "
f'--archive "$ROOT_DIR/{_AGENTKIT_CLI_ARCHIVE}"\n'
)
+ studio = (
+ "python3 -m veadk.cli.studio_start "
+ '--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" '
+ "--auth-mode frontend "
+ '--host "$HOST" --port "$PORT"\n'
+ )
return (
"#!/bin/bash\n"
"set -ex\n"
@@ -1021,11 +1028,19 @@ def _studio_run_script(*, thin: bool = False) -> str:
"HOST=0.0.0.0\n"
"PORT=${_FAAS_RUNTIME_PORT:-8000}\n"
'export PYTHONPATH="./site-packages${PYTHONPATH:+:$PYTHONPATH}"\n'
- f"{companion}"
- "exec python3 -m veadk.cli.cli studio "
- '--provider "${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}" '
- "--auth-mode frontend "
- '--host "$HOST" --port "$PORT"\n'
+ 'trap \'kill "${COMPANION_PID:-}" "${STUDIO_PID:-}" '
+ "2>/dev/null || true' INT TERM\n"
+ f"{companion.rstrip()} &\n"
+ "COMPANION_PID=$!\n"
+ f"{studio.rstrip()} &\n"
+ "STUDIO_PID=$!\n"
+ 'if ! wait "$COMPANION_PID"; then\n'
+ ' kill "$STUDIO_PID" 2>/dev/null || true\n'
+ ' wait "$STUDIO_PID" 2>/dev/null || true\n'
+ " exit 1\n"
+ "fi\n"
+ "COMPANION_PID=\n"
+ 'wait "$STUDIO_PID"\n'
)
diff --git a/tests/cli/test_generated_agent_backend_codegen_extended.py b/tests/cli/test_generated_agent_backend_codegen_extended.py
index 25749ce50..9ac3471be 100644
--- a/tests/cli/test_generated_agent_backend_codegen_extended.py
+++ b/tests/cli/test_generated_agent_backend_codegen_extended.py
@@ -2394,7 +2394,11 @@ def test_studio_deploy_run_script_allows_generated_agent_debug() -> None:
run_script = _studio_deploy_run_script("site-logo.png")
assert "HOST=0.0.0.0" in run_script
- assert "studio --provider volcengine --auth-mode frontend" in run_script
+ assert (
+ "veadk.cli.studio_start --provider volcengine --auth-mode frontend"
+ in run_script
+ )
+ assert "veadk.cli.cli studio" not in run_script
assert '--site-logo "$ROOT_DIR/site-logo.png"' in run_script
assert "--allow-remote-generated-agent-test-run" not in run_script
diff --git a/tests/cli/test_studio_offline_runtime.py b/tests/cli/test_studio_offline_runtime.py
index 331d3fd7d..393cb34d2 100644
--- a/tests/cli/test_studio_offline_runtime.py
+++ b/tests/cli/test_studio_offline_runtime.py
@@ -12,9 +12,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+import base64
+import csv
+import hashlib
+import importlib.util
+import io
from pathlib import Path
+import re
import shutil
import subprocess
+import sys
from zipfile import ZIP_DEFLATED, ZipFile
import pytest
@@ -22,11 +29,20 @@
from frontend.service.studio_release_server import offline_runtime
-def _write_pure_python_wheel(path: Path, *, name: str, version: str) -> None:
+def _write_pure_python_wheel(
+ path: Path,
+ *,
+ name: str,
+ version: str,
+ modules: dict[str, str] | None = None,
+) -> None:
distribution = name.replace("-", "_")
dist_info = f"{distribution}-{version}.dist-info"
with ZipFile(path, "w", ZIP_DEFLATED) as wheel:
- wheel.writestr(f"{distribution}/__init__.py", "")
+ for filename, source in (
+ modules or {f"{distribution}/__init__.py": ""}
+ ).items():
+ wheel.writestr(filename, source)
wheel.writestr(
f"{dist_info}/METADATA",
f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n",
@@ -41,6 +57,155 @@ def _write_pure_python_wheel(path: Path, *, name: str, version: str) -> None:
wheel.writestr(f"{dist_info}/RECORD", "")
+def _wheel_record(archive: ZipFile) -> dict[str, tuple[str, str]]:
+ records = [
+ name for name in archive.namelist() if name.endswith(".dist-info/RECORD")
+ ]
+ assert len(records) == 1
+ rows = csv.reader(io.StringIO(archive.read(records[0]).decode("utf-8")))
+ return {row[0]: (row[1], row[2]) for row in rows if row}
+
+
+def _pyc_source_name(pyc_name: str) -> str:
+ parent, cache, filename = pyc_name.rsplit("/", 2)
+ assert cache == "__pycache__"
+ module = filename.split(".cpython-312", 1)[0]
+ return f"{parent}/{module}.py"
+
+
+@pytest.mark.skipif(
+ sys.version_info[:2] != (3, 12),
+ reason="release bytecode is intentionally generated by CPython 3.12",
+)
+def test_cold_start_wheel_enhancement_is_checked_hash_and_scoped(
+ tmp_path: Path,
+) -> None:
+ wheelhouse = tmp_path / "wheelhouse"
+ wheelhouse.mkdir()
+ targets = {
+ "veadk_python-1.0-py3-none-any.whl": {
+ "veadk/__init__.py": "VALUE = 'veadk'\n",
+ "frontend/__init__.py": "VALUE = 'frontend'\n",
+ },
+ "google_adk-2.2.0-py3-none-any.whl": {
+ "google/adk/__init__.py": "VALUE = 'adk'\n",
+ },
+ "agentkit_sdk_python-0.8.5-py3-none-any.whl": {
+ "agentkit/__init__.py": "VALUE = 'agentkit'\n",
+ },
+ "google_genai-2.22.0-py3-none-any.whl": {
+ "google/genai/__init__.py": "VALUE = 'genai'\n",
+ },
+ "mcp-1.26.0-py3-none-any.whl": {
+ "mcp/__init__.py": "VALUE = 'mcp'\n",
+ },
+ "dateparser-1.4.2-py3-none-any.whl": {
+ "dateparser/__init__.py": "VALUE = 'dateparser'\n",
+ },
+ "fastapi-0.141.1-py3-none-any.whl": {
+ "fastapi/__init__.py": "VALUE = 'fastapi'\n",
+ },
+ "sqlalchemy-2.0.52-py3-none-any.whl": {
+ "sqlalchemy/__init__.py": "VALUE = 'sqlalchemy'\n",
+ },
+ "aiohttp-3.13.4-py3-none-any.whl": {
+ "aiohttp/__init__.py": "VALUE = 'aiohttp'\n",
+ },
+ "tos-2.8.7-py3-none-any.whl": {
+ "tos/__init__.py": "VALUE = 'tos'\n",
+ },
+ "trafilatura-2.0.0-py3-none-any.whl": {
+ "trafilatura/__init__.py": "VALUE = 'trafilatura'\n",
+ },
+ "htmldate-1.9.3-py3-none-any.whl": {
+ "htmldate/__init__.py": "VALUE = 'htmldate'\n",
+ },
+ "authlib-1.8.0-py3-none-any.whl": {
+ "authlib/__init__.py": "VALUE = 'authlib'\n",
+ },
+ "volcengine_python_sdk-5.0.47-py2.py3-none-any.whl": {
+ "volcenginesdkid/__init__.py": "VALUE = 'id'\n",
+ "volcenginesdkvpc/__init__.py": "VALUE = 'vpc'\n",
+ "volcenginesdkarkruntime/__init__.py": "VALUE = 'runtime'\n",
+ "volcenginesdkvefaas/__init__.py": "VALUE = 'vefaas'\n",
+ "volcenginesdkark/__init__.py": "VALUE = 'ark'\n",
+ "volcenginesdkcore/__init__.py": "VALUE = 'core'\n",
+ },
+ }
+ for filename, modules in targets.items():
+ name = filename.rsplit("-", 4)[0].replace("_", "-")
+ _write_pure_python_wheel(
+ wheelhouse / filename,
+ name=name,
+ version=filename.rsplit("-", 4)[1],
+ modules=modules,
+ )
+ untouched = wheelhouse / "unrelated_package-1.0-py3-none-any.whl"
+ _write_pure_python_wheel(
+ untouched,
+ name="unrelated-package",
+ version="1.0",
+ modules={"unrelated_package/__init__.py": "VALUE = 'unchanged'\n"},
+ )
+ untouched_before = untouched.read_bytes()
+
+ enhanced = offline_runtime._enhance_studio_cold_start_wheels(wheelhouse)
+
+ assert set(enhanced) == set(targets)
+ assert untouched.read_bytes() == untouched_before
+ runtime_lock = tmp_path / "studio-runtime.lock"
+ runtime_lock.write_text(
+ "agentkit-sdk-python==0.8.5\n"
+ "aiohttp==3.13.4\n"
+ "authlib==1.8.0\n"
+ "dateparser==1.4.2\n"
+ "fastapi==0.141.1\n"
+ "google-adk==2.2.0\n"
+ "google-genai==2.22.0\n"
+ "htmldate==1.9.3\n"
+ "mcp==1.26.0\n"
+ "sqlalchemy==2.0.52\n"
+ "tos==2.8.7\n"
+ "trafilatura==2.0.0\n"
+ "unrelated-package==1.0\n"
+ "volcengine-python-sdk==5.0.47\n",
+ encoding="utf-8",
+ )
+ offline_runtime._pin_runtime_lock_to_wheelhouse(
+ runtime_lock,
+ wheelhouse,
+ wheelhouse / "veadk_python-1.0-py3-none-any.whl",
+ )
+ requirements = offline_runtime.build_studio_offline_requirements(
+ wheelhouse,
+ wheel_prefix="./",
+ )
+ runtime_contract = runtime_lock.read_text(encoding="utf-8")
+ for wheel in wheelhouse.glob("*.whl"):
+ digest = offline_runtime._sha256(wheel)
+ assert re.search(
+ rf"^\./{re.escape(wheel.name)} --hash=sha256:{digest}$",
+ requirements,
+ flags=re.MULTILINE,
+ )
+ if not wheel.name.startswith("veadk_python-"):
+ assert f"--hash=sha256:{digest}" in runtime_contract
+ for filename in targets:
+ with ZipFile(wheelhouse / filename) as archive:
+ record = _wheel_record(archive)
+ pycs = [name for name in archive.namelist() if name.endswith(".pyc")]
+ assert pycs
+ for name in pycs:
+ content = archive.read(name)
+ source = archive.read(_pyc_source_name(name))
+ assert content[:4] == importlib.util.MAGIC_NUMBER
+ assert int.from_bytes(content[4:8], "little") == 3
+ assert content[8:16] == importlib.util.source_hash(source)
+ encoded = base64.urlsafe_b64encode(hashlib.sha256(content).digest())
+ expected_hash = "sha256=" + encoded.rstrip(b"=").decode("ascii")
+ assert record[name] == (expected_hash, str(len(content)))
+
+
def _stage_root_only_updater(package_root: Path, destination: Path) -> None:
"""Mirror the oldest released Scheduler staging contract exactly."""
requirements = package_root / "requirements.txt"
diff --git a/tests/cli/test_studio_release.py b/tests/cli/test_studio_release.py
index 8d1a6d741..c9c725027 100644
--- a/tests/cli/test_studio_release.py
+++ b/tests/cli/test_studio_release.py
@@ -729,12 +729,17 @@ def test_publisher_entrypoint_exports_exact_managed_cli_source() -> None:
)
-def test_release_entrypoint_starts_companion_and_studio_concurrently() -> None:
- run_script = studio_run_script(provider="volcengine")
-
+@pytest.mark.parametrize(
+ "run_script",
+ (studio_run_script(provider="volcengine"), publisher._studio_run_script()),
+ ids=("direct-package", "release-server"),
+)
+def test_release_entrypoint_starts_companion_and_studio_concurrently(
+ run_script: str,
+) -> None:
companion_start = run_script.index("python3 -m veadk.cli.studio_companion")
companion_pid = run_script.index("COMPANION_PID=$!", companion_start)
- studio_start = run_script.index("python3 -m veadk.cli.cli studio")
+ studio_start = run_script.index("python3 -m veadk.cli.studio_start")
studio_pid = run_script.index("STUDIO_PID=$!", studio_start)
companion_wait = run_script.index('wait "$COMPANION_PID"', studio_pid)
studio_wait = run_script.index('wait "$STUDIO_PID"', companion_wait)
@@ -746,12 +751,34 @@ def test_release_entrypoint_starts_companion_and_studio_concurrently() -> None:
assert 'kill "$STUDIO_PID"' in run_script
+def test_release_entrypoint_does_not_load_generic_cli() -> None:
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import sys; import veadk.cli.studio_start; "
+ "assert 'veadk.cli.cli' not in sys.modules",
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ assert completed.returncode == 0, completed.stderr
+
+
@pytest.mark.parametrize(
("companion_exit", "expected_returncode", "studio_terminated"),
(("0", 0, False), ("7", 1, True)),
)
+@pytest.mark.parametrize(
+ "entrypoint_script",
+ (studio_run_script(provider="volcengine"), publisher._studio_run_script()),
+ ids=("direct-package", "release-server"),
+)
def test_release_entrypoint_parallel_startup_fails_closed(
tmp_path: Path,
+ entrypoint_script: str,
companion_exit: str,
expected_returncode: int,
studio_terminated: bool,
@@ -762,7 +789,7 @@ def test_release_entrypoint_parallel_startup_fails_closed(
fake_bin.mkdir()
entrypoint = package / "run.sh"
entrypoint.write_text(
- studio_run_script(provider="volcengine"),
+ entrypoint_script,
encoding="utf-8",
)
entrypoint.chmod(0o755)
@@ -889,10 +916,12 @@ def build_requirements(
frontend_assets: Path | None = None,
dependency_wheels: Path | None = None,
provider: str = "volcengine",
+ optimize_cold_start: bool = False,
) -> str:
captured["frontend"] = frontend_assets
captured["wheels"] = dependency_wheels
captured["requirements_provider"] = provider
+ captured["optimize_cold_start"] = optimize_cold_start
package_dir.mkdir(parents=True)
(package_dir / "veadk.whl").write_bytes(b"wheel")
return "./veadk.whl\n"
@@ -938,5 +967,6 @@ def write_package(
"frontend": frontend_assets,
"wheels": dependency_wheels,
"requirements_provider": "byteplus",
+ "optimize_cold_start": True,
"package_provider": None,
}
diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py
index 4d9f5fafc..f547716f7 100644
--- a/tests/test_studio_release_workflow.py
+++ b/tests/test_studio_release_workflow.py
@@ -63,6 +63,34 @@ def _verification_script() -> str:
return step["run"].split("<<'PY'\n", 1)[1].split("\nPY\n", 1)[0]
+def _smoke_script() -> str:
+ workflow_path = (
+ Path(__file__).parents[1]
+ / ".github"
+ / "workflows"
+ / "publish-studio-release.yaml"
+ )
+ workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
+ step = next(
+ step
+ for step in workflow["jobs"]["verify"]["steps"]
+ if step.get("name") == "Simulate customer update and smoke-test Studio"
+ )
+ return step["run"]
+
+
+def test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation() -> None:
+ script = _smoke_script()
+
+ assert 'test "$(uname -s)" = "Linux"' in script
+ assert 'test "$(uname -m)" = "x86_64"' in script
+ assert "COLD_START_DEADLINE_SECONDS=90" in script
+ assert "STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024))" in script
+ assert script.index("smoke_studio byteplus") < script.index("import frontend")
+ assert script.index("smoke_studio volcengine") < script.index("import frontend")
+ assert 'runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}"' in script
+
+
def test_verification_reuses_checked_inputs_and_rebuilds_current_source(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/veadk/cli/studio_package.py b/veadk/cli/studio_package.py
index 26d78a195..5eaf406d1 100644
--- a/veadk/cli/studio_package.py
+++ b/veadk/cli/studio_package.py
@@ -66,7 +66,7 @@ def studio_run_script(
else '"${CLOUD_PROVIDER:-${AGENTKIT_CLOUD_PROVIDER:-volcengine}}"'
)
command = (
- "python3 -m veadk.cli.cli studio "
+ "python3 -m veadk.cli.studio_start "
f"--provider {provider_argument} --auth-mode frontend"
)
if site_logo_filename:
@@ -201,6 +201,7 @@ def build_local_studio_requirements(
dependency_wheels: Path | None = None,
provider: CloudProvider = DEFAULT_CLOUD_PROVIDER,
offline_runtime: bool = True,
+ optimize_cold_start: bool = False,
) -> str:
"""Build a local VeADK wheel and return exact deployment requirements.
@@ -269,6 +270,7 @@ def build_local_studio_requirements(
package_dir,
veadk_wheel=wheels[0],
dependency_sources=dependency_sources,
+ optimize_cold_start=optimize_cold_start,
)
shutil.rmtree(prepared_dependencies)
return requirements
diff --git a/veadk/cli/studio_release.py b/veadk/cli/studio_release.py
index 2890a0b05..d5044ef7d 100644
--- a/veadk/cli/studio_release.py
+++ b/veadk/cli/studio_release.py
@@ -558,6 +558,7 @@ def build_studio_release(
frontend_assets=resolved_frontend_assets,
dependency_wheels=dependency_wheels,
provider="byteplus",
+ optimize_cold_start=True,
)
write_studio_package(
package_dir,
diff --git a/veadk/cli/studio_start.py b/veadk/cli/studio_start.py
new file mode 100644
index 000000000..4e2fa4919
--- /dev/null
+++ b/veadk/cli/studio_start.py
@@ -0,0 +1,57 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Cold-start entrypoint for the Studio server without the general CLI."""
+
+from __future__ import annotations
+
+import os
+import sys
+
+
+def _bootstrap_provider() -> None:
+ provider = (
+ os.environ.get("AGENTKIT_CLOUD_PROVIDER")
+ or os.environ.get("CLOUD_PROVIDER")
+ or "volcengine"
+ )
+ arguments = sys.argv[1:]
+ for index, argument in enumerate(arguments):
+ if argument.startswith("--provider="):
+ provider = argument.partition("=")[2]
+ break
+ if argument == "--provider" and index + 1 < len(arguments):
+ provider = arguments[index + 1]
+ break
+ provider = provider.strip().lower()
+ if provider == "volces":
+ provider = "volcengine"
+ if provider not in {"volcengine", "byteplus"}:
+ return
+ os.environ["AGENTKIT_CLOUD_PROVIDER"] = provider
+ os.environ["CLOUD_PROVIDER"] = provider
+
+
+_bootstrap_provider()
+
+from veadk.cli.cli_frontend import studio # noqa: E402
+
+
+def main() -> None:
+ """Run only the Studio Click command tree."""
+ studio(prog_name="studio")
+
+
+if __name__ == "__main__":
+ main()
From a5a15bffb65d0ccb26bb6fa30053198f6df26e83 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 02:07:01 +0800
Subject: [PATCH 17/46] fix(studio): normalize release bundle permissions
---
.../harness-sidecar-release-gate.yaml | 10 +++++++
.github/workflows/publish-studio-release.yaml | 29 +++++++++++++++++++
.../studio_release_server/publisher.py | 28 ++++++++++++++++--
tests/cli/test_studio_release.py | 28 ++++++++++++++++++
tests/test_studio_release_server.py | 29 +++++++++++++++++++
tests/test_studio_release_workflow.py | 16 ++++++++++
veadk/cli/studio_release.py | 14 ++++++++-
7 files changed, 151 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 7047c5125..4aca26ba2 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -13,12 +13,14 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/cli/test_studio_release.py'
- 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
- 'tests/test_studio_release_server.py'
+ - 'tests/test_studio_release_workflow.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
@@ -28,6 +30,7 @@ on:
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/cli/studio_vpc_network.py'
+ - 'veadk/cli/studio_release.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -42,12 +45,14 @@ on:
- 'tests/cli/test_generated_agent_sidecar_runtime.py'
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
+ - 'tests/cli/test_studio_release.py'
- 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
- 'tests/test_studio_release_server.py'
+ - 'tests/test_studio_release_workflow.py'
- 'veadk/cli/generated_agent_codegen.py'
- 'veadk/cli/generated_agent_mcp.py'
- 'veadk/cli/generated_agent_sidecar_runtime.py'
@@ -57,6 +62,7 @@ on:
- 'veadk/cli/managed_sidecar_source.py'
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/cli/studio_vpc_network.py'
+ - 'veadk/cli/studio_release.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -131,6 +137,10 @@ jobs:
(
python -m pytest -q \
tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \
+ tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \
+ tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \
+ tests/cli/test_studio_release.py::test_release_zip_normalizes_runtime_file_permissions \
+ tests/test_studio_release_workflow.py::test_smoke_gate_executes_bundle_as_low_privilege_user \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml
index 59c56bfa4..47b8bcaf2 100644
--- a/.github/workflows/publish-studio-release.yaml
+++ b/.github/workflows/publish-studio-release.yaml
@@ -224,6 +224,35 @@ jobs:
test "${#bundle[@]}" -eq 1
test "$(stat -c %s "${bundle[0]}")" -lt "$STUDIO_MAX_BUNDLE_BYTES"
+ permission_probe_root="$(mktemp -d)"
+ permission_probe="$permission_probe_root/package"
+ permission_probe_bin="$permission_probe_root/bin"
+ permission_probe_state="$permission_probe_root/state"
+ mkdir "$permission_probe" "$permission_probe_bin" "$permission_probe_state"
+ chmod 755 "$permission_probe_root" "$permission_probe" "$permission_probe_bin"
+ sudo chown nobody "$permission_probe_state"
+ chmod 700 "$permission_probe_state"
+ unzip -q "${bundle[0]}" -d "$permission_probe"
+ test "$(stat -c %a "$permission_probe/run.sh")" = "755"
+ test "$(stat -c %a "$permission_probe/agentkit-linux-x64.tar.gz")" = "644"
+ cat >"$permission_probe_bin/python3" <<'SH'
+ #!/bin/bash
+ set -eu
+ if [[ "$*" == *"veadk.cli.studio_companion"* ]]; then
+ test -r "$VEADK_STUDIO_AGENTKIT_CLI_ARCHIVE"
+ touch "$PERMISSION_PROBE_STATE/companion-started"
+ while [ ! -f "$PERMISSION_PROBE_STATE/studio-started" ]; do sleep 0.01; done
+ exit 0
+ fi
+ touch "$PERMISSION_PROBE_STATE/studio-started"
+ while [ ! -f "$PERMISSION_PROBE_STATE/companion-started" ]; do sleep 0.01; done
+ SH
+ chmod 755 "$permission_probe_bin/python3"
+ sudo -u nobody -H env \
+ PATH="$permission_probe_bin:/usr/bin:/bin" \
+ PERMISSION_PROBE_STATE="$permission_probe_state" \
+ "$permission_probe/run.sh"
+
smoke_studio() {
provider="$1"
port="$2"
diff --git a/frontend/service/studio_release_server/publisher.py b/frontend/service/studio_release_server/publisher.py
index 583965c39..e7c3147ce 100644
--- a/frontend/service/studio_release_server/publisher.py
+++ b/frontend/service/studio_release_server/publisher.py
@@ -24,6 +24,7 @@
import re
import shlex
import shutil
+import stat
import subprocess
import sys
import tempfile
@@ -812,7 +813,8 @@ def ensure_studio_bundle_agentkit_cli(
)
try:
with zipfile.ZipFile(bundle, "a") as archive:
- archive.write(
+ _write_zip_file(
+ archive,
cli_archive,
_AGENTKIT_CLI_ARCHIVE,
compress_type=zipfile.ZIP_STORED,
@@ -1361,11 +1363,33 @@ def _validate_public_wheel_license(path: Path, expected_name: str) -> None:
)
+def _write_zip_file(
+ archive: zipfile.ZipFile,
+ path: Path,
+ relative: Path | str,
+ *,
+ compress_type: int,
+) -> None:
+ relative_path = Path(relative)
+ info = zipfile.ZipInfo.from_file(path, relative_path)
+ info.create_system = 3
+ mode = 0o755 if relative_path.as_posix() == "run.sh" else 0o644
+ info.external_attr = (stat.S_IFREG | mode) << 16
+ info.compress_type = compress_type
+ with path.open("rb") as input_file, archive.open(info, "w") as output_file:
+ shutil.copyfileobj(input_file, output_file)
+
+
def _zip_directory(source: Path, destination: Path) -> None:
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(source.rglob("*")):
if path.is_file():
- archive.write(path, path.relative_to(source))
+ _write_zip_file(
+ archive,
+ path,
+ path.relative_to(source),
+ compress_type=zipfile.ZIP_DEFLATED,
+ )
def build_studio_release(
diff --git a/tests/cli/test_studio_release.py b/tests/cli/test_studio_release.py
index c9c725027..0cce727c8 100644
--- a/tests/cli/test_studio_release.py
+++ b/tests/cli/test_studio_release.py
@@ -35,6 +35,8 @@
validate_studio_bundle_dependencies,
)
+from veadk.cli import studio_release as studio_release_module
+
from veadk.cli.studio_dependencies import (
STUDIO_AGENTKIT_CLI_ARTIFACT,
STUDIO_DEPENDENCY_SOURCES,
@@ -70,6 +72,32 @@ def _manifest(content: bytes = b"bundle") -> StudioReleaseManifest:
)
+def test_release_zip_normalizes_runtime_file_permissions(tmp_path: Path) -> None:
+ package = tmp_path / "package"
+ package.mkdir()
+ run_script = package / "run.sh"
+ run_script.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
+ run_script.chmod(0o600)
+ cli_archive = package / "agentkit-linux-x64.tar.gz"
+ cli_archive.write_bytes(b"cli")
+ cli_archive.chmod(0o600)
+ regular_file = package / "requirements.txt"
+ regular_file.write_text("dependency\n", encoding="utf-8")
+ regular_file.chmod(0o700)
+ bundle = tmp_path / "bundle.zip"
+
+ studio_release_module._zip_directory(package, bundle)
+
+ with zipfile.ZipFile(bundle) as archive:
+ assert archive.getinfo("run.sh").create_system == 3
+ assert archive.getinfo("run.sh").external_attr >> 16 & 0o777 == 0o755
+ assert (
+ archive.getinfo("agentkit-linux-x64.tar.gz").external_attr >> 16 & 0o777
+ == 0o644
+ )
+ assert archive.getinfo("requirements.txt").external_attr >> 16 & 0o777 == 0o644
+
+
class _FakeTosClient:
def __init__(self) -> None:
self.objects: dict[tuple[str, str], bytes] = {}
diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py
index 3fbece708..30ea08079 100644
--- a/tests/test_studio_release_server.py
+++ b/tests/test_studio_release_server.py
@@ -956,6 +956,7 @@ def test_publisher_repairs_missing_agentkit_cli_before_manifest(
dependency_wheels.mkdir()
cli_archive = dependency_wheels / "agentkit-linux-x64.tar.gz"
cli_archive.write_bytes(b"pinned-cli")
+ cli_archive.chmod(0o600)
monkeypatch.setattr(
release_publisher,
"_AGENTKIT_CLI_ARCHIVE_SHA256",
@@ -972,6 +973,34 @@ def test_publisher_repairs_missing_agentkit_cli_before_manifest(
with zipfile.ZipFile(bundle) as archive:
assert archive.read("agentkit-linux-x64.tar.gz") == b"pinned-cli"
+ assert (
+ archive.getinfo("agentkit-linux-x64.tar.gz").external_attr >> 16 & 0o777
+ == 0o644
+ )
+
+
+def test_release_server_zip_normalizes_runtime_file_permissions(
+ tmp_path: Path,
+) -> None:
+ package = tmp_path / "package"
+ package.mkdir()
+ run_script = package / "run.sh"
+ run_script.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8")
+ run_script.chmod(0o600)
+ cli_archive = package / "agentkit-linux-x64.tar.gz"
+ cli_archive.write_bytes(b"cli")
+ cli_archive.chmod(0o600)
+ bundle = tmp_path / "bundle.zip"
+
+ release_publisher._zip_directory(package, bundle)
+
+ with zipfile.ZipFile(bundle) as archive:
+ assert archive.getinfo("run.sh").create_system == 3
+ assert archive.getinfo("run.sh").external_attr >> 16 & 0o777 == 0o755
+ assert (
+ archive.getinfo("agentkit-linux-x64.tar.gz").external_attr >> 16 & 0o777
+ == 0o644
+ )
def test_publisher_rejects_bad_agentkit_cli_in_final_bundle(
diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py
index f547716f7..995b91a91 100644
--- a/tests/test_studio_release_workflow.py
+++ b/tests/test_studio_release_workflow.py
@@ -91,6 +91,22 @@ def test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation() -> None:
assert 'runtime_venv="$RUNNER_TEMP/studio-release-runtime-${provider}"' in script
+def test_smoke_gate_executes_bundle_as_low_privilege_user() -> None:
+ script = _smoke_script()
+
+ assert 'permission_probe_root="$(mktemp -d)"' in script
+ assert 'permission_probe="$permission_probe_root/package"' in script
+ assert 'sudo chown nobody "$permission_probe_state"' in script
+ assert 'unzip -q "${bundle[0]}" -d "$permission_probe"' in script
+ assert 'test "$(stat -c %a "$permission_probe/run.sh")" = "755"' in script
+ assert (
+ 'test "$(stat -c %a "$permission_probe/agentkit-linux-x64.tar.gz")" = "644"'
+ in script
+ )
+ assert "sudo -u nobody -H env" in script
+ assert '"$permission_probe/run.sh"' in script
+
+
def test_verification_reuses_checked_inputs_and_rebuilds_current_source(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/veadk/cli/studio_release.py b/veadk/cli/studio_release.py
index d5044ef7d..86ab4749d 100644
--- a/veadk/cli/studio_release.py
+++ b/veadk/cli/studio_release.py
@@ -21,6 +21,8 @@
import json
import os
import re
+import shutil
+import stat
import tempfile
import zipfile
from dataclasses import asdict, dataclass
@@ -619,7 +621,17 @@ def _zip_directory(source: Path, destination: Path) -> None:
with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for path in sorted(source.rglob("*")):
if path.is_file():
- archive.write(path, path.relative_to(source))
+ relative = path.relative_to(source)
+ info = zipfile.ZipInfo.from_file(path, relative)
+ info.create_system = 3
+ mode = 0o755 if relative.as_posix() == "run.sh" else 0o644
+ info.external_attr = (stat.S_IFREG | mode) << 16
+ info.compress_type = zipfile.ZIP_DEFLATED
+ with (
+ path.open("rb") as input_file,
+ archive.open(info, "w") as output_file,
+ ):
+ shutil.copyfileobj(input_file, output_file)
def _parser() -> argparse.ArgumentParser:
From a0e07a58b68fa7cf69100903321243a5b09c90e3 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 03:13:42 +0800
Subject: [PATCH 18/46] fix(studio): normalize legacy bundle entrypoint
---
.../harness-sidecar-release-gate.yaml | 13 +++++
.github/workflows/publish-studio-release.yaml | 8 +++
tests/cli/test_studio_self_update.py | 1 +
tests/cli/test_studio_update.py | 56 ++++++++++++++++++-
tests/test_cloud.py | 1 +
tests/test_harness_sidecar_release_gate.py | 7 +++
tests/test_studio_release_workflow.py | 7 +++
veadk/cli/cli_frontend.py | 1 +
veadk/cli/studio_self_update.py | 1 +
veadk/integrations/ve_faas/ve_faas.py | 21 ++++++-
10 files changed, 113 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 4aca26ba2..3c6808682 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -14,11 +14,13 @@ on:
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
- 'tests/cli/test_studio_release.py'
+ - 'tests/cli/test_studio_update.py'
- 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
+ - 'tests/test_cloud.py'
- 'tests/test_studio_release_server.py'
- 'tests/test_studio_release_workflow.py'
- 'veadk/cli/generated_agent_codegen.py'
@@ -31,6 +33,8 @@ on:
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/cli/studio_vpc_network.py'
- 'veadk/cli/studio_release.py'
+ - 'veadk/cli/studio_self_update.py'
+ - 'veadk/integrations/ve_faas/ve_faas.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -46,11 +50,13 @@ on:
- 'tests/cli/test_generated_agent_mcp.py'
- 'tests/cli/test_studio_rbac.py'
- 'tests/cli/test_studio_release.py'
+ - 'tests/cli/test_studio_update.py'
- 'tests/cli/test_studio_startup_imports.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
- 'tests/test_harness_sidecar_release_gate.py'
+ - 'tests/test_cloud.py'
- 'tests/test_studio_release_server.py'
- 'tests/test_studio_release_workflow.py'
- 'veadk/cli/generated_agent_codegen.py'
@@ -63,6 +69,8 @@ on:
- 'veadk/cli/legacy_runtime_recovery.py'
- 'veadk/cli/studio_vpc_network.py'
- 'veadk/cli/studio_release.py'
+ - 'veadk/cli/studio_self_update.py'
+ - 'veadk/integrations/ve_faas/ve_faas.py'
- 'veadk/extensions/harness/**'
- 'veadk/integrations/agentkit/app.py'
- 'pyproject.toml'
@@ -141,6 +149,11 @@ jobs:
tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \
tests/cli/test_studio_release.py::test_release_zip_normalizes_runtime_file_permissions \
tests/test_studio_release_workflow.py::test_smoke_gate_executes_bundle_as_low_privilege_user \
+ tests/test_studio_release_workflow.py::test_smoke_gate_survives_platform_entrypoint_mode_normalization \
+ tests/cli/test_studio_update.py::test_update_application_code_bundle_merges_only_explicit_environment \
+ tests/cli/test_studio_update.py::test_studio_update_preserves_custom_function_command \
+ tests/cli/test_studio_update.py::test_native_python_function_uses_mode_independent_bundle_entrypoint \
+ tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml
index 47b8bcaf2..9bcea473a 100644
--- a/.github/workflows/publish-studio-release.yaml
+++ b/.github/workflows/publish-studio-release.yaml
@@ -252,6 +252,14 @@ jobs:
PATH="$permission_probe_bin:/usr/bin:/bin" \
PERMISSION_PROBE_STATE="$permission_probe_state" \
"$permission_probe/run.sh"
+ sudo -u nobody -H rm \
+ "$permission_probe_state/companion-started" \
+ "$permission_probe_state/studio-started"
+ chmod 644 "$permission_probe/run.sh"
+ sudo -u nobody -H env \
+ PATH="$permission_probe_bin:/usr/bin:/bin" \
+ PERMISSION_PROBE_STATE="$permission_probe_state" \
+ bash "$permission_probe/run.sh"
smoke_studio() {
provider="$1"
diff --git a/tests/cli/test_studio_self_update.py b/tests/cli/test_studio_self_update.py
index 30e1d88c6..a9042d140 100644
--- a/tests/cli/test_studio_self_update.py
+++ b/tests/cli/test_studio_self_update.py
@@ -669,6 +669,7 @@ def _deploy_scheduler_for_update(
update = captured["update"]
assert update["application_id"] == "application-id"
assert update["function_id"] == "function-id"
+ assert update["normalize_studio_entrypoint"] is True
assert update["environment_overrides"] == {
"VEADK_STUDIO_RELEASE_VERSION": manifest.version,
"CLOUD_PROVIDER": "byteplus",
diff --git a/tests/cli/test_studio_update.py b/tests/cli/test_studio_update.py
index e8749f324..874f01e2f 100644
--- a/tests/cli/test_studio_update.py
+++ b/tests/cli/test_studio_update.py
@@ -563,6 +563,7 @@ def update_application_code_bundle(self, **kwargs: object) -> str:
assert update["application_id"] == "app-id"
assert update["function_id"] == "function-app-id"
assert update["disable_gateway_cors"] is True
+ assert update["normalize_studio_entrypoint"] is True
assert update["environment_overrides"] == {
"STUDIO_WORKSPACE_TOOL_ID": "studio-workspace-tool",
"AGENTKIT_SANDBOX_REGION": "cn-beijing",
@@ -1662,10 +1663,11 @@ def test_update_application_code_bundle_merges_only_explicit_environment(
service.session_token = ""
cast(Any, service).client = SimpleNamespace(
get_function=lambda _: SimpleNamespace(
+ command="./run.sh",
envs=[
SimpleNamespace(key="EXISTING", value="kept"),
SimpleNamespace(key="VEADK_SITE_TITLE", value="old"),
- ]
+ ],
),
update_function=updated_requests.append,
update_function_resource=resource_requests.append,
@@ -1681,6 +1683,7 @@ def test_update_application_code_bundle_merges_only_explicit_environment(
path=str(tmp_path),
environment_overrides={"VEADK_SITE_TITLE": "新标题"},
disable_gateway_cors=True,
+ normalize_studio_entrypoint=True,
)
assert url == "https://same"
@@ -1690,6 +1693,7 @@ def test_update_application_code_bundle_merges_only_explicit_environment(
"EXISTING": "kept",
"VEADK_SITE_TITLE": "新标题",
}
+ assert request.command == "bash ./run.sh"
resource_request = resource_requests[0]
assert resource_request.function_id == "function-id"
assert resource_request.min_instance == 1
@@ -2229,6 +2233,56 @@ def test_update_application_code_bundle_does_not_read_or_replace_environment(
assert request.request_timeout is None
+def test_studio_update_preserves_custom_function_command(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ updated_requests: list[Any] = []
+ service = object.__new__(VeFaaS)
+ service.session_token = ""
+ cast(Any, service).client = SimpleNamespace(
+ get_function=lambda _: SimpleNamespace(
+ command="python3 custom_server.py",
+ envs=[],
+ ),
+ update_function=updated_requests.append,
+ update_function_resource=lambda _: None,
+ )
+ monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None)
+ monkeypatch.setattr(service, "_release_application", lambda _: "https://same")
+
+ service.update_application_code_bundle(
+ application_id="app-id",
+ function_id="function-id",
+ path=str(tmp_path),
+ environment_overrides={"VEADK_STUDIO_RELEASE_VERSION": "next"},
+ normalize_studio_entrypoint=True,
+ )
+
+ request = updated_requests[0]
+ assert request.command is None
+
+
+def test_native_python_function_uses_mode_independent_bundle_entrypoint(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ requests: list[Any] = []
+ service = object.__new__(VeFaaS)
+ service.project_name = "default"
+ cast(Any, service).client = SimpleNamespace(
+ create_function=lambda request: (
+ requests.append(request)
+ or SimpleNamespace(id="function-id", project_name="default")
+ )
+ )
+ monkeypatch.setattr(service, "_upload_and_mount_code", lambda *_: None)
+
+ service._create_function("studio-fn", str(tmp_path))
+
+ assert requests[0].command == "bash ./run.sh"
+
+
@pytest.fixture(autouse=True)
def _workspace_tool_provisioning(monkeypatch):
monkeypatch.setattr(
diff --git a/tests/test_cloud.py b/tests/test_cloud.py
index 3f4e99a27..ac81186bc 100644
--- a/tests/test_cloud.py
+++ b/tests/test_cloud.py
@@ -229,6 +229,7 @@ def test_vefaas_deploy_updates_existing_application_in_place() -> None:
path="/tmp/studio-bundle",
environment_overrides={"VEADK_STUDIO_DEPLOY_ID": "deploy-id"},
disable_gateway_cors=True,
+ normalize_studio_entrypoint=True,
)
service._create_function.assert_not_called()
service._create_application.assert_not_called()
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 45e3eb254..ea60e1df1 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -65,6 +65,13 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
in (backend_run)
)
assert "test_release_server_agentkit_cli_pin_matches_veadk" in backend_run
+ assert "test_smoke_gate_survives_platform_entrypoint_mode_normalization" in (
+ backend_run
+ )
+ assert "test_update_application_code_bundle_merges_only_explicit_environment" in (
+ backend_run
+ )
+ assert "test_vefaas_deploy_updates_existing_application_in_place" in backend_run
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
backend_run
)
diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py
index 995b91a91..98e2835a9 100644
--- a/tests/test_studio_release_workflow.py
+++ b/tests/test_studio_release_workflow.py
@@ -107,6 +107,13 @@ def test_smoke_gate_executes_bundle_as_low_privilege_user() -> None:
assert '"$permission_probe/run.sh"' in script
+def test_smoke_gate_survives_platform_entrypoint_mode_normalization() -> None:
+ script = _smoke_script()
+
+ assert 'chmod 644 "$permission_probe/run.sh"' in script
+ assert 'bash "$permission_probe/run.sh"' in script
+
+
def test_verification_reuses_checked_inputs_and_rebuilds_current_source(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index f5ad57aa4..567cd8027 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -17445,6 +17445,7 @@ def frontend_update(
path=str(package_dir),
environment_overrides=environment_overrides or None,
disable_gateway_cors=True,
+ normalize_studio_entrypoint=True,
)
except Exception as error:
if _is_retryable_cloud_read_error(error):
diff --git a/veadk/cli/studio_self_update.py b/veadk/cli/studio_self_update.py
index 6cd59074d..7d66e4465 100644
--- a/veadk/cli/studio_self_update.py
+++ b/veadk/cli/studio_self_update.py
@@ -504,6 +504,7 @@ def submit_version(self, version: str | None) -> StudioReleaseManifest:
function_id=self._settings.function_id,
path=str(package_dir),
environment_overrides=environment_overrides,
+ normalize_studio_entrypoint=True,
)
self._submitted_version = manifest.version
self._set_progress("publishing", "已提交,正在等待新 Revision 发布")
diff --git a/veadk/integrations/ve_faas/ve_faas.py b/veadk/integrations/ve_faas/ve_faas.py
index 7acc90084..58951ea3a 100644
--- a/veadk/integrations/ve_faas/ve_faas.py
+++ b/veadk/integrations/ve_faas/ve_faas.py
@@ -366,7 +366,7 @@ def _create_function(self, function_name: str, path: str):
# Create function
res = self.client.create_function(
volcenginesdkvefaas.CreateFunctionRequest(
- command="./run.sh",
+ command="bash ./run.sh",
name=function_name,
description="Created by VeADK (Volcengine Agent Development Kit)",
tags=[TagForCreateFunctionInput(key="provider", value="veadk")],
@@ -589,6 +589,7 @@ def update_application_code_bundle(
path: str,
environment_overrides: dict[str, str] | None = None,
disable_gateway_cors: bool = False,
+ normalize_studio_entrypoint: bool = False,
) -> str:
"""Replace an application's function bundle and release it.
@@ -603,6 +604,10 @@ def update_application_code_bundle(
path: Prepared function bundle directory.
environment_overrides: Environment values to explicitly replace.
disable_gateway_cors: Disable route-wide APIG CORS after release.
+ normalize_studio_entrypoint: Replace only the legacy ``./run.sh``
+ command with ``bash ./run.sh`` so platform archive mode
+ normalization cannot prevent Studio from starting. Custom
+ commands are preserved.
Returns:
The existing Application URL after the new revision is released.
@@ -611,6 +616,7 @@ def update_application_code_bundle(
function_id=function_id,
path=path,
environment_overrides=environment_overrides,
+ normalize_studio_entrypoint=normalize_studio_entrypoint,
)
url = self._release_application(application_id)
self._set_function_min_instance(function_id)
@@ -628,6 +634,7 @@ def submit_application_code_bundle_update(
function_id: str,
path: str,
environment_overrides: dict[str, str] | None = None,
+ normalize_studio_entrypoint: bool = False,
) -> None:
"""Replace a function bundle and submit its Application release.
@@ -641,6 +648,7 @@ def submit_application_code_bundle_update(
function_id=function_id,
path=path,
environment_overrides=environment_overrides,
+ normalize_studio_entrypoint=normalize_studio_entrypoint,
)
self._set_function_min_instance(function_id)
self._start_application_release(application_id)
@@ -652,18 +660,22 @@ def _replace_application_code_bundle(
path: str,
environment_overrides: dict[str, str] | None,
request_timeout: int | None = None,
+ normalize_studio_entrypoint: bool = False,
) -> None:
"""Upload a bundle and update the Function without releasing it."""
request_options: dict[str, Any] = {"id": function_id}
if request_timeout is not None:
request_options["request_timeout"] = request_timeout
- if environment_overrides:
+ function: Any | None = None
+ if environment_overrides or normalize_studio_entrypoint:
function = cast(
Any,
self.client.get_function(
volcenginesdkvefaas.GetFunctionRequest(id=function_id)
),
)
+ if environment_overrides:
+ assert function is not None
environment = {
item.key: item.value for item in (getattr(function, "envs", None) or [])
}
@@ -672,6 +684,10 @@ def _replace_application_code_bundle(
volcenginesdkvefaas.EnvForUpdateFunctionInput(key=key, value=value)
for key, value in environment.items()
]
+ if normalize_studio_entrypoint:
+ assert function is not None
+ if str(getattr(function, "command", "") or "").strip() == "./run.sh":
+ request_options["command"] = "bash ./run.sh"
self._upload_and_mount_code(function_id, path)
self.client.update_function(
@@ -1051,6 +1067,7 @@ def deploy(
if value is not None
},
disable_gateway_cors=disable_gateway_cors,
+ normalize_studio_entrypoint=True,
)
logger.info(
f"VeFaaS application {name} with ID {existing_app_id} updated on {url}."
From 1a9239512e9afd1d6a5249ecdcfae51dc8c33535 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 05:07:03 +0800
Subject: [PATCH 19/46] fix(studio): keep optional tools off cold start path
---
.../harness-sidecar-release-gate.yaml | 1 +
.github/workflows/publish-studio-release.yaml | 2 +-
.../server/studio_tools/branch_compare.py | 14 ++++++----
tests/cli/test_studio_startup_imports.py | 26 +++++++++++++++++++
tests/test_harness_sidecar_release_gate.py | 3 +++
tests/test_studio_release_workflow.py | 3 ++-
6 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 3c6808682..d5032745b 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -148,6 +148,7 @@ jobs:
tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \
tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \
tests/cli/test_studio_release.py::test_release_zip_normalizes_runtime_file_permissions \
+ tests/test_studio_release_workflow.py::test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation \
tests/test_studio_release_workflow.py::test_smoke_gate_executes_bundle_as_low_privilege_user \
tests/test_studio_release_workflow.py::test_smoke_gate_survives_platform_entrypoint_mode_normalization \
tests/cli/test_studio_update.py::test_update_application_code_bundle_merges_only_explicit_environment \
diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml
index 9bcea473a..61af93ce6 100644
--- a/.github/workflows/publish-studio-release.yaml
+++ b/.github/workflows/publish-studio-release.yaml
@@ -218,7 +218,7 @@ jobs:
test "$(uname -s)" = "Linux"
test "$(uname -m)" = "x86_64"
test "$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" = "3.12"
- COLD_START_DEADLINE_SECONDS=90
+ COLD_START_DEADLINE_SECONDS=60
STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024))
bundle=("$RUNNER_TEMP"/studio-release-output/studio-bundle-*.zip)
test "${#bundle[@]}" -eq 1
diff --git a/frontend/server/studio_tools/branch_compare.py b/frontend/server/studio_tools/branch_compare.py
index b04f07565..a5e134f39 100644
--- a/frontend/server/studio_tools/branch_compare.py
+++ b/frontend/server/studio_tools/branch_compare.py
@@ -22,17 +22,12 @@
from typing import Any
from uuid import uuid4
-from google.adk.agents.run_config import RunConfig, StreamingMode
-from google.adk.runners import InMemoryRunner
-from google.genai import types
-
from frontend.server.studio_tools.registry import (
StudioTool,
StudioToolExecutionContext,
StudioToolExecutionError,
StudioToolRegistry,
)
-from veadk import Agent
BRANCH_COMPARE_TOOL_NAME = "branch_compare"
DEFAULT_BRANCH_MODEL = "doubao-seed-2-0-lite-260428"
@@ -151,6 +146,15 @@ async def _generate_with_model(
instruction: str,
report_delta: BranchDeltaReporter,
) -> str:
+ # Building the Studio tool catalog is part of the Function readiness path.
+ # Import the model runtime only when this optional tool is actually invoked;
+ # these modules account for a large share of a cold Python startup.
+ from google.adk.agents.run_config import RunConfig, StreamingMode
+ from google.adk.runners import InMemoryRunner
+ from google.genai import types
+
+ from veadk import Agent
+
model_name = os.getenv("VEADK_STUDIO_BRANCH_MODEL", DEFAULT_BRANCH_MODEL).strip()
agent = Agent(
name=f"studio_branch_{uuid4().hex[:12]}",
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 65b01cf72..ad9a07201 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -55,3 +55,29 @@ def test_studio_startup_modules_do_not_eagerly_load_generated_cloud_models() ->
capture_output=True,
text=True,
)
+
+
+def test_studio_tool_catalog_does_not_eagerly_load_branch_model_runtime() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from frontend.server.studio_tools.registry import build_studio_tool_registry
+
+if "veadk.agent" in sys.modules:
+ raise SystemExit("VeADK Agent loaded before Studio tool catalog construction")
+
+registry = build_studio_tool_registry()
+assert any(item["name"] == "branch_compare" for item in registry.manifests())
+
+if "veadk.agent" in sys.modules:
+ raise SystemExit("branch model runtime loaded during Studio cold start")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index ea60e1df1..78cf1dc7d 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -68,6 +68,9 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
assert "test_smoke_gate_survives_platform_entrypoint_mode_normalization" in (
backend_run
)
+ assert "test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation" in (
+ backend_run
+ )
assert "test_update_application_code_bundle_merges_only_explicit_environment" in (
backend_run
)
diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py
index 98e2835a9..dac1ca2c1 100644
--- a/tests/test_studio_release_workflow.py
+++ b/tests/test_studio_release_workflow.py
@@ -84,7 +84,8 @@ def test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation() -> None:
assert 'test "$(uname -s)" = "Linux"' in script
assert 'test "$(uname -m)" = "x86_64"' in script
- assert "COLD_START_DEADLINE_SECONDS=90" in script
+ assert "COLD_START_DEADLINE_SECONDS=60" in script
+ assert "COLD_START_DEADLINE_SECONDS=90" not in script
assert "STUDIO_MAX_BUNDLE_BYTES=$((256 * 1024 * 1024))" in script
assert script.index("smoke_studio byteplus") < script.index("import frontend")
assert script.index("smoke_studio volcengine") < script.index("import frontend")
From 588d775f9d9a04d615d06f2bcf3a0767982fc519 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 05:38:58 +0800
Subject: [PATCH 20/46] perf(studio): defer optional startup dependencies
---
tests/cli/test_studio_startup_imports.py | 44 ++++++++++++++++++++++++
veadk/cli/frontend_branding.py | 7 ++--
veadk/utils/logger.py | 5 ++-
3 files changed, 50 insertions(+), 6 deletions(-)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index ad9a07201..b66303ee9 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -81,3 +81,47 @@ def test_studio_tool_catalog_does_not_eagerly_load_branch_model_runtime() -> Non
capture_output=True,
text=True,
)
+
+
+def test_frontend_branding_defers_optional_logo_network_stack() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from veadk.cli.frontend_branding import normalize_site_title
+
+assert normalize_site_title(None) == "AgentKit Studio"
+for module in ("filetype", "httpx"):
+ if module in sys.modules:
+ raise SystemExit(f"optional branding dependency loaded at startup: {module}")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_logger_import_does_not_load_general_network_helpers() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from veadk.utils.logger import get_logger
+
+assert get_logger("startup").name == "veadk.startup"
+for module in ("requests", "yaml"):
+ if module in sys.modules:
+ raise SystemExit(f"general utility dependency loaded by logger: {module}")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
diff --git a/veadk/cli/frontend_branding.py b/veadk/cli/frontend_branding.py
index df21e1bfb..15a2261e4 100644
--- a/veadk/cli/frontend_branding.py
+++ b/veadk/cli/frontend_branding.py
@@ -20,9 +20,6 @@
from pathlib import Path
from urllib.parse import urlparse
-import filetype
-import httpx
-
DEFAULT_SITE_TITLE = "AgentKit Studio"
MAX_SITE_TITLE_LENGTH = 16
MAX_SITE_LOGO_BYTES = 5 * 1024 * 1024
@@ -81,6 +78,8 @@ def resolve_site_logo(source: str | None) -> SiteLogo | None:
def _download_logo(url: str) -> bytes:
+ import httpx
+
content = bytearray()
try:
with httpx.stream("GET", url, follow_redirects=True, timeout=10.0) as response:
@@ -95,6 +94,8 @@ def _download_logo(url: str) -> bytes:
def _validate_logo(content: bytes) -> SiteLogo:
+ import filetype
+
kind = filetype.guess(content)
if kind is None or kind.mime not in _SUPPORTED_IMAGE_TYPES:
raise ValueError("Site logo must be PNG, JPEG, GIF, WebP, AVIF, or ICO.")
diff --git a/veadk/utils/logger.py b/veadk/utils/logger.py
index a0674c2fb..c128005c5 100644
--- a/veadk/utils/logger.py
+++ b/veadk/utils/logger.py
@@ -13,12 +13,11 @@
# limitations under the License.
import logging
+import os
import sys
from opentelemetry import trace
-from veadk.utils.misc import getenv
-
_LOGGER_NAME = "veadk"
# ANSI escape codes, matching the colors loguru used by default.
@@ -93,7 +92,7 @@ def setup_logger():
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(_VeADKFormatter(colorize=sys.stdout.isatty()))
root.addHandler(handler)
- root.setLevel(getenv("LOGGING_LEVEL", "DEBUG"))
+ root.setLevel(os.getenv("LOGGING_LEVEL") or "DEBUG")
# keep veadk logs on their own handler, do not bubble up to the root logger
root.propagate = False
From 712ee19b6be00774902cad9a6948a322e1e3de37 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 06:01:13 +0800
Subject: [PATCH 21/46] fix(studio): defer optional cold-start dependencies
---
.../evaluation_automation/model_gateway.py | 4 +-
frontend/server/knowledge/web_import.py | 3 +-
tests/cli/test_studio_startup_imports.py | 48 +++++++++++++++++++
3 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/frontend/server/evaluation_automation/model_gateway.py b/frontend/server/evaluation_automation/model_gateway.py
index cf368f96c..894fdfef4 100644
--- a/frontend/server/evaluation_automation/model_gateway.py
+++ b/frontend/server/evaluation_automation/model_gateway.py
@@ -24,8 +24,6 @@
from pydantic import BaseModel
-from veadk import Agent, Runner
-
from .models import (
AutoEvaluationCase,
AutoEvaluationOutput,
@@ -115,6 +113,8 @@ async def _run(
schema: type[OutputT],
payload: Any,
) -> OutputT:
+ from veadk import Agent, Runner
+
agent = Agent(
name=name,
description="AgentKit Studio evaluation automation.",
diff --git a/frontend/server/knowledge/web_import.py b/frontend/server/knowledge/web_import.py
index bfd0df0ce..8c9a701cc 100644
--- a/frontend/server/knowledge/web_import.py
+++ b/frontend/server/knowledge/web_import.py
@@ -26,7 +26,6 @@
from urllib.parse import SplitResult, urljoin, urlsplit, urlunsplit
import httpx
-from trafilatura import extract, extract_metadata
MAX_REDIRECTS = 3
MAX_HTML_BYTES = 5 * 1024 * 1024
@@ -179,6 +178,8 @@ def _decode_html(html: bytes) -> str:
def _extract_markdown(html: bytes, final_url: str) -> tuple[str, str]:
+ from trafilatura import extract, extract_metadata
+
decoded_html = _decode_html(html)
markdown = extract(
decoded_html,
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index b66303ee9..6a8bd8971 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -125,3 +125,51 @@ def test_logger_import_does_not_load_general_network_helpers() -> None:
capture_output=True,
text=True,
)
+
+
+def test_evaluation_automation_defers_model_runtime_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.evaluation_automation
+
+if "veadk.agent" in sys.modules:
+ raise SystemExit("evaluation model runtime loaded during Studio cold start")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_knowledge_routes_defer_document_extraction_stack() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.knowledge.routes
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "trafilatura"
+ or name.startswith("trafilatura.")
+ or name == "dateparser"
+ or name.startswith("dateparser.")
+)
+if unexpected:
+ raise SystemExit("web document extraction stack loaded during Studio cold start")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
From 8e071ad53a50bef331d1339a514074ee1edeb9d0 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 06:52:37 +0800
Subject: [PATCH 22/46] fix(studio): defer generated agent planner runtime
---
tests/cli/test_studio_startup_imports.py | 20 ++++++++++++++++++++
veadk/cli/generated_agent_planner.py | 3 ++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 6a8bd8971..8fae82bf4 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -147,6 +147,26 @@ def test_evaluation_automation_defers_model_runtime_until_first_call() -> None:
)
+def test_generated_agent_planner_defers_model_runtime_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import veadk.cli.generated_agent_planner
+
+if "veadk.agent" in sys.modules:
+ raise SystemExit("generated Agent planner loaded model runtime at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/veadk/cli/generated_agent_planner.py b/veadk/cli/generated_agent_planner.py
index c0f3ee85b..7e25f94ef 100644
--- a/veadk/cli/generated_agent_planner.py
+++ b/veadk/cli/generated_agent_planner.py
@@ -22,7 +22,6 @@
from pydantic import BaseModel, ConfigDict, Field, model_validator
-from veadk import Agent, Runner
from veadk.cli.generated_agent_codegen import AgentDraft, CustomTool, MemoryConfig
from veadk.consts import DEFAULT_MODEL_AGENT_NAME
from veadk.utils.cloud_provider import cloud_provider_from_env
@@ -186,6 +185,8 @@ def _to_agent_draft(plan: GeneratedAgentPlan) -> AgentDraft:
async def generate_agent_draft(requirement: str) -> dict:
"""Call Ark with a strict output schema and return a Studio AgentDraft."""
+ from veadk import Agent, Runner
+
planner = Agent(
name="studio_agent_draft_planner",
description="Builds a validated recursive Studio Agent configuration.",
From 87f6ee34c94107a77da137963ef84145e7cbcf74 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 07:22:23 +0800
Subject: [PATCH 23/46] fix(studio): defer skill and mcp startup runtimes
---
frontend/server/skills/devenv.py | 183 +++++++++++++++++------
frontend/server/skills/repair.py | 4 +-
tests/cli/test_studio_startup_imports.py | 61 ++++++++
veadk/cli/generated_agent_mcp.py | 22 ++-
4 files changed, 220 insertions(+), 50 deletions(-)
diff --git a/frontend/server/skills/devenv.py b/frontend/server/skills/devenv.py
index 4ec4a4c2e..010a0f5e6 100644
--- a/frontend/server/skills/devenv.py
+++ b/frontend/server/skills/devenv.py
@@ -46,17 +46,6 @@
import requests
from agentkit.auth.errors import NetworkError
-from agentkit.sdk.skills import types as skills_types
-from agentkit.sdk.skills.client import AgentkitSkillsClient
-from agentkit.sdk.tools import types as tools_types
-from agentkit.sdk.tools.client import AgentkitToolsClient
-from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs
-from agentkit.toolkit.cli.sandbox.sandbox_client import (
- SANDBOX_FILE_DOWNLOAD_ROUTE,
- build_bash_exec_url,
- build_exec_url,
- build_file_url,
-)
from fastapi import HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response, StreamingResponse
@@ -67,21 +56,8 @@
is_agentkit_resource_not_found,
sandbox_region_candidates,
)
-from veadk.cli.agentkit_session_metadata import (
- build_create_session_request,
- build_list_sessions_request,
- call_session_client,
- session_display_name,
- session_username,
-)
-from veadk.cli.frontend_skill_creator import (
- _safe_json_response,
- _sandbox_model_config,
- _validated_activities,
-)
from veadk.cli.studio_model_catalog import provider_allows_model
from veadk.cli.studio_sandbox_tools import studio_sandbox_agent_model_name
-from veadk.skills.skill import Skill
from veadk.utils.cloud_provider import cloud_provider_from_env
from veadk.utils.logger import get_logger
@@ -91,6 +67,125 @@
logger = get_logger(__name__)
+
+def AgentkitSkillsClient(*args: Any, **kwargs: Any) -> Any:
+ """Construct the Skill client only when a workbench request needs it."""
+ from agentkit.sdk.skills.client import AgentkitSkillsClient as _Client
+
+ return _Client(*args, **kwargs)
+
+
+def AgentkitToolsClient(*args: Any, **kwargs: Any) -> Any:
+ """Construct the Tool client only when a workbench request needs it."""
+ from agentkit.sdk.tools.client import AgentkitToolsClient as _Client
+
+ return _Client(*args, **kwargs)
+
+
+def _skills_types() -> Any:
+ from agentkit.sdk.skills import types
+
+ return types
+
+
+def _tools_types() -> Any:
+ from agentkit.sdk.tools import types
+
+ return types
+
+
+def build_exec_session_envs(*args: Any, **kwargs: Any) -> Any:
+ from agentkit.toolkit.cli.sandbox.env_config import (
+ build_exec_session_envs as _build_exec_session_envs,
+ )
+
+ return _build_exec_session_envs(*args, **kwargs)
+
+
+def build_bash_exec_url(*args: Any, **kwargs: Any) -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ build_bash_exec_url as _build_bash_exec_url,
+ )
+
+ return _build_bash_exec_url(*args, **kwargs)
+
+
+def build_exec_url(*args: Any, **kwargs: Any) -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ build_exec_url as _build_exec_url,
+ )
+
+ return _build_exec_url(*args, **kwargs)
+
+
+def build_file_url(*args: Any, **kwargs: Any) -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ build_file_url as _build_file_url,
+ )
+
+ return _build_file_url(*args, **kwargs)
+
+
+def _sandbox_file_download_route() -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ SANDBOX_FILE_DOWNLOAD_ROUTE,
+ )
+
+ return SANDBOX_FILE_DOWNLOAD_ROUTE
+
+
+def _safe_json_response(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.frontend_skill_creator import _safe_json_response as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def _sandbox_model_config(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.frontend_skill_creator import _sandbox_model_config as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def _validated_activities(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.frontend_skill_creator import _validated_activities as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def build_create_session_request(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import (
+ build_create_session_request as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def build_list_sessions_request(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import (
+ build_list_sessions_request as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def call_session_client(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import call_session_client as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def session_display_name(*args: Any, **kwargs: Any) -> str:
+ from veadk.cli.agentkit_session_metadata import session_display_name as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def session_username(*args: Any, **kwargs: Any) -> str:
+ from veadk.cli.agentkit_session_metadata import session_username as _impl
+
+ return _impl(*args, **kwargs)
+
+
_TOOL_ID_ENV = "SANDBOX_DEV"
_DEVENV_IMAGE_ENV = "VEADK_DEVENV_IMAGE"
_EXPECTED_TOOL_TYPE = "DevEnv"
@@ -2235,7 +2330,7 @@ def _read_remote_artifact(
for attempt in range(1, _ARTIFACT_READ_ATTEMPTS + 1):
try:
response = requests.get(
- build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE),
+ build_file_url(endpoint, _sandbox_file_download_route()),
params={"path": path, "change_policy": "abort"},
timeout=(10, 120),
)
@@ -2851,7 +2946,7 @@ def report(phase: str, message: str) -> None:
for item in SkillVersionRepository._versions(client, effective_skill_id)
}
client.update_skill(
- skills_types.UpdateSkillRequest(
+ _skills_types().UpdateSkillRequest(
Id=effective_skill_id,
Name=archive.name,
Description=archive.description,
@@ -2862,7 +2957,7 @@ def report(phase: str, message: str) -> None:
)
else:
created = client.create_skill(
- skills_types.CreateSkillRequest(
+ _skills_types().CreateSkillRequest(
Name=archive.name,
Description=archive.description,
TosUrl=tos_url,
@@ -2892,10 +2987,10 @@ def report(phase: str, message: str) -> None:
if body.skill_space_ids:
report("publishing", "正在发布到技能空间")
client.publish_skill_to_skill_space(
- skills_types.PublishSkillToSkillSpaceRequest(
+ _skills_types().PublishSkillToSkillSpaceRequest(
SkillSpaces=body.skill_space_ids,
Skills=[
- skills_types.SkillBasicInfo(
+ _skills_types().SkillBasicInfo(
SkillId=effective_skill_id, Version=version
)
],
@@ -3002,7 +3097,7 @@ def _resolve_center_source(
)
client = self._skills_client_factory(source.region)
try:
- version_request = skills_types.GetSkillVersionRequest(
+ version_request = _skills_types().GetSkillVersionRequest(
Id=source.skill_id,
SkillVersion=source.version,
)
@@ -3035,7 +3130,7 @@ def _resolve_center_source(
status_code=404,
) from version_error
try:
- info_request = skills_types.GetSkillInfoRequest(
+ info_request = _skills_types().GetSkillInfoRequest(
SkillName=source.skill_name,
SkillSpaceName=source.skill_space_name,
SkillSpaceId=source.skill_space_id,
@@ -3086,6 +3181,8 @@ def _archive_from_skill_response(
if bucket and tos_path:
from veadk.skills.materializer import _download_legacy_skill_space_skill
+ from veadk.skills.skill import Skill
+
remote = Skill(
name=str(
getattr(response, "skill_name", "")
@@ -3170,7 +3267,7 @@ def _tool_id(self, *, required: bool = True) -> str:
return value
def _get_tool(self, tool_id: str) -> Any:
- request = tools_types.GetToolRequest(ToolId=tool_id)
+ request = _tools_types().GetToolRequest(ToolId=tool_id)
for index, region in enumerate(sandbox_region_candidates(self._region)):
try:
client = self._tools_client_factory(region)
@@ -3198,12 +3295,12 @@ def _find_session(self, tool_id: str, job_id: str) -> dict[str, str]:
try:
client = self._tools_client_factory(region)
for _page in range(100):
- list_request = tools_types.ListSessionsRequest(
+ list_request = _tools_types().ListSessionsRequest(
ToolId=tool_id,
MaxResults=100,
NextToken=next_token,
Filters=[
- tools_types.FiltersItemForListSessions(
+ _tools_types().FiltersItemForListSessions(
Name="UserSessionId", Values=[job_id]
)
],
@@ -3400,7 +3497,7 @@ def _complete_remote_command_output(
def read_output() -> bytes:
response = requests.get(
- build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE),
+ build_file_url(endpoint, _sandbox_file_download_route()),
params={"path": path, "change_policy": "abort"},
timeout=(10, 30),
)
@@ -3826,7 +3923,7 @@ def _reconcile_recovery_snapshot(
next_token: str | None = None
seen_tokens: set[str] = set()
for _page in range(100):
- request = tools_types.ListSessionSnapshotsRequest(
+ request = _tools_types().ListSessionSnapshotsRequest(
ToolId=tool_id,
SessionId=session_id,
UserSessionId=job_id,
@@ -3906,7 +4003,7 @@ def _read_recovery_snapshot_status(
job_id: str,
) -> str | None:
"""Read and validate the authoritative state of one checkpoint."""
- request = tools_types.GetSessionSnapshotRequest(
+ request = _tools_types().GetSessionSnapshotRequest(
ToolId=tool_id,
SnapshotId=snapshot_id,
)
@@ -4011,7 +4108,7 @@ def _resume_latest_snapshot(
)
return existing_session
for _page in range(100):
- list_request = tools_types.ListSessionSnapshotsRequest(
+ list_request = _tools_types().ListSessionSnapshotsRequest(
ToolId=tool_id,
UserSessionId=job_id,
MaxResults=100,
@@ -4054,7 +4151,7 @@ def _resume_latest_snapshot(
)
resume_requested = True
resumed = client.resume_session_from_snapshot(
- tools_types.ResumeSessionFromSnapshotRequest(
+ _tools_types().ResumeSessionFromSnapshotRequest(
ToolId=tool_id,
SnapshotId=snapshot.snapshot_id,
CreateNewInstance=True,
@@ -4098,12 +4195,12 @@ def _reconcile_resumed_session(
next_token: str | None = None
seen_tokens: set[str] = set()
for _page in range(100):
- request = tools_types.ListSessionsRequest(
+ request = _tools_types().ListSessionsRequest(
ToolId=tool_id,
MaxResults=100,
NextToken=next_token,
Filters=[
- tools_types.FiltersItemForListSessions(
+ _tools_types().FiltersItemForListSessions(
Name="UserSessionId",
Values=[job_id],
)
@@ -4161,7 +4258,7 @@ def _wait_for_resumed_session(
) -> dict[str, str]:
deadline = time.monotonic() + 60
while True:
- get_request = tools_types.GetSessionRequest(
+ get_request = _tools_types().GetSessionRequest(
ToolId=tool_id,
SessionId=session_id,
)
@@ -4299,7 +4396,7 @@ def _delete_session(self, client: Any, tool_id: str, session_id: str) -> None:
def delete_once() -> None:
try:
client.delete_session(
- tools_types.DeleteSessionRequest(
+ _tools_types().DeleteSessionRequest(
ToolId=tool_id,
SessionId=session_id,
)
diff --git a/frontend/server/skills/repair.py b/frontend/server/skills/repair.py
index 9cc5fd8fb..09faa1c81 100644
--- a/frontend/server/skills/repair.py
+++ b/frontend/server/skills/repair.py
@@ -18,8 +18,6 @@
import inspect
-from veadk.cli.frontend_skill_creator import _runner_source
-
def repair_generated_skill(root):
"""Repair safe frontmatter and root-name mistakes without changing behavior."""
@@ -138,6 +136,8 @@ def safe_plain_description(value):
def skill_workbench_runner_source() -> str:
"""Inject deterministic repair into the workbench-only DevEnv runner."""
+ from veadk.cli.frontend_skill_creator import _runner_source
+
source = _runner_source()
definition_anchor = "def metadata(skill_md):"
validation_anchor = """ skill_md = skill_md_path.read_text(encoding=\"utf-8\")
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 8fae82bf4..d736c54ee 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -167,6 +167,67 @@ def test_generated_agent_planner_defers_model_runtime_until_first_call() -> None
)
+def test_generated_agent_mcp_defers_protocol_runtime_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import veadk.cli.generated_agent_mcp
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "mcp"
+ or name.startswith("mcp.")
+ or name == "google.adk.tools.mcp_tool"
+ or name.startswith("google.adk.tools.mcp_tool.")
+)
+if unexpected:
+ raise SystemExit("generated Agent MCP protocol runtime loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_skill_workbench_defers_agentkit_runtime_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.skills.devenv
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name in {
+ "agentkit.sdk.skills.client",
+ "agentkit.sdk.skills.types",
+ "agentkit.sdk.tools.client",
+ "agentkit.sdk.tools.types",
+ "agentkit.toolkit.cli.sandbox.env_config",
+ "agentkit.toolkit.cli.sandbox.sandbox_client",
+ "veadk.skills.skill",
+ }
+)
+if unexpected:
+ raise SystemExit("Skill workbench AgentKit runtime loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/veadk/cli/generated_agent_mcp.py b/veadk/cli/generated_agent_mcp.py
index 4e76ea693..e427f7964 100644
--- a/veadk/cli/generated_agent_mcp.py
+++ b/veadk/cli/generated_agent_mcp.py
@@ -17,17 +17,29 @@
from __future__ import annotations
from contextlib import suppress
-from typing import Iterator
+from typing import Any, Iterator
import httpx
-from google.adk.tools.mcp_tool.mcp_session_manager import (
- StreamableHTTPConnectionParams,
-)
-from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset
from veadk.cli.generated_agent_codegen import AgentDraft, McpTool, prepare_mcp_auth
+def StreamableHTTPConnectionParams(*args: Any, **kwargs: Any) -> Any:
+ """Construct ADK MCP connection params only for an actual debug probe."""
+ from google.adk.tools.mcp_tool.mcp_session_manager import (
+ StreamableHTTPConnectionParams as _StreamableHTTPConnectionParams,
+ )
+
+ return _StreamableHTTPConnectionParams(*args, **kwargs)
+
+
+def MCPToolset(*args: Any, **kwargs: Any) -> Any:
+ """Construct an ADK MCP toolset without charging Studio cold start."""
+ from google.adk.tools.mcp_tool.mcp_toolset import MCPToolset as _MCPToolset
+
+ return _MCPToolset(*args, **kwargs)
+
+
class McpDebugConnectionError(ValueError):
"""Raised when a configured MCP server cannot expose tools for debugging."""
From 0ed9a112a3594ad6571369e8cdeb515eada713fd Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 07:47:27 +0800
Subject: [PATCH 24/46] perf(studio): defer migration sandbox runtime
---
frontend/server/migration/gateway.py | 96 +++++++++++++++++++-----
tests/cli/test_studio_startup_imports.py | 31 ++++++++
2 files changed, 107 insertions(+), 20 deletions(-)
diff --git a/frontend/server/migration/gateway.py b/frontend/server/migration/gateway.py
index ef3d66445..f2dc25aa8 100644
--- a/frontend/server/migration/gateway.py
+++ b/frontend/server/migration/gateway.py
@@ -24,25 +24,11 @@
from typing import Any, Protocol
import requests
-from agentkit.sdk.tools import types as tools_types
-from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs
-from agentkit.toolkit.cli.sandbox.sandbox_client import (
- SANDBOX_FILE_DOWNLOAD_ROUTE,
- build_bash_exec_url,
- build_file_url,
-)
from veadk.cli.agentkit_sandbox_region import (
is_agentkit_resource_not_found,
sandbox_region_candidates,
)
-from veadk.cli.agentkit_session_metadata import (
- build_create_session_request,
- build_list_sessions_request,
- call_session_client,
- session_username,
-)
-from veadk.cli.frontend_skill_creator import _sandbox_model_config
from veadk.utils.cloud_provider import cloud_provider_from_env
_TOOL_ID_ENV = "SANDBOX_DEV"
@@ -80,6 +66,76 @@
logger = logging.getLogger(__name__)
+def _tools_types() -> Any:
+ from agentkit.sdk.tools import types
+
+ return types
+
+
+def build_exec_session_envs(*args: Any, **kwargs: Any) -> Any:
+ from agentkit.toolkit.cli.sandbox.env_config import (
+ build_exec_session_envs as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def build_bash_exec_url(*args: Any, **kwargs: Any) -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ build_bash_exec_url as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def build_file_url(*args: Any, **kwargs: Any) -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import build_file_url as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def _sandbox_file_download_route() -> str:
+ from agentkit.toolkit.cli.sandbox.sandbox_client import (
+ SANDBOX_FILE_DOWNLOAD_ROUTE,
+ )
+
+ return SANDBOX_FILE_DOWNLOAD_ROUTE
+
+
+def build_create_session_request(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import (
+ build_create_session_request as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def build_list_sessions_request(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import (
+ build_list_sessions_request as _impl,
+ )
+
+ return _impl(*args, **kwargs)
+
+
+def call_session_client(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.agentkit_session_metadata import call_session_client as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def session_username(*args: Any, **kwargs: Any) -> str:
+ from veadk.cli.agentkit_session_metadata import session_username as _impl
+
+ return _impl(*args, **kwargs)
+
+
+def _sandbox_model_config(*args: Any, **kwargs: Any) -> Any:
+ from veadk.cli.frontend_skill_creator import _sandbox_model_config as _impl
+
+ return _impl(*args, **kwargs)
+
+
class MigrationGatewayError(RuntimeError):
"""A remote dependency failure with explicit retry semantics."""
@@ -221,7 +277,7 @@ def _get_tool(self) -> tuple[Any, str]:
"管理员未配置 Dev Sandbox。",
status_code=503,
)
- request = tools_types.GetToolRequest(ToolId=self._tool_id)
+ request = _tools_types().GetToolRequest(ToolId=self._tool_id)
for index, region in enumerate(self._regions):
try:
return self._client(region).get_tool(request), region
@@ -337,12 +393,12 @@ def _list_region(
username=owner_id,
)
else:
- request = tools_types.ListSessionsRequest(
+ request = _tools_types().ListSessionsRequest(
ToolId=self._tool_id,
MaxResults=100,
NextToken=next_token,
Filters=[
- tools_types.FiltersItemForListSessions(
+ _tools_types().FiltersItemForListSessions(
Name="UserSessionId",
Values=[task_id],
)
@@ -488,7 +544,7 @@ def _wait_for_ready_session(
response = call_session_client(
self._client(region),
"get_session",
- tools_types.GetSessionRequest(
+ _tools_types().GetSessionRequest(
ToolId=self._tool_id,
SessionId=initial.session_id,
),
@@ -664,7 +720,7 @@ def get_file(
endpoint = self._require_endpoint(session)
try:
response = requests.get(
- build_file_url(endpoint, SANDBOX_FILE_DOWNLOAD_ROUTE),
+ build_file_url(endpoint, _sandbox_file_download_route()),
params={"path": path, "change_policy": "abort"},
timeout=_READ_TIMEOUT,
stream=True,
@@ -858,7 +914,7 @@ def background_launch_confirmed(
def delete_session(self, session: MigrationSandboxSession) -> None:
try:
self._client(session.region).delete_session(
- tools_types.DeleteSessionRequest(
+ _tools_types().DeleteSessionRequest(
ToolId=session.tool_id,
SessionId=session.session_id,
)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index d736c54ee..27324450b 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -228,6 +228,37 @@ def test_skill_workbench_defers_agentkit_runtime_until_first_call() -> None:
)
+def test_migration_gateway_defers_agentkit_runtime_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.migration.gateway
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name in {
+ "agentkit.sdk.tools.types",
+ "agentkit.toolkit.cli.sandbox.env_config",
+ "agentkit.toolkit.cli.sandbox.sandbox_client",
+ "veadk.cli.agentkit_session_metadata",
+ "veadk.cli.frontend_skill_creator",
+ }
+)
+if unexpected:
+ raise SystemExit("Migration Gateway AgentKit runtime loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From 56d6c4da2cc40356215e6ea116f2c37cba0fa3b4 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 09:27:09 +0800
Subject: [PATCH 25/46] perf(studio): defer sandbox session runtimes
---
frontend/server/studio_tools/codex_sandbox.py | 97 +++++++++++++------
frontend/server/studio_tools/sandbox_shell.py | 20 +++-
tests/cli/test_studio_startup_imports.py | 69 +++++++++++++
tests/test_harness_sidecar_release_gate.py | 1 +
4 files changed, 155 insertions(+), 32 deletions(-)
diff --git a/frontend/server/studio_tools/codex_sandbox.py b/frontend/server/studio_tools/codex_sandbox.py
index 26bb9f668..f4de38a94 100644
--- a/frontend/server/studio_tools/codex_sandbox.py
+++ b/frontend/server/studio_tools/codex_sandbox.py
@@ -22,7 +22,7 @@
import re
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
from dataclasses import dataclass
-from typing import Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol
import httpx
@@ -42,25 +42,16 @@
SandboxResolutionError,
SandboxTargetResolver,
)
-from veadk.cli.codex_app_server import (
- CodexAppServerError,
- CodexAppServerEvent,
- CodexAppServerSession,
- CodexAppServerTransportError,
- CodexAppServerTurnTimeoutError,
- CodexPermissionSettings,
- sandbox_service_url,
-)
from veadk.utils.logger import get_logger
+if TYPE_CHECKING:
+ from veadk.cli.codex_app_server import (
+ CodexAppServerEvent,
+ CodexPermissionSettings,
+ )
+
logger = get_logger(__name__)
-_CODEX_PERMISSIONS = CodexPermissionSettings(
- approval_policy="never",
- approvals_reviewer="auto_review",
- sandbox_mode="danger-full-access",
- network_access=True,
-)
_CONNECT_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0)
_READINESS_TIMEOUT_SECONDS = 5.0
_CODEX_TOOL_TIMEOUT_MS = 30 * 60 * 1_000
@@ -84,6 +75,51 @@
_URL_QUERY_RE = re.compile(r"https?://[^\s?]+\?[^\s]+")
+def CodexAppServerSession(endpoint: str) -> CodexSandboxConnection:
+ """Construct the heavy app-server transport on the first delegated task."""
+
+ from veadk.cli.codex_app_server import CodexAppServerSession as _Session
+
+ return _Session(endpoint)
+
+
+def sandbox_service_url(endpoint: str, path: str) -> str:
+ """Resolve the private app-server URL only when readiness is probed."""
+
+ from veadk.cli.codex_app_server import sandbox_service_url as _service_url
+
+ return _service_url(endpoint, path)
+
+
+def _codex_permissions() -> CodexPermissionSettings:
+ from veadk.cli.codex_app_server import CodexPermissionSettings
+
+ return CodexPermissionSettings(
+ approval_policy="never",
+ approvals_reviewer="auto_review",
+ sandbox_mode="danger-full-access",
+ network_access=True,
+ )
+
+
+def _codex_error_type() -> type[Exception]:
+ from veadk.cli.codex_app_server import CodexAppServerError
+
+ return CodexAppServerError
+
+
+def _codex_transport_error_type() -> type[Exception]:
+ from veadk.cli.codex_app_server import CodexAppServerTransportError
+
+ return CodexAppServerTransportError
+
+
+def _codex_turn_timeout_error_type() -> type[Exception]:
+ from veadk.cli.codex_app_server import CodexAppServerTurnTimeoutError
+
+ return CodexAppServerTurnTimeoutError
+
+
class CodexSandboxConnection(Protocol):
"""The narrow app-server surface required by the Studio adapter."""
@@ -119,19 +155,20 @@ def __init__(
self,
target_resolver: SandboxTargetResolver,
*,
- connection_factory: Callable[[str], CodexSandboxConnection] = (
- CodexAppServerSession
- ),
+ connection_factory: Callable[[str], CodexSandboxConnection] | None = None,
readiness_probe: Callable[[SandboxExecutionTarget], Awaitable[bool]]
| None = None,
sleep: Callable[[float], Awaitable[Any]] = asyncio.sleep,
) -> None:
self._target_resolver = target_resolver
- self._connection_factory = connection_factory
+ default_connection_factory = connection_factory is None
+ self._connection_factory = (
+ connection_factory
+ if connection_factory is not None
+ else CodexAppServerSession
+ )
self._readiness_probe = readiness_probe or (
- _codex_app_server_ready
- if connection_factory is CodexAppServerSession
- else _always_ready
+ _codex_app_server_ready if default_connection_factory else _always_ready
)
self._sleep = sleep
self._connections: dict[
@@ -177,7 +214,7 @@ async def execute(
try:
entry = await self._ready_connection(target, mount, context)
- except CodexAppServerTransportError as error:
+ except _codex_transport_error_type() as error:
failure = await _report_failure(context, mount, "Codex Sandbox 连接失败")
_append_activity_event(activity_events, failure)
raise StudioToolRuntimeError(
@@ -210,7 +247,7 @@ async def execute(
try:
async for event in entry.connection.stream_turn(
prompt,
- permissions=_CODEX_PERMISSIONS,
+ permissions=_codex_permissions(),
):
if event.kind == "text":
if event.text:
@@ -231,7 +268,7 @@ async def execute(
_progress_event(event, fallback_id=text_event_id),
),
)
- except CodexAppServerTurnTimeoutError as error:
+ except _codex_turn_timeout_error_type() as error:
failure = await _report_failure(
context, mount, "Codex Sandbox 执行超时"
)
@@ -247,7 +284,7 @@ async def execute(
ok=False,
),
) from error
- except CodexAppServerTransportError as error:
+ except _codex_transport_error_type() as error:
failure = await _report_failure(
context, mount, "Codex Sandbox 连接中断"
)
@@ -264,7 +301,7 @@ async def execute(
ok=False,
),
) from error
- except CodexAppServerError as error:
+ except _codex_error_type() as error:
failure = await _report_failure(
context, mount, "Codex Sandbox 执行失败"
)
@@ -325,7 +362,7 @@ async def _ready_connection(
entry: _CodexConnectionEntry | None = None
try:
if not await self._readiness_probe(target):
- raise CodexAppServerTransportError(
+ raise _codex_transport_error_type()(
"Codex app-server readiness check did not pass."
)
entry = await self._connection(target, mount, context)
@@ -335,7 +372,7 @@ async def _ready_connection(
async with entry.lock:
await entry.connection.connect()
return entry
- except CodexAppServerTransportError as error:
+ except _codex_transport_error_type() as error:
logger.warning(
"Codex Sandbox app-server connection failed "
"environment_id_prefix=%s attempt=%d/%d error_type=%s",
diff --git a/frontend/server/studio_tools/sandbox_shell.py b/frontend/server/studio_tools/sandbox_shell.py
index d765e2073..06a59f42a 100644
--- a/frontend/server/studio_tools/sandbox_shell.py
+++ b/frontend/server/studio_tools/sandbox_shell.py
@@ -37,8 +37,6 @@
StudioToolExecutionError,
StudioToolRegistry,
)
-from veadk.cli.agentkit_session_metadata import build_create_session_request
-from veadk.cli.codex_app_server import sandbox_service_url
_READY_STATUS = "ready"
_FAILED_TOOL_STATUSES = frozenset(
@@ -52,6 +50,24 @@
_ALLOWED_TARGET_HEADERS = frozenset({"authorization", "x-api-key"})
+def build_create_session_request(*args: Any, **kwargs: Any) -> Any:
+ """Build Session metadata only when a mounted Sandbox needs a Session."""
+
+ from veadk.cli.agentkit_session_metadata import (
+ build_create_session_request as _build_request,
+ )
+
+ return _build_request(*args, **kwargs)
+
+
+def sandbox_service_url(endpoint: str, path: str) -> str:
+ """Resolve the Sandbox data-plane URL only for an actual tool call."""
+
+ from veadk.cli.codex_app_server import sandbox_service_url as _service_url
+
+ return _service_url(endpoint, path)
+
+
class SandboxTargetResolver(Protocol):
async def resolve(
self,
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 27324450b..d9b86bef4 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -18,6 +18,8 @@
import sys
from pathlib import Path
+import pytest
+
def test_studio_startup_modules_do_not_eagerly_load_generated_cloud_models() -> None:
root = Path(__file__).resolve().parents[2]
@@ -83,6 +85,73 @@ def test_studio_tool_catalog_does_not_eagerly_load_branch_model_runtime() -> Non
)
+def test_studio_tools_defer_sandbox_runtime_until_first_sandbox_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from frontend.server.studio_tools.registry import build_studio_tool_registry
+
+registry = build_studio_tool_registry()
+assert any(item["name"] == "branch_compare" for item in registry.manifests())
+
+unexpected = sorted(
+ name
+ for name in (
+ "veadk.cli.agentkit_session_metadata",
+ "veadk.cli.codex_app_server",
+ )
+ if name in sys.modules
+)
+if unexpected:
+ raise SystemExit("Sandbox session runtime loaded during Studio cold start")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_codex_sandbox_default_connection_factory_is_lazy(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import frontend.server.studio_tools.codex_sandbox as codex_sandbox
+ from frontend.server.environments.session_mounts import SessionEnvironmentMount
+ from frontend.server.studio_tools.registry import StudioToolExecutionContext
+ from frontend.server.studio_tools.sandbox_shell import SandboxExecutionTarget
+
+ created_endpoints: list[str] = []
+ connection = object()
+
+ class Targets:
+ async def resolve(
+ self,
+ mount: SessionEnvironmentMount,
+ context: StudioToolExecutionContext,
+ ) -> SandboxExecutionTarget:
+ del mount, context
+ raise AssertionError("target resolution is not a startup operation")
+
+ def connection_factory(endpoint: str) -> object:
+ created_endpoints.append(endpoint)
+ return connection
+
+ monkeypatch.setattr(
+ codex_sandbox,
+ "CodexAppServerSession",
+ connection_factory,
+ )
+ delegate = codex_sandbox.CodexSandboxDelegate(Targets())
+
+ assert created_endpoints == []
+ assert delegate._connection_factory("https://sandbox.example") is connection
+ assert created_endpoints == ["https://sandbox.example"]
+
+
def test_frontend_branding_defers_optional_logo_network_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 78cf1dc7d..3f33913b5 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -51,6 +51,7 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
frontend_run = _run_script(frontend)
aggregate_run = _run_script(aggregate)
assert "test_studio_sidecar.py" in backend_run
+ assert "tests/cli/test_studio_startup_imports.py" in backend_run
assert "test_legacy_runtime_recovery.py" in backend_run
assert "zero_mcp_servers_json" in backend_run
assert "test_zero_structured_mcp_is_valid" in backend_run
From 79dda726b914ac9bb838bc01492c8389a1f46a2e Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 10:08:04 +0800
Subject: [PATCH 26/46] perf(studio): defer heavy builtin tool clients
---
.../harness-sidecar-release-gate.yaml | 3 +
.../studio_tools/veadk_builtin_tools.py | 154 ++++++++++++++++--
tests/cli/test_studio_startup_imports.py | 31 ++++
.../test_deferred_veadk_builtin_tools.py | 96 +++++++++++
tests/test_harness_sidecar_release_gate.py | 1 +
5 files changed, 275 insertions(+), 10 deletions(-)
create mode 100644 tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index d5032745b..896a335fc 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -16,6 +16,7 @@ on:
- 'tests/cli/test_studio_release.py'
- 'tests/cli/test_studio_update.py'
- 'tests/cli/test_studio_startup_imports.py'
+ - 'tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
@@ -52,6 +53,7 @@ on:
- 'tests/cli/test_studio_release.py'
- 'tests/cli/test_studio_update.py'
- 'tests/cli/test_studio_startup_imports.py'
+ - 'tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py'
- 'tests/frontend/server/test_runtime_iam.py'
- 'tests/extensions/harness/**'
- 'tests/integrations/agentkit/test_app.py'
@@ -112,6 +114,7 @@ jobs:
tests/cli/test_generated_agent_sidecar_runtime.py \
tests/cli/test_managed_sidecar_source.py \
tests/cli/test_studio_startup_imports.py \
+ tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py \
tests/integrations/agentkit/test_app.py \
tests/cli/test_studio_rbac.py::test_sidecar_deployment_uses_agentkit_cli_structured_release \
tests/cli/test_generated_agent_backend_codegen_extended.py::test_generated_agent_sidecar_debug_uses_runtime_apig_and_active_plan \
diff --git a/frontend/server/studio_tools/veadk_builtin_tools.py b/frontend/server/studio_tools/veadk_builtin_tools.py
index 07561ae61..75d0ddfe1 100644
--- a/frontend/server/studio_tools/veadk_builtin_tools.py
+++ b/frontend/server/studio_tools/veadk_builtin_tools.py
@@ -17,15 +17,19 @@
from __future__ import annotations
import asyncio
+import copy
+import inspect
+from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, cast
from urllib.parse import quote
from google.adk.agents import Agent
from google.adk.agents.invocation_context import InvocationContext
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.sessions import InMemorySessionService, Session
-from google.adk.tools import FunctionTool, ToolContext
+from google.adk.tools.function_tool import FunctionTool
+from google.adk.tools.tool_context import ToolContext
from frontend.server.studio_tools.registry import (
StudioTool,
@@ -75,6 +79,106 @@
"web_search",
}
+_DEFERRED_BUILTIN_DECLARATIONS: dict[str, tuple[str, dict[str, Any]]] = {
+ "link_reader": (
+ inspect.cleandoc(
+ """
+ Use this tool when you need to fetch content from web pages, PDFs, or Douyin videos.
+ It retrieves the title and main content from the provided URLs.
+
+ Examples: {"url_list": ["abc.com", "xyz.com"]}
+ Args:
+ url_list (list[str]): A list of URLs to parse (maximum 3).
+ Returns:
+ list[dict]: A list of dictionaries, each containing the title and content of the corresponding URL.
+ """
+ ),
+ {
+ "properties": {
+ "url_list": {
+ "items": {"type": "string"},
+ "title": "Url List",
+ "type": "array",
+ }
+ },
+ "required": ["url_list"],
+ "title": "link_readerParams",
+ "type": "object",
+ },
+ ),
+ "image_edit": (
+ inspect.cleandoc(
+ """
+ Edit images in batch according to prompts and optional settings.
+
+ Each item in `params` describes a single image-edit request.
+
+ Args:
+ params (list[dict]):
+ A list of image editing requests. Each item supports:
+
+ Required:
+ - origin_image (str):
+ The URL or Base64 string of the original image to edit.
+ Example:
+ * URL: "https://example.com/image.png"
+ * Base64: "data:image/png;base64,"
+
+ - prompt (str):
+ The textual description/instruction for editing the image.
+ Supports English and Chinese.
+
+ Optional:
+ - image_name (str):
+ Name/identifier for the generated image.
+
+ - response_format (str):
+ Format of the returned image.
+ * "url": JPEG link (default)
+ * "b64_json": Base64 string in JSON
+
+ - guidance_scale (float):
+ How strongly the prompt affects the result.
+ Range: [1.0, 10.0], default 2.5.
+
+ - watermark (bool):
+ Whether to add watermark.
+ Default: True.
+
+ - seed (int):
+ Random seed for reproducibility.
+ Range: [-1, 2^31-1], default -1 (random).
+
+ Returns:
+ Dict: API response containing generated image metadata.
+ Example:
+ {
+ "status": "success",
+ "success_list": [{"image_name": ""}],
+ "error_list": [{}]
+ }
+
+ Notes:
+ - Uses SeedEdit 3.0 model.
+ - Provide the same `seed` for consistent outputs across runs.
+ - A high `guidance_scale` enforces stricter adherence to text prompt.
+ """
+ ),
+ {
+ "properties": {
+ "params": {
+ "items": {},
+ "title": "Params",
+ "type": "array",
+ }
+ },
+ "required": ["params"],
+ "title": "image_editParams",
+ "type": "object",
+ },
+ ),
+}
+
@dataclass
class _BuiltinExecutionHost:
@@ -101,8 +205,8 @@ async def execute(
async with lock:
session = Session(
id=context.session_id,
- appName=context.app_name,
- userId=context.user_id,
+ app_name=context.app_name,
+ user_id=context.user_id,
state=dict(self.states.get(context.scope_id, {})),
)
invocation_context = InvocationContext(
@@ -151,13 +255,17 @@ async def _publish_artifacts(
)
if artifact is None or artifact.inline_data is None:
continue
+ mime_type = artifact.inline_data.mime_type
+ data = artifact.inline_data.data
+ if not mime_type or data is None:
+ continue
record = await self.media_service.save_bytes(
app_name=context.app_name,
user_id=context.user_id,
session_id=context.session_id,
file_name=filename,
- mime_type=artifact.inline_data.mime_type,
- data=artifact.inline_data.data,
+ mime_type=mime_type,
+ data=data,
origin="model",
)
ref = record.ref
@@ -184,12 +292,21 @@ def _schema(function_tool: FunctionTool) -> tuple[str, dict[str, Any]]:
declaration = function_tool._get_declaration()
if declaration is None:
raise ValueError(f"Built-in tool has no declaration: {function_tool.name}")
- schema = dict(declaration.parameters_json_schema or {"type": "object"})
+ schema: dict[str, Any] = dict(
+ declaration.parameters_json_schema or {"type": "object"}
+ )
schema.setdefault("additionalProperties", False)
description = (declaration.description or function_tool.name).strip()[:4096]
return description, schema
+def _deferred_schema(name: str) -> tuple[str, dict[str, Any]]:
+ description, schema = _DEFERRED_BUILTIN_DECLARATIONS[name]
+ copied_schema = copy.deepcopy(schema)
+ copied_schema.setdefault("additionalProperties", False)
+ return description, copied_schema
+
+
def register_veadk_builtin_tools(
registry: StudioToolRegistry,
*,
@@ -198,16 +315,33 @@ def register_veadk_builtin_tools(
"""Expose the existing VeADK built-ins through the Studio-owned channel."""
host = _BuiltinExecutionHost(media_service=media_service)
+ resolved_tools: dict[str, FunctionTool] = {}
for name in list_builtin_tools():
- function_tool = FunctionTool(get_builtin_tool(name))
- description, input_schema = _schema(function_tool)
+ if name in _DEFERRED_BUILTIN_DECLARATIONS:
+ description, input_schema = _deferred_schema(name)
+ else:
+ function_tool = FunctionTool(
+ cast(Callable[..., Any], get_builtin_tool(name))
+ )
+ resolved_tools[name] = function_tool
+ description, input_schema = _schema(function_tool)
async def execute(
arguments: dict[str, Any],
context: StudioToolExecutionContext,
*,
- current_tool: FunctionTool = function_tool,
+ current_name: str = name,
) -> Any:
+ current_tool = resolved_tools.get(current_name)
+ if current_tool is None:
+ current_tool = FunctionTool(
+ cast(Callable[..., Any], get_builtin_tool(current_name))
+ )
+ if _schema(current_tool) != _deferred_schema(current_name):
+ raise RuntimeError(
+ f"Deferred built-in declaration changed: {current_name}"
+ )
+ resolved_tools[current_name] = current_tool
return await host.execute(current_tool, arguments, context)
registry.register(
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index d9b86bef4..45e64e436 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -152,6 +152,37 @@ def connection_factory(endpoint: str) -> object:
assert created_endpoints == ["https://sandbox.example"]
+def test_studio_tools_defer_builtin_model_clients_until_first_call() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from frontend.server.studio_tools.registry import build_studio_tool_registry
+
+registry = build_studio_tool_registry()
+names = {item["name"] for item in registry.manifests()}
+assert {"image_edit", "link_reader"} <= names
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "pydantic.v1.tools"
+ or name == "volcenginesdkarkruntime"
+ or name.startswith("volcenginesdkarkruntime.")
+)
+if unexpected:
+ raise SystemExit("built-in model clients loaded during Studio cold start")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_frontend_branding_defers_optional_logo_network_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
new file mode 100644
index 000000000..64533cf76
--- /dev/null
+++ b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
@@ -0,0 +1,96 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any, cast
+
+import pytest
+
+from frontend.server.studio_tools import veadk_builtin_tools
+from frontend.server.studio_tools.registry import (
+ StudioToolExecutionContext,
+ StudioToolRegistry,
+)
+
+
+def _context() -> StudioToolExecutionContext:
+ return StudioToolExecutionContext(
+ runtime_id="runtime-1",
+ app_name="app-1",
+ user_id="user-1",
+ session_id="session-1",
+ run_id="run-1",
+ scope_id="scope-1",
+ catalog_revision="revision-1",
+ )
+
+
+@pytest.mark.parametrize("name", ["image_edit", "link_reader"])
+def test_deferred_builtin_declaration_matches_canonical_tool(name: str) -> None:
+ function_tool = veadk_builtin_tools.FunctionTool(
+ cast(Callable[..., Any], veadk_builtin_tools.get_builtin_tool(name))
+ )
+
+ assert veadk_builtin_tools._deferred_schema(name) == veadk_builtin_tools._schema(
+ function_tool
+ )
+
+
+@pytest.mark.asyncio
+async def test_deferred_builtin_resolves_once_on_first_execution(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ resolutions: list[str] = []
+ description, _schema = veadk_builtin_tools._DEFERRED_BUILTIN_DECLARATIONS[
+ "link_reader"
+ ]
+
+ async def link_reader(url_list: list[str]) -> dict[str, Any]:
+ return {"urls": url_list}
+
+ link_reader.__doc__ = description
+
+ def resolve(name: str) -> Any:
+ resolutions.append(name)
+ return link_reader
+
+ monkeypatch.setattr(
+ veadk_builtin_tools,
+ "list_builtin_tools",
+ lambda: ["link_reader"],
+ )
+ monkeypatch.setattr(veadk_builtin_tools, "get_builtin_tool", resolve)
+ registry = StudioToolRegistry()
+
+ veadk_builtin_tools.register_veadk_builtin_tools(registry)
+
+ assert resolutions == []
+ first = await registry.execute(
+ name="link_reader",
+ executor_revision="veadk-builtin-v1",
+ arguments={"url_list": ["https://example.com"]},
+ context=_context(),
+ )
+ second = await registry.execute(
+ name="link_reader",
+ executor_revision="veadk-builtin-v1",
+ arguments={"url_list": ["https://example.org"]},
+ context=_context(),
+ )
+
+ assert first == {"urls": ["https://example.com"]}
+ assert second == {"urls": ["https://example.org"]}
+ assert resolutions == ["link_reader"]
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 3f33913b5..7aabec057 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -52,6 +52,7 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
aggregate_run = _run_script(aggregate)
assert "test_studio_sidecar.py" in backend_run
assert "tests/cli/test_studio_startup_imports.py" in backend_run
+ assert "test_deferred_veadk_builtin_tools.py" in backend_run
assert "test_legacy_runtime_recovery.py" in backend_run
assert "zero_mcp_servers_json" in backend_run
assert "test_zero_structured_mcp_is_valid" in backend_run
From a6a58345c9f2cae02aafabdefdce6694a58bb509 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 10:50:48 +0800
Subject: [PATCH 27/46] perf(studio): defer source project sandbox runtime
---
.../service.py | 23 ++++++--
frontend/server/migration/routes.py | 39 +++++++------
tests/cli/test_studio_startup_imports.py | 57 +++++++++++++++++++
3 files changed, 98 insertions(+), 21 deletions(-)
diff --git a/frontend/server/intelligent_development_projects/service.py b/frontend/server/intelligent_development_projects/service.py
index 3f9ebd3ac..f276be30d 100644
--- a/frontend/server/intelligent_development_projects/service.py
+++ b/frontend/server/intelligent_development_projects/service.py
@@ -22,17 +22,14 @@
import shlex
from collections.abc import Mapping
from datetime import datetime, timezone
+from typing import TYPE_CHECKING
from uuid import uuid4
-from frontend.server.intelligent_development import DeliveryReference, release_path
-from frontend.server.intelligent_development_task import IntentDecision
-from frontend.server.sandbox_remote import SandboxRemoteTransport
from frontend.server.source_project_limits import (
SOURCE_PROJECT_MAX_BYTES,
SOURCE_PROJECT_MAX_FILES,
SOURCE_PROJECT_MAX_REPORT_BYTES,
)
-from veadk.cli.frontend_sandbox import SandboxSessionUnavailableError
from .models import (
IntelligentDevelopmentProject,
@@ -49,6 +46,21 @@
)
from .models import SourceVersionView
+if TYPE_CHECKING:
+ from frontend.server.intelligent_development import DeliveryReference
+ from frontend.server.intelligent_development_task import IntentDecision
+ from frontend.server.sandbox_remote import SandboxRemoteTransport
+else:
+
+ def SandboxRemoteTransport(endpoint: str): # noqa: N802
+ """Preserve the injectable transport factory without loading it at startup."""
+ from frontend.server.sandbox_remote import (
+ SandboxRemoteTransport as _SandboxRemoteTransport,
+ )
+
+ return _SandboxRemoteTransport(endpoint)
+
+
_MAX_ARTIFACT_BYTES = SOURCE_PROJECT_MAX_BYTES
_MAX_REPORT_BYTES = SOURCE_PROJECT_MAX_REPORT_BYTES
logger = logging.getLogger(__name__)
@@ -183,6 +195,7 @@ async def restore_base_version(
from frontend.server.intelligent_development_source import (
load_intelligent_development_artifact,
)
+ from veadk.cli.frontend_sandbox import SandboxSessionUnavailableError
from .repository import IntelligentDevelopmentVersionIntegrityError
@@ -270,6 +283,8 @@ async def persist_delivery(
delivery: DeliveryReference,
decision: IntentDecision,
) -> tuple[IntelligentDevelopmentProject, IntelligentDevelopmentVersion]:
+ from frontend.server.intelligent_development import release_path
+
binding = await self._resolved_binding(owner_id, session_id)
base_version = (
await self.repository.get_version(
diff --git a/frontend/server/migration/routes.py b/frontend/server/migration/routes.py
index 19ae991f0..4f3bb3f1e 100644
--- a/frontend/server/migration/routes.py
+++ b/frontend/server/migration/routes.py
@@ -19,17 +19,12 @@
import asyncio
import logging
from collections.abc import Callable
-from typing import Any
+from typing import TYPE_CHECKING, Any
from fastapi import HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response
-from frontend.server.source_projects import (
- SOURCE_PROJECT_EXCEPTIONS,
- SourceProjectService,
-)
-
from .evaluation.models import EvaluationDatasetBody, ResumeEvaluationBody
from .evaluation.service import MigrationEvaluationService
from .models import (
@@ -44,6 +39,9 @@
)
logger = logging.getLogger(__name__)
+if TYPE_CHECKING:
+ from frontend.server.source_projects import SourceProjectService
+
_ZIP_CONTENT_TYPES = {
"application/zip",
"application/x-zip-compressed",
@@ -153,7 +151,7 @@ async def persist() -> dict[str, object]:
"versionId": version.version_id,
"message": "源码已保存到已迁移项目。",
}
- except (MigrationError, *SOURCE_PROJECT_EXCEPTIONS) as error:
+ except MigrationError as error:
logger.warning(
"Could not persist migration source task_id=%s error_type=%s",
task_id,
@@ -164,11 +162,20 @@ async def persist() -> dict[str, object]:
"message": "源码暂未保存,可刷新任务重试。",
"retryable": True,
}
- except Exception:
- logger.exception(
- "Unexpected migration persistence failure task_id=%s",
- task_id,
- )
+ except Exception as error:
+ from frontend.server.source_projects import SOURCE_PROJECT_EXCEPTIONS
+
+ if isinstance(error, SOURCE_PROJECT_EXCEPTIONS):
+ logger.warning(
+ "Could not persist migration source task_id=%s error_type=%s",
+ task_id,
+ type(error).__name__,
+ )
+ else:
+ logger.exception(
+ "Unexpected migration persistence failure task_id=%s",
+ task_id,
+ )
result = {
"state": "failed",
"message": "源码暂未保存,可刷新任务重试。",
@@ -378,12 +385,10 @@ async def watch() -> None:
@app.get("/web/agent-migrations/capabilities")
async def capabilities(request: Request) -> dict[str, object]:
owner_resolver(request)
- payload = await invoke("capabilities", service.capabilities)
+ payload: dict[str, object] = await invoke("capabilities", service.capabilities)
if evaluation_service is not None:
- payload = {
- **payload,
- "evaluation": evaluation_service.capabilities(),
- }
+ payload = dict(payload)
+ payload["evaluation"] = evaluation_service.capabilities()
return payload
@app.get("/web/agent-migrations/tasks")
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 45e64e436..281bfc675 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -359,6 +359,63 @@ def test_migration_gateway_defers_agentkit_runtime_until_first_call() -> None:
)
+def test_migration_routes_defer_source_project_runtime_until_persistence() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.migration.routes
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "frontend.server.source_projects"
+ or name.startswith("frontend.server.intelligent_development_projects")
+ or name == "frontend.server.intelligent_development_task"
+ or name == "frontend.server.sandbox_remote"
+ or name == "agentkit.toolkit.cli.sandbox.sandbox_client"
+)
+if unexpected:
+ raise SystemExit("migration source-project runtime loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_source_project_service_defers_sandbox_runtime_until_first_use() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.intelligent_development_projects.service
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "frontend.server.intelligent_development_task"
+ or name == "frontend.server.sandbox_remote"
+ or name == "veadk.cli.frontend_sandbox"
+ or name == "agentkit.toolkit.cli.sandbox.sandbox_client"
+)
+if unexpected:
+ raise SystemExit("source-project Sandbox runtime loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From f4ced5fc6947656fe00882f74426577b16981a92 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 11:42:43 +0800
Subject: [PATCH 28/46] perf(studio): defer environment project runtime
---
frontend/server/environments/service.py | 54 +++++++++++++++++-------
tests/cli/test_studio_startup_imports.py | 26 ++++++++++++
2 files changed, 65 insertions(+), 15 deletions(-)
diff --git a/frontend/server/environments/service.py b/frontend/server/environments/service.py
index a15598ac4..8c7e7326d 100644
--- a/frontend/server/environments/service.py
+++ b/frontend/server/environments/service.py
@@ -29,22 +29,10 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
-from typing import Any, Literal, Protocol
+from typing import TYPE_CHECKING, Any, Literal, Protocol
from urllib.parse import urlsplit
from uuid import uuid4
-from veadk.cli.generated_agent_codegen import (
- AgentDraft,
- GeneratedFile,
- GeneratedProject,
- SelectedSkill,
-)
-from veadk.cli.generated_agent_skills import (
- SkillSpaceResolver,
- materialize_selected_skills,
- skill_name_from_markdown,
-)
-
from .dockerfile import (
build_dockerfile,
environment_base_image,
@@ -91,6 +79,35 @@
from .resources import EnvironmentCloudGateway
from .tool_provisioning import EnvironmentToolProvisioner
+if TYPE_CHECKING:
+ from veadk.cli.generated_agent_codegen import GeneratedFile
+ from veadk.cli.generated_agent_skills import SkillSpaceResolver
+else:
+
+ def GeneratedFile(*args: Any, **kwargs: Any): # noqa: N802
+ """Preserve the injectable file factory without loading codegen at startup."""
+ from veadk.cli.generated_agent_codegen import GeneratedFile as _GeneratedFile
+
+ return _GeneratedFile(*args, **kwargs)
+
+
+async def materialize_selected_skills(*args: Any, **kwargs: Any) -> None:
+ """Load generated-skill runtime only for a real environment build."""
+ from veadk.cli.generated_agent_skills import (
+ materialize_selected_skills as _materialize_selected_skills,
+ )
+
+ await _materialize_selected_skills(*args, **kwargs)
+
+
+def skill_name_from_markdown(content: str) -> str | None:
+ """Load generated-skill parsing only when materialized files are inspected."""
+ from veadk.cli.generated_agent_skills import (
+ skill_name_from_markdown as _skill_name_from_markdown,
+ )
+
+ return _skill_name_from_markdown(content)
+
class WorkspaceReferenceLookup(Protocol):
async def workspace_names_for_environment(
@@ -1135,6 +1152,15 @@ async def _materialize_environment_skills(
owner_id: str,
environment: EnvironmentRecord,
) -> tuple[list[GeneratedFile], EnvironmentSkillManifest]:
+ if not environment.selected_skills:
+ return [], EnvironmentSkillManifest()
+
+ from veadk.cli.generated_agent_codegen import (
+ AgentDraft,
+ GeneratedProject,
+ SelectedSkill,
+ )
+
selected: list[SelectedSkill] = []
for item in environment.selected_skills:
payload = item.model_dump(by_alias=True)
@@ -1150,8 +1176,6 @@ async def _materialize_environment_skills(
payload.pop("artifactId", None)
skill = SelectedSkill.model_validate(payload)
selected.append(skill)
- if not selected:
- return [], EnvironmentSkillManifest()
project = GeneratedProject(name="environment", files=[])
await materialize_selected_skills(
AgentDraft(name="environment", selectedSkills=selected),
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 281bfc675..1c126082a 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -416,6 +416,32 @@ def test_source_project_service_defers_sandbox_runtime_until_first_use() -> None
)
+def test_environment_service_defers_generated_project_runtime_until_build() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.environments
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "veadk.cli.generated_agent_codegen"
+ or name == "veadk.cli.generated_agent_skills"
+)
+if unexpected:
+ raise SystemExit("generated project runtime loaded by environment composition")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From 2c60331292e0b7e0c3593459a9db18cd63c3794f Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 12:08:38 +0800
Subject: [PATCH 29/46] perf(studio): defer intelligent development sandbox
runtime
---
.../server/intelligent_development_routes.py | 39 +++++++++++++++++--
.../server/intelligent_development_source.py | 15 ++++++-
.../server/intelligent_development_task.py | 15 ++++++-
tests/cli/test_studio_startup_imports.py | 28 +++++++++++++
4 files changed, 90 insertions(+), 7 deletions(-)
diff --git a/frontend/server/intelligent_development_routes.py b/frontend/server/intelligent_development_routes.py
index 278453dd0..0181b46b4 100644
--- a/frontend/server/intelligent_development_routes.py
+++ b/frontend/server/intelligent_development_routes.py
@@ -25,11 +25,10 @@
from collections.abc import AsyncGenerator, Callable
from dataclasses import replace
from datetime import datetime, timezone
-from typing import Any
+from typing import TYPE_CHECKING, Any
from uuid import uuid4
import anyio
-from agentkit.toolkit.cli.sandbox.env_config import build_exec_session_envs
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from starlette.types import Receive, Scope, Send
@@ -56,7 +55,6 @@
project_error_detail,
project_http_error,
)
-from frontend.server.sandbox_remote import SandboxRemoteTransport
from veadk.cli.codex_app_server import (
CodexAppServerError,
CodexPermissionSettings,
@@ -81,13 +79,46 @@
SandboxValidationError,
mount_sandbox_routes,
)
-from veadk.cli.frontend_skill_creator import _sandbox_model_config
from veadk.cli.studio_model_catalog import (
provider_allows_studio_development_model,
)
from veadk.utils.cloud_provider import cloud_provider_from_env
from veadk.utils.logger import get_logger
+if TYPE_CHECKING:
+ from agentkit.sdk.tools.types import EnvsItemForCreateSession
+ from frontend.server.sandbox_remote import SandboxRemoteTransport
+else:
+
+ def SandboxRemoteTransport(endpoint: str): # noqa: N802
+ """Preserve the injectable transport factory without loading it at startup."""
+ from frontend.server.sandbox_remote import (
+ SandboxRemoteTransport as _SandboxRemoteTransport,
+ )
+
+ return _SandboxRemoteTransport(endpoint)
+
+
+def build_exec_session_envs(
+ *args: Any, **kwargs: Any
+) -> list[EnvsItemForCreateSession] | None:
+ """Load AgentKit Sandbox environment helpers on the first session request."""
+ from agentkit.toolkit.cli.sandbox.env_config import (
+ build_exec_session_envs as _build_exec_session_envs,
+ )
+
+ return _build_exec_session_envs(*args, **kwargs)
+
+
+def _sandbox_model_config(provider: str) -> tuple[str, str]:
+ """Load Skill creator model configuration only for Sandbox development."""
+ from veadk.cli.frontend_skill_creator import (
+ _sandbox_model_config as _resolve_sandbox_model_config,
+ )
+
+ return _resolve_sandbox_model_config(provider)
+
+
INTELLIGENT_DEVELOPMENT_PREFIX = "/web/intelligent-development"
INTELLIGENT_DEVELOPMENT_TOOL_NAME = "intelligent-development"
INTELLIGENT_DEVELOPMENT_AGENT_KIND = "intelligent-development"
diff --git a/frontend/server/intelligent_development_source.py b/frontend/server/intelligent_development_source.py
index b2d4b0d22..0d98c0cfb 100644
--- a/frontend/server/intelligent_development_source.py
+++ b/frontend/server/intelligent_development_source.py
@@ -24,6 +24,7 @@
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
+from typing import TYPE_CHECKING
from frontend.server.deployment_source import (
DeploymentSourceError,
@@ -37,7 +38,6 @@
IntelligentDevelopmentVersionNotFound,
SourceVersionProducer,
)
-from frontend.server.sandbox_remote import SandboxRemoteTransport
from frontend.server.source_project_limits import (
SOURCE_PROJECT_MAX_BYTES,
SOURCE_PROJECT_MAX_FILES,
@@ -49,6 +49,19 @@
SandboxSessionUnavailableError,
)
+if TYPE_CHECKING:
+ from frontend.server.sandbox_remote import SandboxRemoteTransport
+else:
+
+ def SandboxRemoteTransport(endpoint: str): # noqa: N802
+ """Preserve the injectable transport factory without loading it at startup."""
+ from frontend.server.sandbox_remote import (
+ SandboxRemoteTransport as _SandboxRemoteTransport,
+ )
+
+ return _SandboxRemoteTransport(endpoint)
+
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_MAX_ARTIFACT_BYTES = SOURCE_PROJECT_MAX_BYTES
_MAX_EXPANDED_BYTES = SOURCE_PROJECT_MAX_BYTES
diff --git a/frontend/server/intelligent_development_task.py b/frontend/server/intelligent_development_task.py
index b6ba4504e..340b19a46 100644
--- a/frontend/server/intelligent_development_task.py
+++ b/frontend/server/intelligent_development_task.py
@@ -31,7 +31,7 @@
from pathlib import PurePosixPath
import re
import shlex
-from typing import Literal, cast
+from typing import TYPE_CHECKING, Literal, cast
from uuid import uuid4
import yaml
@@ -42,7 +42,18 @@
StudioCredentials,
release_path,
)
-from frontend.server.sandbox_remote import SandboxRemoteTransport
+
+if TYPE_CHECKING:
+ from frontend.server.sandbox_remote import SandboxRemoteTransport
+else:
+
+ def SandboxRemoteTransport(endpoint: str): # noqa: N802
+ """Preserve the injectable transport factory without loading it at startup."""
+ from frontend.server.sandbox_remote import (
+ SandboxRemoteTransport as _SandboxRemoteTransport,
+ )
+
+ return _SandboxRemoteTransport(endpoint)
CredentialResolver = Callable[[], StudioCredentials]
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 1c126082a..caff3011c 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -442,6 +442,34 @@ def test_environment_service_defers_generated_project_runtime_until_build() -> N
)
+def test_intelligent_development_routes_defer_sandbox_helpers_until_first_use() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.intelligent_development_routes
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "frontend.server.sandbox_remote"
+ or name == "veadk.cli.frontend_skill_creator"
+ or name == "agentkit.toolkit.cli.sandbox.env_config"
+ or name == "agentkit.toolkit.cli.sandbox.sandbox_client"
+)
+if unexpected:
+ raise SystemExit("intelligent development Sandbox helpers loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From 84fed715967517400d2ab8cf260b904e9ca1ca30 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 13:13:11 +0800
Subject: [PATCH 30/46] perf(studio): defer workspace sandbox transport
---
frontend/server/workspace_projects.py | 16 +++++++++++++--
tests/cli/test_studio_startup_imports.py | 26 ++++++++++++++++++++++++
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/frontend/server/workspace_projects.py b/frontend/server/workspace_projects.py
index 900c098b4..4ab05b486 100644
--- a/frontend/server/workspace_projects.py
+++ b/frontend/server/workspace_projects.py
@@ -23,17 +23,29 @@
import shlex
from dataclasses import replace
from datetime import datetime, timezone
+from typing import TYPE_CHECKING, Any
from agentkit.sdk.tools.types import SetSessionTtlRequest
-from typing import Any
from fastapi import HTTPException
-from frontend.server.sandbox_remote import SandboxRemoteTransport
from frontend.server.workspace_preview import ProjectInput, _expired
from veadk.cli.agentkit_session_metadata import build_create_session_request
from veadk.cli.frontend_sandbox import SandboxCloudSession, STUDIO_SANDBOX_TTL_SECONDS
+if TYPE_CHECKING:
+ from frontend.server.sandbox_remote import SandboxRemoteTransport
+else:
+
+ def SandboxRemoteTransport(endpoint: str): # noqa: N802
+ """Load the remote transport only when a workspace request needs it."""
+ from frontend.server.sandbox_remote import (
+ SandboxRemoteTransport as _SandboxRemoteTransport,
+ )
+
+ return _SandboxRemoteTransport(endpoint)
+
+
_KIND = "studio-workspace"
_TRANSITIONAL_SESSIONS = {
"creating",
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index caff3011c..b1c964240 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -470,6 +470,32 @@ def test_intelligent_development_routes_defer_sandbox_helpers_until_first_use()
)
+def test_workspace_projects_defers_sandbox_transport_until_first_use() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.workspace_projects
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "frontend.server.sandbox_remote"
+ or name == "agentkit.toolkit.cli.sandbox.sandbox_client"
+)
+if unexpected:
+ raise SystemExit("workspace project Sandbox transport loaded at startup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From 0fdf9570ebd5bdf653aaf5dda7656d9464133120 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 13:44:03 +0800
Subject: [PATCH 31/46] perf(studio): defer workspace project runtime
---
frontend/server/sandbox_remote.py | 20 +++++----------
frontend/server/sandbox_remote_errors.py | 31 +++++++++++++++++++++++
frontend/server/workspace_preview.py | 26 ++++++++++++-------
tests/cli/test_studio_startup_imports.py | 32 ++++++++++++++++++++++++
4 files changed, 86 insertions(+), 23 deletions(-)
create mode 100644 frontend/server/sandbox_remote_errors.py
diff --git a/frontend/server/sandbox_remote.py b/frontend/server/sandbox_remote.py
index 3fb05c522..a6ebac4a6 100644
--- a/frontend/server/sandbox_remote.py
+++ b/frontend/server/sandbox_remote.py
@@ -28,6 +28,12 @@
build_file_url,
)
+from frontend.server.sandbox_remote_errors import (
+ SandboxRemoteError,
+ SandboxRemoteResponseError,
+ SandboxRemoteSizeError,
+)
+
_MAX_PATH_BYTES = 512
_MAX_TRANSFER_BYTES = 20 * 1024 * 1024
_MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024
@@ -35,20 +41,6 @@
_T = TypeVar("_T")
-class SandboxRemoteError(RuntimeError):
- def __init__(self, message: str, *, retryable: bool = False) -> None:
- super().__init__(message)
- self.retryable = retryable
-
-
-class SandboxRemoteResponseError(SandboxRemoteError):
- pass
-
-
-class SandboxRemoteSizeError(SandboxRemoteResponseError):
- pass
-
-
def _exact_file_path(value: str) -> str:
if (
not isinstance(value, str)
diff --git a/frontend/server/sandbox_remote_errors.py b/frontend/server/sandbox_remote_errors.py
new file mode 100644
index 000000000..81ddad6f3
--- /dev/null
+++ b/frontend/server/sandbox_remote_errors.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Lightweight Sandbox transport errors safe to import during route setup."""
+
+from __future__ import annotations
+
+
+class SandboxRemoteError(RuntimeError):
+ def __init__(self, message: str, *, retryable: bool = False) -> None:
+ super().__init__(message)
+ self.retryable = retryable
+
+
+class SandboxRemoteResponseError(SandboxRemoteError):
+ pass
+
+
+class SandboxRemoteSizeError(SandboxRemoteResponseError):
+ pass
diff --git a/frontend/server/workspace_preview.py b/frontend/server/workspace_preview.py
index 8a276aaa1..79a2fbc84 100644
--- a/frontend/server/workspace_preview.py
+++ b/frontend/server/workspace_preview.py
@@ -27,6 +27,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
+from frontend.server.sandbox_remote_errors import SandboxRemoteError
from veadk.cli.frontend_sandbox import (
SandboxCloudGateway,
SandboxCloudSession,
@@ -130,22 +131,29 @@ def mount_workspace_preview_routes(
creator_resolver: Callable[[Request], str],
) -> None:
tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip()
- from frontend.server.workspace_projects import PersistentWorkspaceProjects
- from frontend.server.sandbox_remote import SandboxRemoteError
+ projects = None
- projects = PersistentWorkspaceProjects(gateway, tool_id)
+ def project_service():
+ nonlocal projects
+ if projects is None:
+ from frontend.server.workspace_projects import PersistentWorkspaceProjects
- def require_tool() -> None:
+ projects = PersistentWorkspaceProjects(gateway, tool_id)
+ return projects
+
+ def require_tool():
nonlocal tool_id
tool_id = (os.getenv("STUDIO_WORKSPACE_TOOL_ID") or "").strip()
- projects.tool_id = tool_id
if not tool_id:
raise HTTPException(503, "请先配置工作区 Sandbox 镜像")
+ service = project_service()
+ service.tool_id = tool_id
+ return service
@app.get("/web/workspace-preview/state")
async def workspace_state(request: Request) -> JSONResponse:
owner = owner_resolver(request)
- require_tool()
+ projects = require_tool()
try:
return JSONResponse(
await projects.state(owner),
@@ -157,7 +165,7 @@ async def workspace_state(request: Request) -> JSONResponse:
@app.get("/web/workspace-preview/projects")
async def list_projects(request: Request) -> JSONResponse:
owner = owner_resolver(request)
- require_tool()
+ projects = require_tool()
try:
cloud, names = await projects.list(owner, creator_resolver(request))
details = await projects.describe(cloud, names)
@@ -175,7 +183,7 @@ async def list_projects(request: Request) -> JSONResponse:
@app.post("/web/workspace-preview/projects")
async def create_project(request: Request, body: ProjectInput) -> JSONResponse:
owner = owner_resolver(request)
- require_tool()
+ projects = require_tool()
try:
cloud = await projects.create(owner, creator_resolver(request), body.name)
return project_response(cloud, body.name)
@@ -185,7 +193,7 @@ async def create_project(request: Request, body: ProjectInput) -> JSONResponse:
@app.post("/web/workspace-preview/projects/{project_name}/open")
async def open_project(request: Request, project_name: str) -> JSONResponse:
owner = owner_resolver(request)
- require_tool()
+ projects = require_tool()
try:
cloud = await projects.open(owner, creator_resolver(request), project_name)
return project_response(cloud, project_name)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index b1c964240..94784d4d9 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -496,6 +496,38 @@ def test_workspace_projects_defers_sandbox_transport_until_first_use() -> None:
)
+def test_workspace_preview_route_setup_defers_project_sandbox_runtime() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from fastapi import FastAPI
+
+from frontend.server.workspace_preview import mount_workspace_preview_routes
+
+app = FastAPI()
+mount_workspace_preview_routes(app, object(), lambda _request: "owner", lambda _request: "creator")
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "frontend.server.workspace_projects"
+ or name == "frontend.server.sandbox_remote"
+ or name == "agentkit.toolkit.cli.sandbox.sandbox_client"
+)
+if unexpected:
+ raise SystemExit("workspace preview Sandbox runtime loaded during route setup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From b04527454ae0ffb02fa83b834971676b058d2920 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 14:04:09 +0800
Subject: [PATCH 32/46] perf(studio): defer request-only sdk types
---
frontend/server/agent_reviews/repository.py | 9 ++--
frontend/server/workspace_tool.py | 25 ++++++++---
tests/cli/test_studio_startup_imports.py | 46 +++++++++++++++++++++
3 files changed, 72 insertions(+), 8 deletions(-)
diff --git a/frontend/server/agent_reviews/repository.py b/frontend/server/agent_reviews/repository.py
index 57f1f332b..80b56404a 100644
--- a/frontend/server/agent_reviews/repository.py
+++ b/frontend/server/agent_reviews/repository.py
@@ -19,9 +19,6 @@
from collections.abc import Callable, Iterator
from typing import Any
-from agentkit.sdk.runtime import types as sdk
-from agentkit.sdk.runtime.client import AgentkitRuntimeClient
-
from frontend.server.agentkit_clients import create_agentkit_client
from frontend.server.storage import StudioProvider
@@ -38,6 +35,8 @@ def __init__(
self.credentials = credentials
def client(self, region: str) -> Any:
+ from agentkit.sdk.runtime.client import AgentkitRuntimeClient
+
access_key, secret_key, token = self.credentials()
return create_agentkit_client(
AgentkitRuntimeClient,
@@ -49,11 +48,15 @@ def client(self, region: str) -> Any:
)
def get(self, region: str, runtime_id: str) -> Any:
+ from agentkit.sdk.runtime import types as sdk
+
return self.client(region).get_runtime(
sdk.GetRuntimeRequest.model_validate({"RuntimeId": runtime_id})
)
def list(self, region: str) -> Iterator[Any]:
+ from agentkit.sdk.runtime import types as sdk
+
client = self.client(region)
token = ""
seen: set[str] = set()
diff --git a/frontend/server/workspace_tool.py b/frontend/server/workspace_tool.py
index 5111353b9..7c452b3da 100644
--- a/frontend/server/workspace_tool.py
+++ b/frontend/server/workspace_tool.py
@@ -21,9 +21,10 @@
import secrets
import time
from collections.abc import Mapping
-from typing import Any
+from typing import TYPE_CHECKING, Any
-from agentkit.sdk.tools import types
+if TYPE_CHECKING:
+ from frontend.server.storage import StudioProvider
DEFAULT_WORKSPACE_IMAGES = {
(
@@ -41,6 +42,12 @@
}
+def _tools_types():
+ from agentkit.sdk.tools import types
+
+ return types
+
+
def resolve_workspace_image(provider: str, region: str) -> str:
override = os.getenv("STUDIO_WORKSPACE_IMAGE", "").strip()
if override:
@@ -56,6 +63,7 @@ def resolve_workspace_image(provider: str, region: str) -> str:
def workspace_tool_request(
image: str, provider: str, model_environment: Mapping[str, str]
) -> Any:
+ types = _tools_types()
if provider not in {"volcengine", "byteplus"}:
raise ValueError("Unsupported workspace provider")
if not image or image != image.strip() or "://" in image or "/" not in image:
@@ -116,6 +124,7 @@ def ensure_workspace_tool(
model_environment: Mapping[str, str],
timeout: float = 600,
) -> str:
+ types = _tools_types()
request = workspace_tool_request(image, provider, model_environment)
response = client.list_tools(
types.ListToolsRequest(
@@ -239,7 +248,9 @@ def workspace_update_environment(
return {"STUDIO_WORKSPACE_TOOL_ID": tool_id}
-def repair_deployed_workspace_binding(*, provider: str, resolve_credentials) -> str:
+def repair_deployed_workspace_binding(
+ *, provider: StudioProvider, resolve_credentials
+) -> str:
"""Backfill older releases whose updater did not know about workspace Tools."""
import volcenginesdkvefaas as faas
from veadk.integrations.ve_faas.ve_faas import VeFaaS
@@ -258,7 +269,9 @@ def repair_deployed_workspace_binding(*, provider: str, resolve_credentials) ->
)
def read_environment():
- function = service.client.get_function(faas.GetFunctionRequest(id=function_id))
+ function: Any = service.client.get_function(
+ faas.GetFunctionRequest(id=function_id)
+ )
return {item.key: item.value for item in function.envs or []}
environment = read_environment()
@@ -291,7 +304,9 @@ def read_environment():
return overrides["STUDIO_WORKSPACE_TOOL_ID"]
-def mount_workspace_upgrade_repair(app, *, provider: str, resolve_credentials) -> None:
+def mount_workspace_upgrade_repair(
+ app, *, provider: StudioProvider, resolve_credentials
+) -> None:
"""Run only for deployed Studios lacking the new binding, without blocking HTTP."""
if os.getenv("STUDIO_WORKSPACE_TOOL_ID", "").strip() or not os.getenv(
"VEADK_STUDIO_FUNCTION_ID"
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 94784d4d9..8546f6277 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -528,6 +528,52 @@ def test_workspace_preview_route_setup_defers_project_sandbox_runtime() -> None:
)
+def test_workspace_tool_defers_agentkit_types_until_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.workspace_tool
+
+if "agentkit.sdk.tools.types" in sys.modules:
+ raise SystemExit("workspace Tool SDK types loaded during route setup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_agent_review_repository_defers_runtime_sdk_until_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import frontend.server.agent_reviews.repository
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "agentkit.sdk.runtime.types"
+ or name == "agentkit.sdk.runtime.client"
+)
+if unexpected:
+ raise SystemExit("Agent review Runtime SDK loaded during route setup")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
From 288876b8545d4c552c4765da6d14a5246a48e001 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 15:07:12 +0800
Subject: [PATCH 33/46] perf(studio): defer adk session startup
---
tests/cli/test_studio_startup_imports.py | 95 +++++++++
veadk/cli/agentkit_session_metadata.py | 213 +++++++++++++-------
veadk/cli/cli_frontend.py | 7 +-
veadk/cli/studio_start.py | 239 +++++++++++++++++++++++
4 files changed, 481 insertions(+), 73 deletions(-)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 8546f6277..2586607be 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -574,6 +574,101 @@ def test_agent_review_repository_defers_runtime_sdk_until_request() -> None:
)
+def test_studio_entrypoint_defers_unused_google_adk_package_exports() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import veadk.cli.studio_start
+from google.adk.cli.fast_api import get_fast_api_app
+
+assert callable(get_fast_api_app)
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "google.adk.cli.cli_tools_click"
+ or name == "google.adk.workflow._workflow"
+ or name == "google.adk.workflow._graph"
+ or name == "google.adk.workflow._function_node"
+ or name == "google.adk.evaluation"
+ or name.startswith("google.adk.evaluation.")
+)
+if unexpected:
+ raise SystemExit("unused Google ADK package exports loaded by Studio entrypoint")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_studio_fast_api_defers_evaluation_storage_until_first_use() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+import tempfile
+from pathlib import Path
+
+from veadk.cli.studio_start import studio_fast_api_factory
+
+with tempfile.TemporaryDirectory() as agents_dir:
+ app_dir = Path(agents_dir, "agent-test")
+ app_dir.mkdir()
+ app = studio_fast_api_factory()(agents_dir=agents_dir, web=False)
+ assert any(route.path == "/list-apps" for route in app.routes)
+ if "google.adk.evaluation" in sys.modules:
+ raise SystemExit("evaluation storage loaded while constructing Studio app")
+
+ fast_api = sys.modules["google.adk.cli.fast_api"]
+ manager = fast_api.LocalEvalSetsManager(agents_dir=agents_dir)
+ assert manager.list_eval_sets("agent-test") == []
+ if "google.adk.evaluation.local_eval_sets_manager" not in sys.modules:
+ raise SystemExit("evaluation storage not loaded for a real request")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_session_metadata_defers_agentkit_models_until_first_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from veadk.cli import agentkit_session_metadata as metadata
+
+assert metadata.session_display_name_metadata_value("Studio") == "Studio"
+if "agentkit.sdk.tools.types" in sys.modules:
+ raise SystemExit("AgentKit Session models loaded during Studio route setup")
+
+request = metadata.build_list_sessions_request(
+ tool_id="tool-test",
+ max_results=10,
+ username="owner-test",
+)
+assert request is not None
+if "agentkit.sdk.tools.types" not in sys.modules:
+ raise SystemExit("AgentKit Session models not loaded for a real request")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_knowledge_routes_defer_document_extraction_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/veadk/cli/agentkit_session_metadata.py b/veadk/cli/agentkit_session_metadata.py
index af1f59de6..591fcfe14 100644
--- a/veadk/cli/agentkit_session_metadata.py
+++ b/veadk/cli/agentkit_session_metadata.py
@@ -17,11 +17,10 @@
from __future__ import annotations
from collections.abc import Mapping
+from functools import cache
+from types import SimpleNamespace
from typing import Any
-from agentkit.sdk.tools import types as tools_types
-from pydantic import Field
-
SESSION_DISPLAY_NAME_MAX_LENGTH = 40
SESSION_METADATA_VALUE_MAX_BYTES = 63
SESSION_DISPLAY_NAME_METADATA_KEY = "veadk_display_name"
@@ -31,67 +30,131 @@
_SESSION_DISPLAY_NAME_TRUNCATION_MARK = "…"
-class _SessionMetadata(tools_types.ToolsBaseModel):
- key: str = Field(alias="Key")
- type: str | None = Field(default=None, alias="Type")
- value: str = Field(alias="Value")
-
-
-class _SessionEnv(tools_types.ToolsBaseModel):
- key: str = Field(alias="Key")
- value: str = Field(alias="Value")
-
-
-class _CreateSessionRequestCompat(tools_types.CreateSessionRequest):
- metadata: list[_SessionMetadata] | None = Field(default=None, alias="Metadata")
-
-
-class _CreateSessionRequestFullCompat(tools_types.ToolsBaseModel):
- tool_id: str = Field(alias="ToolId")
- ttl: int | None = Field(default=None, alias="Ttl")
- ttl_unit: str | None = Field(default=None, alias="TtlUnit")
- user_session_id: str | None = Field(default=None, alias="UserSessionId")
- metadata: list[_SessionMetadata] | None = Field(default=None, alias="Metadata")
- envs: list[_SessionEnv] | None = Field(default=None, alias="Envs")
-
-
-class _ListSessionsRequestCompat(tools_types.ListSessionsRequest):
- metadata: list[_SessionMetadata] | None = Field(default=None, alias="Metadata")
-
-
-class _GetSessionResponseCompat(tools_types.ToolsBaseModel):
- created_at: str | None = Field(default=None, alias="CreatedAt")
- endpoint: str | None = Field(default=None, alias="Endpoint")
- expire_at: str | None = Field(default=None, alias="ExpireAt")
- internal_endpoint: str | None = Field(default=None, alias="InternalEndpoint")
- session_id: str | None = Field(default=None, alias="SessionId")
- status: str | None = Field(default=None, alias="Status")
- tool_type: str | None = Field(default=None, alias="ToolType")
- user_session_id: str | None = Field(default=None, alias="UserSessionId")
- metadata: list[_SessionMetadata] | None = Field(default=None, alias="Metadata")
-
-
-class _SessionInfoCompat(_GetSessionResponseCompat):
- pass
+@cache
+def _session_models() -> SimpleNamespace:
+ """Build SDK compatibility models only for an actual Session request."""
+ from agentkit.sdk.tools import types as tools_types
+ from pydantic import Field, create_model
-class _ListSessionsResponseCompat(tools_types.ToolsBaseModel):
- next_token: str | None = Field(default=None, alias="NextToken")
- session_infos: list[_SessionInfoCompat] | None = Field(
- default=None,
- alias="SessionInfos",
+ session_metadata = create_model(
+ "_SessionMetadata",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ key=(str, Field(alias="Key")),
+ type=(str | None, Field(default=None, alias="Type")),
+ value=(str, Field(alias="Value")),
)
-
-
-class _SnapshotInfoCompat(tools_types.SnapshotsForListSessionSnapshots):
- session_metadata: list[_SessionMetadata] | None = Field(
- default=None, alias="SessionMetadata"
+ session_env = create_model(
+ "_SessionEnv",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ key=(str, Field(alias="Key")),
+ value=(str, Field(alias="Value")),
+ )
+ create_request_compat = create_model(
+ "_CreateSessionRequestCompat",
+ __base__=tools_types.CreateSessionRequest,
+ __module__=__name__,
+ metadata=(
+ list[session_metadata] | None,
+ Field(default=None, alias="Metadata"),
+ ),
+ )
+ create_request_full_compat = create_model(
+ "_CreateSessionRequestFullCompat",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ tool_id=(str, Field(alias="ToolId")),
+ ttl=(int | None, Field(default=None, alias="Ttl")),
+ ttl_unit=(str | None, Field(default=None, alias="TtlUnit")),
+ user_session_id=(
+ str | None,
+ Field(default=None, alias="UserSessionId"),
+ ),
+ metadata=(
+ list[session_metadata] | None,
+ Field(default=None, alias="Metadata"),
+ ),
+ envs=(list[session_env] | None, Field(default=None, alias="Envs")),
+ )
+ list_request_compat = create_model(
+ "_ListSessionsRequestCompat",
+ __base__=tools_types.ListSessionsRequest,
+ __module__=__name__,
+ metadata=(
+ list[session_metadata] | None,
+ Field(default=None, alias="Metadata"),
+ ),
+ )
+ get_response_compat = create_model(
+ "_GetSessionResponseCompat",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ created_at=(str | None, Field(default=None, alias="CreatedAt")),
+ endpoint=(str | None, Field(default=None, alias="Endpoint")),
+ expire_at=(str | None, Field(default=None, alias="ExpireAt")),
+ internal_endpoint=(
+ str | None,
+ Field(default=None, alias="InternalEndpoint"),
+ ),
+ session_id=(str | None, Field(default=None, alias="SessionId")),
+ status=(str | None, Field(default=None, alias="Status")),
+ tool_type=(str | None, Field(default=None, alias="ToolType")),
+ user_session_id=(
+ str | None,
+ Field(default=None, alias="UserSessionId"),
+ ),
+ metadata=(
+ list[session_metadata] | None,
+ Field(default=None, alias="Metadata"),
+ ),
+ )
+ session_info_compat = create_model(
+ "_SessionInfoCompat",
+ __base__=get_response_compat,
+ __module__=__name__,
+ )
+ list_response_compat = create_model(
+ "_ListSessionsResponseCompat",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ next_token=(str | None, Field(default=None, alias="NextToken")),
+ session_infos=(
+ list[session_info_compat] | None,
+ Field(default=None, alias="SessionInfos"),
+ ),
+ )
+ snapshot_info_compat = create_model(
+ "_SnapshotInfoCompat",
+ __base__=tools_types.SnapshotsForListSessionSnapshots,
+ __module__=__name__,
+ session_metadata=(
+ list[session_metadata] | None,
+ Field(default=None, alias="SessionMetadata"),
+ ),
+ )
+ list_snapshots_response_compat = create_model(
+ "_ListSessionSnapshotsResponseCompat",
+ __base__=tools_types.ToolsBaseModel,
+ __module__=__name__,
+ next_token=(str | None, Field(default=None, alias="NextToken")),
+ snapshots=(
+ list[snapshot_info_compat] | None,
+ Field(default=None, alias="Snapshots"),
+ ),
+ )
+ return SimpleNamespace(
+ tools_types=tools_types,
+ session_metadata=session_metadata,
+ session_env=session_env,
+ create_request_compat=create_request_compat,
+ create_request_full_compat=create_request_full_compat,
+ list_request_compat=list_request_compat,
+ get_response_compat=get_response_compat,
+ list_response_compat=list_response_compat,
+ list_snapshots_response_compat=list_snapshots_response_compat,
)
-
-
-class _ListSessionSnapshotsResponseCompat(tools_types.ToolsBaseModel):
- next_token: str | None = Field(default=None, alias="NextToken")
- snapshots: list[_SnapshotInfoCompat] | None = Field(default=None, alias="Snapshots")
def _model_supports_alias(model: Any, alias: str) -> bool:
@@ -128,11 +191,13 @@ def build_create_session_request(
envs: Mapping[str, str] | None = None,
) -> Any:
"""Build a native or compatibility CreateSession request."""
+ models = _session_models()
+ tools_types = models.tools_types
metadata = []
if display_name:
display_name = session_display_name_metadata_value(display_name)
metadata.append(
- _SessionMetadata(
+ models.session_metadata(
Key=SESSION_DISPLAY_NAME_METADATA_KEY,
Type="String",
Value=display_name,
@@ -140,7 +205,7 @@ def build_create_session_request(
)
if username:
metadata.append(
- _SessionMetadata(
+ models.session_metadata(
Key=SESSION_USERNAME_METADATA_KEY,
Type="String",
Value=username,
@@ -148,7 +213,7 @@ def build_create_session_request(
)
if creator_name:
metadata.append(
- _SessionMetadata(
+ models.session_metadata(
Key=SESSION_CREATOR_NAME_METADATA_KEY,
Type="String",
Value=creator_name,
@@ -156,7 +221,7 @@ def build_create_session_request(
)
if agent_kind:
metadata.append(
- _SessionMetadata(
+ models.session_metadata(
Key=SESSION_AGENT_KIND_METADATA_KEY,
Type="String",
Value=agent_kind,
@@ -166,9 +231,9 @@ def build_create_session_request(
supports_metadata = _model_supports_alias(request_type, "Metadata")
supports_envs = _model_supports_alias(request_type, "Envs")
if metadata and not supports_metadata:
- request_type = _CreateSessionRequestCompat
+ request_type = models.create_request_compat
if envs and not supports_envs:
- request_type = _CreateSessionRequestFullCompat
+ request_type = models.create_request_full_compat
request_data: dict[str, Any] = {
"ToolId": tool_id,
"Ttl": ttl_seconds,
@@ -178,7 +243,7 @@ def build_create_session_request(
if metadata:
request_data["Metadata"] = metadata
if envs:
- env_type = getattr(tools_types, "EnvsItemForCreateSession", _SessionEnv)
+ env_type = getattr(tools_types, "EnvsItemForCreateSession", models.session_env)
request_data["Envs"] = [
env_type(Key=key, Value=value)
for key, value in envs.items()
@@ -195,9 +260,11 @@ def build_list_sessions_request(
username: str | None = None,
) -> Any:
"""Build ListSessions with an optional Username metadata filter."""
+ models = _session_models()
+ tools_types = models.tools_types
request_type: Any = tools_types.ListSessionsRequest
if username is not None and not _model_supports_alias(request_type, "Metadata"):
- request_type = _ListSessionsRequestCompat
+ request_type = models.list_request_compat
request_data: dict[str, Any] = {
"ToolId": tool_id,
"MaxResults": max_results,
@@ -205,7 +272,7 @@ def build_list_sessions_request(
}
if username is not None:
request_data["Metadata"] = [
- _SessionMetadata(
+ models.session_metadata(
Key=SESSION_USERNAME_METADATA_KEY,
Value=username,
)
@@ -215,22 +282,24 @@ def build_list_sessions_request(
def call_session_client(client: Any, method_name: str, request: Any) -> Any:
"""Invoke a Session API while preserving Metadata on older SDK releases."""
+ models = _session_models()
+ tools_types = models.tools_types
native_response_model: Any | None = None
compat_response_model: Any | None = None
api_action = ""
metadata_alias = "Metadata"
if method_name == "get_session":
native_response_model = tools_types.GetSessionResponse
- compat_response_model = _GetSessionResponseCompat
+ compat_response_model = models.get_response_compat
api_action = "GetSession"
elif method_name == "list_sessions":
native_response_model = tools_types.SessionInfosForListSessions
- compat_response_model = _ListSessionsResponseCompat
+ compat_response_model = models.list_response_compat
api_action = "ListSessions"
elif method_name == "list_session_snapshots":
native_response_model = tools_types.SnapshotsForListSessionSnapshots
- compat_response_model = _ListSessionSnapshotsResponseCompat
+ compat_response_model = models.list_snapshots_response_compat
api_action = "ListSessionSnapshots"
metadata_alias = "SessionMetadata"
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 567cd8027..7d50257f1 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -1973,7 +1973,12 @@ def _run_frontend_server(
sandbox_chat_hermes_snapshot_tool_id
)
- from google.adk.cli.fast_api import get_fast_api_app
+ if os.environ.get("_VEADK_STUDIO_LAZY_ADK_PACKAGES") == "1":
+ from veadk.cli.studio_start import studio_fast_api_factory
+
+ get_fast_api_app = studio_fast_api_factory()
+ else:
+ from google.adk.cli.fast_api import get_fast_api_app
agents_dir = os.path.abspath(agents_dir)
allow_origins = _frontend_allow_origins(vite)
diff --git a/veadk/cli/studio_start.py b/veadk/cli/studio_start.py
index 4e2fa4919..2d2ddde28 100644
--- a/veadk/cli/studio_start.py
+++ b/veadk/cli/studio_start.py
@@ -16,8 +16,246 @@
from __future__ import annotations
+import ast
+import importlib
+import importlib.machinery
+import importlib.util
import os
+from pathlib import Path
import sys
+from types import ModuleType
+from typing import Any
+
+
+_STUDIO_LAZY_ADK_PACKAGES = "_VEADK_STUDIO_LAZY_ADK_PACKAGES"
+
+
+def _install_lazy_package(
+ name: str,
+ exports: dict[str, tuple[str, str | None]],
+) -> ModuleType:
+ existing = sys.modules.get(name)
+ if existing is not None:
+ return existing
+ spec = importlib.util.find_spec(name)
+ if spec is None or spec.submodule_search_locations is None:
+ raise ImportError(f"Unable to locate package {name!r}")
+ package = ModuleType(name)
+ package.__file__ = spec.origin
+ package.__loader__ = spec.loader
+ package.__package__ = name
+ package.__path__ = list(spec.submodule_search_locations)
+ package.__spec__ = spec
+ package.__dict__["__all__"] = list(exports)
+
+ def _load_export(attribute: str) -> Any:
+ target = exports.get(attribute)
+ if target is None:
+ raise AttributeError(f"module {name!r} has no attribute {attribute!r}")
+ module_name, target_attribute = target
+ module = importlib.import_module(module_name)
+ value = (
+ module if target_attribute is None else getattr(module, target_attribute)
+ )
+ setattr(package, attribute, value)
+ return value
+
+ package.__getattr__ = _load_export # type: ignore[attr-defined]
+ sys.modules[name] = package
+ return package
+
+
+def _install_lazy_module(name: str, eager: dict[str, Any]) -> ModuleType:
+ existing = sys.modules.get(name)
+ if existing is not None:
+ return existing
+ parent_name, _, _child = name.rpartition(".")
+ parent = sys.modules.get(parent_name)
+ parent_path = getattr(parent, "__path__", None)
+ if parent is None or parent_path is None:
+ raise ImportError(f"Unable to locate parent package {parent_name!r}")
+ spec = importlib.machinery.PathFinder.find_spec(name, parent_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Unable to locate module {name!r}")
+ loader = spec.loader
+ proxy = ModuleType(name)
+ proxy.__file__ = spec.origin
+ proxy.__loader__ = spec.loader
+ proxy.__package__ = parent_name
+ proxy.__spec__ = spec
+ proxy.__dict__.update(eager)
+ loaded: list[ModuleType] = []
+
+ def _load_real() -> ModuleType:
+ if loaded:
+ return loaded[0]
+ real = importlib.util.module_from_spec(spec)
+ sys.modules[name] = real
+ try:
+ loader.exec_module(real)
+ except BaseException:
+ sys.modules[name] = proxy
+ raise
+ loaded.append(real)
+ return real
+
+ def _load_attribute(attribute: str) -> Any:
+ if attribute.startswith("__"):
+ raise AttributeError(f"module {name!r} has no attribute {attribute!r}")
+ return getattr(_load_real(), attribute)
+
+ proxy.__getattr__ = _load_attribute # type: ignore[attr-defined]
+ proxy.__dict__["_load_real_module"] = _load_real
+ sys.modules[name] = proxy
+ return proxy
+
+
+def _literal_assignment(module_name: str, assignment: str) -> Any:
+ parent_name, _, _child = module_name.rpartition(".")
+ parent = sys.modules.get(parent_name)
+ parent_path = getattr(parent, "__path__", None)
+ if parent is None or parent_path is None:
+ raise ImportError(f"Unable to locate parent package {parent_name!r}")
+ spec = importlib.machinery.PathFinder.find_spec(module_name, parent_path)
+ if spec is None or spec.origin is None:
+ raise ImportError(f"Unable to locate module {module_name!r}")
+ source = Path(spec.origin).read_text(encoding="utf-8")
+ for node in ast.parse(source, filename=spec.origin).body:
+ if not isinstance(node, (ast.Assign, ast.AnnAssign)):
+ continue
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
+ if node.value is not None and any(
+ isinstance(target, ast.Name) and target.id == assignment
+ for target in targets
+ ):
+ return ast.literal_eval(node.value)
+ raise ImportError(f"Unable to locate {assignment!r} in {module_name!r}")
+
+
+def _install_studio_adk_imports() -> None:
+ """Keep command-only ADK exports out of the Studio readiness path."""
+
+ _install_lazy_package(
+ "google.adk",
+ {
+ "version": ("google.adk.version", None),
+ "__version__": ("google.adk.version", "__version__"),
+ "Agent": ("google.adk.agents.llm_agent", "Agent"),
+ "Context": ("google.adk.agents.context", "Context"),
+ "Event": ("google.adk.events.event", "Event"),
+ "Runner": ("google.adk.runners", "Runner"),
+ "Workflow": ("google.adk.workflow", "Workflow"),
+ },
+ )
+ _install_lazy_package(
+ "google.adk.cli",
+ {"main": ("google.adk.cli.cli_tools_click", "main")},
+ )
+ _install_lazy_package(
+ "google.adk.workflow",
+ {
+ "BaseNode": ("google.adk.workflow._base_node", "BaseNode"),
+ "DEFAULT_ROUTE": ("google.adk.workflow._graph", "DEFAULT_ROUTE"),
+ "Edge": ("google.adk.workflow._graph", "Edge"),
+ "FunctionNode": (
+ "google.adk.workflow._function_node",
+ "FunctionNode",
+ ),
+ "JoinNode": ("google.adk.workflow._join_node", "JoinNode"),
+ "Node": ("google.adk.workflow._node", "Node"),
+ "NodeTimeoutError": (
+ "google.adk.workflow._errors",
+ "NodeTimeoutError",
+ ),
+ "RetryConfig": (
+ "google.adk.workflow._retry_config",
+ "RetryConfig",
+ ),
+ "START": ("google.adk.workflow._base_node", "START"),
+ "Workflow": ("google.adk.workflow._workflow", "Workflow"),
+ "node": ("google.adk.workflow._node", "node"),
+ },
+ )
+ _install_lazy_module(
+ "google.adk.cli.cli_eval",
+ {"EVAL_SESSION_ID_PREFIX": "___eval___session___"},
+ )
+ _install_lazy_module(
+ "google.adk.cli.cli_deploy",
+ {
+ "_AGENT_ENGINE_CLASS_METHODS": _literal_assignment(
+ "google.adk.cli.cli_deploy",
+ "_AGENT_ENGINE_CLASS_METHODS",
+ )
+ },
+ )
+ dev_server = _install_lazy_module("google.adk.cli.dev_server", {})
+
+ class _DeferredDevServer:
+ def __new__(cls, *args: Any, **kwargs: Any) -> Any:
+ real = dev_server.__dict__["_load_real_module"]()
+ return real.DevServer(*args, **kwargs)
+
+ setattr(dev_server, "DevServer", _DeferredDevServer)
+ os.environ[_STUDIO_LAZY_ADK_PACKAGES] = "1"
+
+
+class _DeferredEvalManager:
+ def __init__(
+ self,
+ module_name: str,
+ class_name: str,
+ *args: Any,
+ **kwargs: Any,
+ ) -> None:
+ self._module_name = module_name
+ self._class_name = class_name
+ self._args = args
+ self._kwargs = kwargs
+ self._delegate: Any | None = None
+
+ def _resolve(self) -> Any:
+ if self._delegate is None:
+ manager = getattr(
+ importlib.import_module(self._module_name), self._class_name
+ )
+ self._delegate = manager(*self._args, **self._kwargs)
+ return self._delegate
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(self._resolve(), name)
+
+
+def studio_fast_api_factory() -> Any:
+ """Return ADK's production app factory with evaluation storage deferred."""
+
+ fast_api = importlib.import_module("google.adk.cli.fast_api")
+
+ class _DeferredEvalSetsManager(_DeferredEvalManager):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(
+ "google.adk.evaluation.local_eval_sets_manager",
+ "LocalEvalSetsManager",
+ *args,
+ **kwargs,
+ )
+
+ class _DeferredEvalSetResultsManager(_DeferredEvalManager):
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ super().__init__(
+ "google.adk.evaluation.local_eval_set_results_manager",
+ "LocalEvalSetResultsManager",
+ *args,
+ **kwargs,
+ )
+
+ setattr(fast_api, "LocalEvalSetsManager", _DeferredEvalSetsManager)
+ setattr(
+ fast_api,
+ "LocalEvalSetResultsManager",
+ _DeferredEvalSetResultsManager,
+ )
+ return fast_api.get_fast_api_app
def _bootstrap_provider() -> None:
@@ -44,6 +282,7 @@ def _bootstrap_provider() -> None:
_bootstrap_provider()
+_install_studio_adk_imports()
from veadk.cli.cli_frontend import studio # noqa: E402
From d4d1b13803dd2d8dae09617ca6564e5555c5cb3d Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 16:37:38 +0800
Subject: [PATCH 34/46] perf(studio): defer genai request models
---
tests/cli/test_studio_startup_imports.py | 97 ++++++++++++++
veadk/cli/studio_start.py | 158 +++++++++++++++++++++++
2 files changed, 255 insertions(+)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 2586607be..36600a8eb 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -639,6 +639,103 @@ def test_studio_fast_api_defers_evaluation_storage_until_first_use() -> None:
)
+def test_studio_fast_api_defers_genai_models_until_first_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+import tempfile
+from pathlib import Path
+
+from veadk.cli.studio_start import studio_fast_api_factory
+
+with tempfile.TemporaryDirectory() as agents_dir:
+ Path(agents_dir, "agent-test").mkdir()
+ app = studio_fast_api_factory()(agents_dir=agents_dir, web=False)
+ assert any(route.path == "/run_sse" for route in app.routes)
+
+ genai_types = sys.modules["google.genai.types"]
+ real_module_loaded = getattr(
+ genai_types,
+ "_veadk_real_module_loaded",
+ None,
+ )
+ if real_module_loaded is None or real_module_loaded():
+ raise SystemExit("Google GenAI models loaded while constructing Studio app")
+
+ from google.adk.cli.api_server import RunAgentRequest
+
+ request = RunAgentRequest.model_validate(
+ {
+ "app_name": "agent-test",
+ "user_id": "user-test",
+ "session_id": "session-test",
+ "new_message": {
+ "role": "user",
+ "parts": [{"text": "hello"}],
+ },
+ }
+ )
+ assert type(request.new_message).__name__ == "Content"
+ assert request.new_message.parts[0].text == "hello"
+ dumped = request.model_dump(mode="json")
+ assert dumped["new_message"]["parts"][0]["text"] == "hello"
+ if not real_module_loaded():
+ raise SystemExit("Google GenAI models not loaded for a real request")
+
+ from google.adk.tools.function_tool import FunctionTool
+
+ def sample_tool(value: str) -> str:
+ return value
+
+ declaration = FunctionTool(sample_tool)._get_declaration()
+ assert declaration.parameters_json_schema["properties"]["value"] == {
+ "title": "Value",
+ "type": "string",
+ }
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_studio_deferred_genai_models_support_openapi_first_use() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+import tempfile
+from pathlib import Path
+
+from veadk.cli.studio_start import studio_fast_api_factory
+
+with tempfile.TemporaryDirectory() as agents_dir:
+ Path(agents_dir, "agent-test").mkdir()
+ app = studio_fast_api_factory()(agents_dir=agents_dir, web=False)
+ genai_types = sys.modules["google.genai.types"]
+ real_module_loaded = genai_types._veadk_real_module_loaded
+ if real_module_loaded():
+ raise SystemExit("Google GenAI models loaded before OpenAPI generation")
+
+ schema = app.openapi()
+ assert "/run_sse" in schema["paths"]
+ assert schema["components"]["schemas"]["RunAgentRequest"]
+ if not real_module_loaded():
+ raise SystemExit("Google GenAI models not loaded for OpenAPI generation")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_session_metadata_defers_agentkit_models_until_first_request() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/veadk/cli/studio_start.py b/veadk/cli/studio_start.py
index 2d2ddde28..7d1530aff 100644
--- a/veadk/cli/studio_start.py
+++ b/veadk/cli/studio_start.py
@@ -17,12 +17,14 @@
from __future__ import annotations
import ast
+from enum import Enum
import importlib
import importlib.machinery
import importlib.util
import os
from pathlib import Path
import sys
+from threading import RLock
from types import ModuleType
from typing import Any
@@ -110,6 +112,161 @@ def _load_attribute(attribute: str) -> Any:
return proxy
+class _DeferredGenaiTypeMeta(type):
+ """Resolve enum members only when a deferred GenAI type is first used."""
+
+ def __call__(cls, *args: Any, **kwargs: Any) -> Any:
+ load_real = cls.__dict__["_load_real_module"]
+ target_name = cls.__dict__["_target_name"]
+ return getattr(load_real(), target_name)(*args, **kwargs)
+
+ def __getattr__(cls, attribute: str) -> Any:
+ if attribute.startswith("_"):
+ raise AttributeError(
+ f"type object {cls.__name__!r} has no attribute {attribute!r}"
+ )
+ load_real = cls.__dict__["_load_real_module"]
+ target_name = cls.__dict__["_target_name"]
+ return getattr(getattr(load_real(), target_name), attribute)
+
+
+def _install_lazy_genai_types() -> ModuleType:
+ """Defer generated Google GenAI models until an ADK request uses them."""
+
+ name = "google.genai.types"
+ existing = sys.modules.get(name)
+ if existing is not None:
+ return existing
+ parent_name, _, child_name = name.rpartition(".")
+ parent = sys.modules.get(parent_name)
+ parent_path = getattr(parent, "__path__", None)
+ if parent is None or parent_path is None:
+ raise ImportError(f"Unable to locate parent package {parent_name!r}")
+ spec = importlib.machinery.PathFinder.find_spec(name, parent_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Unable to locate module {name!r}")
+ loader = spec.loader
+ proxy = ModuleType(name)
+ proxy.__file__ = spec.origin
+ proxy.__loader__ = loader
+ proxy.__package__ = parent_name
+ proxy.__spec__ = spec
+ loaded: list[ModuleType] = []
+ load_lock = RLock()
+
+ def _load_real_module() -> ModuleType:
+ with load_lock:
+ if loaded:
+ return loaded[0]
+ real = importlib.util.module_from_spec(spec)
+ sys.modules[name] = real
+ setattr(parent, child_name, real)
+ try:
+ loader.exec_module(real)
+ except BaseException:
+ sys.modules[name] = proxy
+ setattr(parent, child_name, proxy)
+ raise
+ loaded.append(real)
+ return real
+
+ class _DeferredGenaiType(str, Enum):
+ TYPE_UNSPECIFIED = "TYPE_UNSPECIFIED"
+ STRING = "STRING"
+ NUMBER = "NUMBER"
+ INTEGER = "INTEGER"
+ BOOLEAN = "BOOLEAN"
+ ARRAY = "ARRAY"
+ OBJECT = "OBJECT"
+ NULL = "NULL"
+
+ _DeferredGenaiType.__name__ = "Type"
+ _DeferredGenaiType.__qualname__ = "Type"
+ _DeferredGenaiType.__module__ = name
+ setattr(proxy, "Type", _DeferredGenaiType)
+
+ def _deferred_type(target_name: str) -> type[Any]:
+ def _validate(value: Any) -> Any:
+ target = getattr(_load_real_module(), target_name)
+ try:
+ if isinstance(value, target):
+ return value
+ except TypeError:
+ pass
+ model_validate = getattr(target, "model_validate", None)
+ if callable(model_validate):
+ return model_validate(value)
+ from pydantic import TypeAdapter
+
+ return TypeAdapter(target).validate_python(value)
+
+ def _core_schema(
+ cls: type[Any],
+ source_type: Any,
+ handler: Any,
+ ) -> Any:
+ del cls, source_type, handler
+ from pydantic_core import core_schema
+
+ return core_schema.no_info_after_validator_function(
+ _validate,
+ core_schema.any_schema(),
+ )
+
+ def _json_schema(
+ cls: type[Any],
+ schema: Any,
+ handler: Any,
+ ) -> dict[str, Any]:
+ del cls, schema, handler
+ from pydantic import TypeAdapter
+
+ return TypeAdapter(getattr(_load_real_module(), target_name)).json_schema()
+
+ return _DeferredGenaiTypeMeta(
+ target_name,
+ (),
+ {
+ "__module__": name,
+ "_target_name": target_name,
+ "_load_real_module": staticmethod(_load_real_module),
+ "__get_pydantic_core_schema__": classmethod(_core_schema),
+ "__get_pydantic_json_schema__": classmethod(_json_schema),
+ },
+ )
+
+ def _load_attribute(attribute: str) -> Any:
+ if attribute.startswith("__"):
+ raise AttributeError(f"module {name!r} has no attribute {attribute!r}")
+ value = _deferred_type(attribute)
+ setattr(proxy, attribute, value)
+ return value
+
+ proxy.__getattr__ = _load_attribute # type: ignore[attr-defined]
+ proxy.__dict__["_load_real_module"] = _load_real_module
+ proxy.__dict__["_veadk_real_module_loaded"] = lambda: bool(loaded)
+ sys.modules[name] = proxy
+ setattr(parent, child_name, proxy)
+ return proxy
+
+
+def _install_studio_genai_imports() -> None:
+ """Keep generated GenAI models out of the Studio readiness path."""
+
+ package = _install_lazy_package(
+ "google.genai",
+ {
+ "Client": ("google.genai.client", "Client"),
+ "interactions": ("google.genai.interactions", None),
+ "types": ("google.genai.types", None),
+ "version": ("google.genai.version", None),
+ "__version__": ("google.genai.version", "__version__"),
+ },
+ )
+ types_module = _install_lazy_genai_types()
+ setattr(package, "types", types_module)
+
+
def _literal_assignment(module_name: str, assignment: str) -> Any:
parent_name, _, _child = module_name.rpartition(".")
parent = sys.modules.get(parent_name)
@@ -282,6 +439,7 @@ def _bootstrap_provider() -> None:
_bootstrap_provider()
+_install_studio_genai_imports()
_install_studio_adk_imports()
from veadk.cli.cli_frontend import studio # noqa: E402
From 172decd58aac68a45f4b3d8c82f67592de6d6788 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 17:47:09 +0800
Subject: [PATCH 35/46] perf(studio): defer genai telemetry models
---
tests/cli/test_studio_startup_imports.py | 21 +++++++
veadk/cli/studio_start.py | 75 ++++++++++++++++++++++++
2 files changed, 96 insertions(+)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 36600a8eb..0eaf475ee 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -662,6 +662,15 @@ def test_studio_fast_api_defers_genai_models_until_first_request() -> None:
if real_module_loaded is None or real_module_loaded():
raise SystemExit("Google GenAI models loaded while constructing Studio app")
+ genai_models = sys.modules["google.genai.models"]
+ real_models_module_loaded = getattr(
+ genai_models,
+ "_veadk_real_module_loaded",
+ None,
+ )
+ if real_models_module_loaded is None or real_models_module_loaded():
+ raise SystemExit("Google GenAI client loaded while constructing Studio app")
+
from google.adk.cli.api_server import RunAgentRequest
request = RunAgentRequest.model_validate(
@@ -692,6 +701,18 @@ def sample_tool(value: str) -> str:
"title": "Value",
"type": "string",
}
+
+ from google.adk.telemetry import _experimental_semconv
+ from google.adk.telemetry import tracing
+
+ assert isinstance(
+ tracing._instrumented_with_opentelemetry_instrumentation_google_genai(),
+ bool,
+ )
+ transformed = _experimental_semconv.transformers.t_contents("hello")
+ assert transformed[0].parts[0].text == "hello"
+ if not real_models_module_loaded():
+ raise SystemExit("Google GenAI client not loaded for real telemetry use")
"""
subprocess.run(
diff --git a/veadk/cli/studio_start.py b/veadk/cli/studio_start.py
index 7d1530aff..5eb84bb2a 100644
--- a/veadk/cli/studio_start.py
+++ b/veadk/cli/studio_start.py
@@ -250,6 +250,79 @@ def _load_attribute(attribute: str) -> Any:
return proxy
+def _install_lazy_genai_models() -> ModuleType:
+ """Defer the generated GenAI client until telemetry actually uses it."""
+
+ name = "google.genai.models"
+ existing = sys.modules.get(name)
+ if existing is not None:
+ return existing
+ parent_name, _, child_name = name.rpartition(".")
+ parent = sys.modules.get(parent_name)
+ parent_path = getattr(parent, "__path__", None)
+ if parent is None or parent_path is None:
+ raise ImportError(f"Unable to locate parent package {parent_name!r}")
+ spec = importlib.machinery.PathFinder.find_spec(name, parent_path)
+ if spec is None or spec.loader is None:
+ raise ImportError(f"Unable to locate module {name!r}")
+ loader = spec.loader
+ proxy = ModuleType(name)
+ proxy.__file__ = spec.origin
+ proxy.__loader__ = loader
+ proxy.__package__ = parent_name
+ proxy.__spec__ = spec
+ loaded: list[ModuleType] = []
+ load_lock = RLock()
+
+ def _load_real_module() -> ModuleType:
+ with load_lock:
+ if loaded:
+ return loaded[0]
+ real = importlib.util.module_from_spec(spec)
+ sys.modules[name] = real
+ setattr(parent, child_name, real)
+ try:
+ loader.exec_module(real)
+ except BaseException:
+ sys.modules[name] = proxy
+ setattr(parent, child_name, proxy)
+ raise
+ loaded.append(real)
+ return real
+
+ _DeferredModels = _DeferredGenaiTypeMeta(
+ "Models",
+ (),
+ {
+ "__module__": name,
+ "_target_name": "Models",
+ "_load_real_module": staticmethod(_load_real_module),
+ },
+ )
+
+ class _DeferredTransformers:
+ def __getattr__(self, attribute: str) -> Any:
+ if attribute.startswith("_"):
+ raise AttributeError(
+ f"object {type(self).__name__!r} has no attribute {attribute!r}"
+ )
+ return getattr(_load_real_module().t, attribute)
+
+ def _load_attribute(attribute: str) -> Any:
+ if attribute.startswith("__"):
+ raise AttributeError(f"module {name!r} has no attribute {attribute!r}")
+ return getattr(_load_real_module(), attribute)
+
+ setattr(proxy, "Models", _DeferredModels)
+ setattr(proxy, "t", _DeferredTransformers())
+ proxy.__getattr__ = _load_attribute # type: ignore[attr-defined]
+ proxy.__dict__["_load_real_module"] = _load_real_module
+ proxy.__dict__["_veadk_real_module_loaded"] = lambda: bool(loaded)
+ sys.modules[name] = proxy
+ setattr(parent, child_name, proxy)
+ return proxy
+
+
def _install_studio_genai_imports() -> None:
"""Keep generated GenAI models out of the Studio readiness path."""
@@ -265,6 +338,8 @@ def _install_studio_genai_imports() -> None:
)
types_module = _install_lazy_genai_types()
setattr(package, "types", types_module)
+ models_module = _install_lazy_genai_models()
+ setattr(package, "models", models_module)
def _literal_assignment(module_name: str, assignment: str) -> Any:
From 8eb55661e5440122815484583dfd62d22b5d1851 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 19:08:52 +0800
Subject: [PATCH 36/46] perf(studio): defer builtin tool execution runtime
---
frontend/server/studio_tools/__init__.py | 77 +-
.../studio_tools/builtin_declarations.py | 699 ++++++++++++++++++
frontend/server/studio_tools/local.py | 26 +-
frontend/server/studio_tools/registry.py | 2 +-
.../studio_tools/veadk_builtin_tools.py | 197 ++---
tests/cli/test_studio_startup_imports.py | 46 ++
.../test_deferred_veadk_builtin_tools.py | 12 +-
.../agentkit/studio_channel/__init__.py | 43 +-
8 files changed, 916 insertions(+), 186 deletions(-)
create mode 100644 frontend/server/studio_tools/builtin_declarations.py
diff --git a/frontend/server/studio_tools/__init__.py b/frontend/server/studio_tools/__init__.py
index f72c73c96..eb5a92043 100644
--- a/frontend/server/studio_tools/__init__.py
+++ b/frontend/server/studio_tools/__init__.py
@@ -14,40 +14,49 @@
"""Studio BFF-owned dynamic tools and the Runtime WebSocket bridge."""
-from frontend.server.studio_tools.codex_sandbox import (
- CodexSandboxConnection,
- CodexSandboxDelegate,
- register_codex_sandbox_tool,
-)
-from frontend.server.studio_tools.connector import (
- StudioChannelError,
- StudioToolRun,
- open_studio_tool_run,
- runtime_supports_bff_tools,
-)
-from frontend.server.studio_tools.local import (
- LocalStudioToolDispatcher,
- build_local_studio_tools,
- ensure_local_studio_toolset,
- local_progress_sse_event,
- stream_local_studio_response,
-)
-from frontend.server.studio_tools.registry import (
- StudioTool,
- StudioToolCatalogSnapshot,
- StudioToolExecutionContext,
- StudioToolRegistry,
- StudioToolRuntimeError,
- build_studio_tool_registry,
-)
-from frontend.server.studio_tools.sandbox_shell import (
- AgentkitEnvironmentSandboxResolver,
- SandboxExecutionTarget,
- SandboxResolutionError,
- SandboxTargetResolver,
- execute_in_sandbox,
- register_sandbox_shell_tool,
-)
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+
+_EXPORT_MODULES = {
+ "AgentkitEnvironmentSandboxResolver": "sandbox_shell",
+ "CodexSandboxConnection": "codex_sandbox",
+ "CodexSandboxDelegate": "codex_sandbox",
+ "LocalStudioToolDispatcher": "local",
+ "SandboxExecutionTarget": "sandbox_shell",
+ "SandboxResolutionError": "sandbox_shell",
+ "SandboxTargetResolver": "sandbox_shell",
+ "StudioChannelError": "connector",
+ "StudioTool": "registry",
+ "StudioToolCatalogSnapshot": "registry",
+ "StudioToolExecutionContext": "registry",
+ "StudioToolRegistry": "registry",
+ "StudioToolRun": "connector",
+ "StudioToolRuntimeError": "registry",
+ "build_local_studio_tools": "local",
+ "build_studio_tool_registry": "registry",
+ "ensure_local_studio_toolset": "local",
+ "execute_in_sandbox": "sandbox_shell",
+ "local_progress_sse_event": "local",
+ "open_studio_tool_run": "connector",
+ "register_codex_sandbox_tool": "codex_sandbox",
+ "register_sandbox_shell_tool": "sandbox_shell",
+ "runtime_supports_bff_tools": "connector",
+ "stream_local_studio_response": "local",
+}
+
+
+def __getattr__(name: str) -> Any:
+ module_name = _EXPORT_MODULES.get(name)
+ if module_name is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ value = getattr(module, name)
+ globals()[name] = value
+ return value
+
__all__ = [
"AgentkitEnvironmentSandboxResolver",
diff --git a/frontend/server/studio_tools/builtin_declarations.py b/frontend/server/studio_tools/builtin_declarations.py
new file mode 100644
index 000000000..dcdc7019c
--- /dev/null
+++ b/frontend/server/studio_tools/builtin_declarations.py
@@ -0,0 +1,699 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Cold-start-safe declarations for Studio's canonical VeADK built-in tools."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+BUILTIN_TOOL_DECLARATIONS: dict[str, tuple[str, dict[str, Any]]] = {
+ "coding": (
+ "Create code with the pre-configured OpenCode AgentKit sandbox.",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "timeout": {"default": 900, "title": "Timeout", "type": "integer"},
+ "workflow_prompt": {"title": "Workflow Prompt", "type": "string"},
+ },
+ "required": ["workflow_prompt"],
+ "title": "codingParams",
+ "type": "object",
+ },
+ ),
+ "get_city_weather": (
+ "Retrieves the weather information of a given city. the args "
+ "must in English",
+ {
+ "additionalProperties": False,
+ "properties": {"city": {"title": "City", "type": "string"}},
+ "required": ["city"],
+ "title": "get_city_weatherParams",
+ "type": "object",
+ },
+ ),
+ "get_location_weather": (
+ "Retrieves the weather information of a given city. the args "
+ "must in English",
+ {
+ "additionalProperties": False,
+ "properties": {"city": {"title": "City", "type": "string"}},
+ "required": ["city"],
+ "title": "get_location_weatherParams",
+ "type": "object",
+ },
+ ),
+ "image_edit": (
+ "Edit images in batch according to prompts and optional settings.\n"
+ "\n"
+ "Each item in `params` describes a single image-edit request.\n"
+ "\n"
+ "Args:\n"
+ " params (list[dict]):\n"
+ " A list of image editing requests. Each item supports:\n"
+ "\n"
+ " Required:\n"
+ " - origin_image (str):\n"
+ " The URL or Base64 string of the original image to "
+ "edit.\n"
+ " Example:\n"
+ ' * URL: "https://example.com/image.png"\n'
+ ' * Base64: "data:image/png;base64,"\n'
+ "\n"
+ " - prompt (str):\n"
+ " The textual description/instruction for editing the "
+ "image.\n"
+ " Supports English and Chinese.\n"
+ "\n"
+ " Optional:\n"
+ " - image_name (str):\n"
+ " Name/identifier for the generated image.\n"
+ "\n"
+ " - response_format (str):\n"
+ " Format of the returned image.\n"
+ ' * "url": JPEG link (default)\n'
+ ' * "b64_json": Base64 string in JSON\n'
+ "\n"
+ " - guidance_scale (float):\n"
+ " How strongly the prompt affects the result.\n"
+ " Range: [1.0, 10.0], default 2.5.\n"
+ "\n"
+ " - watermark (bool):\n"
+ " Whether to add watermark.\n"
+ " Default: True.\n"
+ "\n"
+ " - seed (int):\n"
+ " Random seed for reproducibility.\n"
+ " Range: [-1, 2^31-1], default -1 (random).\n"
+ "\n"
+ "Returns:\n"
+ " Dict: API response containing generated image metadata.\n"
+ " Example:\n"
+ " {\n"
+ ' "status": "success",\n'
+ ' "success_list": [{"image_name": ""}],\n'
+ ' "error_list": [{}]\n'
+ " }\n"
+ "\n"
+ "Notes:\n"
+ " - Uses SeedEdit 3.0 model.\n"
+ " - Provide the same `seed` for consistent outputs across runs.\n"
+ " - A high `guidance_scale` enforces stricter adherence to text "
+ "prompt.",
+ {
+ "additionalProperties": False,
+ "properties": {"params": {"items": {}, "title": "Params", "type": "array"}},
+ "required": ["params"],
+ "title": "image_editParams",
+ "type": "object",
+ },
+ ),
+ "image_generate": (
+ "Generate images with Seedream 4.0 / 4.5 / 5.0\n"
+ "\n"
+ "Commit batch image generation requests via tasks.\n"
+ "\n"
+ "Args:\n"
+ " tasks (list[dict]):\n"
+ " A list of image-generation tasks. Each task is a dict.\n"
+ " timeout (int)\n"
+ " The timeout limit for the image generation task request, "
+ "in seconds, with a default value of 600 seconds.\n"
+ " model_name (str):\n"
+ " Optional model name. If not specified, use the default "
+ "model from environment.\n"
+ " If during execution, this tool encounters a model-related "
+ "error (note that it must be a model-related error, otherwise do "
+ "not perform this action), such as `ModelNotOpen`,\n"
+ " then after reminding about the relevant issue, you can "
+ "execute this tool again and downgrade the model to the following "
+ "models, passing this parameter:\n"
+ " - `doubao-seedream-5-0-260128`\n"
+ " - `doubao-seedream-4-5-251128`\n"
+ " - `doubao-seedream-4-0-250828`\n"
+ "Per-task schema\n"
+ "---------------\n"
+ "Required:\n"
+ " - task_type (str):\n"
+ " One of:\n"
+ ' * "multi_image_to_group" # 多图生组图\n'
+ ' * "single_image_to_group" # 单图生组图\n'
+ ' * "text_to_group" # 文生组图\n'
+ ' * "multi_image_to_single" # 多图生单图\n'
+ ' * "single_image_to_single" # 单图生单图\n'
+ ' * "text_to_single" # 文生单图\n'
+ " - prompt (str)\n"
+ " Text description of the desired image(s). 中文/English 均可。\n"
+ ' 若要指定生成图片的数量,请在prompt中添加"生成N张图片",其中N为具体的数字。\n'
+ "Optional:\n"
+ " - size (str)\n"
+ " 指定生成图像的大小,有两种用法(二选一,不可混用):\n"
+ " 方式 1:分辨率级别\n"
+ ' 可选值: "1K", "2K", "4K"\n'
+ " 模型会结合 prompt 中的语义推断合适的宽高比、长宽。\n"
+ " 方式 2:具体宽高值\n"
+ ' 格式: "<宽度>x<高度>",如 "2048x2048", "2384x1728"\n'
+ " 约束:\n"
+ " * 总像素数范围: [1024x1024, 4096x4096]\n"
+ " * 宽高比范围: [1/16, 16]\n"
+ " 推荐值:\n"
+ " - 1:1 → 2048x2048\n"
+ " - 4:3 → 2384x1728\n"
+ " - 3:4 → 1728x2304\n"
+ " - 16:9 → 2560x1440\n"
+ " - 9:16 → 1440x2560\n"
+ " - 3:2 → 2496x1664\n"
+ " - 2:3 → 1664x2496\n"
+ " - 21:9 → 3024x1296\n"
+ ' 默认值: "2048x2048"\n'
+ " - response_format (str)\n"
+ ' Return format: "url" (default, URL 24h 过期) | "b64_json".\n'
+ " - watermark (bool)\n"
+ " Add watermark. Default: true.\n"
+ ' - image (str | list[str]) # 仅"非文生图"需要。文生图请不要提供 image\n'
+ " Reference image(s) as URL or Base64.\n"
+ ' * 生成"单图"的任务:传入 string(exactly 1 image)。\n'
+ ' * 生成"组图"的任务:传入 array(2–10 images)。\n'
+ " - sequential_image_generation (str)\n"
+ ' 控制是否生成"组图"。Default: "disabled".\n'
+ ' * 若要生成组图:必须设为 "auto"。\n'
+ " - max_images (int)\n"
+ " 仅当生成组图时生效。控制模型能生成的最多张数,范围 [1, 15], 不设置默认为15。\n"
+ " 注意这个参数不等于生成的图片数量,而是模型最多能生成的图片数量。\n"
+ " 在单图组图场景最多 14;多图组图场景需满足 (len(images)+max_images ≤ 15)。\n"
+ " - tools (list[dict])\n"
+ " 工具配置,用于增强生成能力。目前支持联网搜索工具。\n"
+ ' 格式: [{"type": "web_search"}]\n'
+ " 注意:仅文生图(text_to_single / text_to_group)场景支持此参数,\n"
+ " 图生图场景(包含 image 参数)不支持使用 tools。\n"
+ " - output_format (str)\n"
+ ' 输出图片格式。枚举值: "png", "jpeg"。\n'
+ ' 默认值: "jpeg"(不传时模型默认)。\n'
+ " 注意:仅 Seedream 5.0 支持该字段,存量模型指定会报错。\n"
+ "Model 行为说明(如何由参数推断模式)\n"
+ "---------------------------------\n"
+ '1) 文生单图: 不提供 image 且 (S 未设置或 S="disabled") → 1 张图。\n'
+ '2) 文生组图: 不提供 image 且 S="auto" → 组图,数量由 max_images 控制。\n'
+ '3) 单图生单图: image=string 且 (S 未设置或 S="disabled") → 1 张图。\n'
+ '4) 单图生组图: image=string 且 S="auto" → 组图,数量 ≤14。\n'
+ '5) 多图生单图: image=array (2–10) 且 (S 未设置或 S="disabled") → 1 张图。\n'
+ '6) 多图生组图: image=array (2–10) 且 S="auto" → 组图,需满足总数 ≤15。\n'
+ "返回结果\n"
+ "--------\n"
+ " Dict with generation summary.\n"
+ " Example:\n"
+ " {\n"
+ ' "status": "success",\n'
+ ' "success_list": [\n'
+ ' {"image_name": "url"}\n'
+ " ],\n"
+ ' "error_list": ["image_name"],\n'
+ ' "error_detail_list": [\n'
+ ' {"task_idx": 0, "error": {"code": "InvalidParameter", '
+ '"message": "..."}}\n'
+ " ]\n"
+ " }\n"
+ "Notes:\n"
+ '- 组图任务必须 sequential_image_generation="auto"。\n'
+ '- 如果想要指定生成组图的数量,请在prompt里添加数量说明,例如:"生成3张图片"。\n'
+ "- size 推荐使用 2048x2048 或表格里的标准比例,确保生成质量。",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "model_name": {
+ "default": None,
+ "title": "Model Name",
+ "type": "string",
+ },
+ "tasks": {
+ "items": {"additionalProperties": True, "type": "object"},
+ "title": "Tasks",
+ "type": "array",
+ },
+ "timeout": {"default": 600, "title": "Timeout", "type": "integer"},
+ },
+ "required": ["tasks"],
+ "title": "image_generateParams",
+ "type": "object",
+ },
+ ),
+ "link_reader": (
+ "Use this tool when you need to fetch content from web pages, PDFs, "
+ "or Douyin videos.\n"
+ "It retrieves the title and main content from the provided URLs.\n"
+ "\n"
+ 'Examples: {"url_list": ["abc.com", "xyz.com"]}\n'
+ "Args:\n"
+ " url_list (list[str]): A list of URLs to parse (maximum 3).\n"
+ "Returns:\n"
+ " list[dict]: A list of dictionaries, each containing the title "
+ "and content of the corresponding URL.",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "url_list": {
+ "items": {"type": "string"},
+ "title": "Url List",
+ "type": "array",
+ }
+ },
+ "required": ["url_list"],
+ "title": "link_readerParams",
+ "type": "object",
+ },
+ ),
+ "parallel_web_search": (
+ "Search queries from websites in parallel.\n"
+ "\n"
+ "Args:\n"
+ " queries: The queries to search. Each query will be "
+ "searched in parallel.\n"
+ "\n"
+ "Returns:\n"
+ " A dict of query to result documents.",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "queries": {
+ "items": {"type": "string"},
+ "title": "Queries",
+ "type": "array",
+ }
+ },
+ "required": ["queries"],
+ "title": "parallel_web_searchParams",
+ "type": "object",
+ },
+ ),
+ "ppt_generate": (
+ "Create a real PPTX file and attach it to the current conversation.\n"
+ "\n"
+ "Plan the complete deck before calling this tool. ``deck_markdown`` "
+ "uses a\n"
+ "simple flat format to avoid complex nested arguments: start every "
+ "content\n"
+ "slide with ``## Slide title``; add one plain-text summary line "
+ "followed by\n"
+ "3-7 ``- bullet`` lines. Add an optional ``Sources: URL | URL`` line "
+ "when\n"
+ "external claims or assets are used. The title slide is added "
+ "automatically.\n"
+ "\n"
+ "Args:\n"
+ " title: Audience-facing deck title.\n"
+ " deck_markdown: Ordered content slides in the flat Markdown "
+ "format.\n"
+ " tool_context: Current ADK tool context used to save the "
+ "result.\n"
+ " subtitle: Optional title-slide subtitle.\n"
+ " theme: Visual theme: blue, dark, warm, or green.\n"
+ " filename: Download filename ending in .pptx.\n"
+ "\n"
+ "Returns:\n"
+ " Metadata for the saved PowerPoint artifact.",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "deck_markdown": {"title": "Deck Markdown", "type": "string"},
+ "filename": {
+ "default": "presentation.pptx",
+ "title": "Filename",
+ "type": "string",
+ },
+ "subtitle": {"default": "", "title": "Subtitle", "type": "string"},
+ "theme": {"default": "blue", "title": "Theme", "type": "string"},
+ "title": {"title": "Title", "type": "string"},
+ },
+ "required": ["title", "deck_markdown"],
+ "title": "ppt_generateParams",
+ "type": "object",
+ },
+ ),
+ "run_code": (
+ "Run code in a code sandbox and return the output.\n"
+ "For C++ code, don't execute it directly, compile and execute via "
+ "Python; write sources and object files to /tmp.\n"
+ "\n"
+ "Args:\n"
+ " code (str): The code to run.\n"
+ " language (str): The execution language. Use ``python3`` for code or "
+ "``bash`` for shell scripts.\n"
+ " timeout (int, optional): The timeout in seconds for the code "
+ "execution.\n"
+ " Defaults to 300 and must be between 1 and 300 seconds.\n"
+ " exec_dir (str, optional): Working directory for Bash execution. "
+ "Defaults to ``/tmp``.\n"
+ " env (dict[str, str], optional): Environment variables for Bash "
+ "execution.\n"
+ " hard_timeout (int, optional): Hard timeout for Bash execution. "
+ "Defaults\n"
+ " to 300 and must be between 1 and 300 seconds.\n"
+ " max_output_length (int, optional): Maximum Bash output length. "
+ "Defaults to 30000.\n"
+ "\n"
+ "Returns:\n"
+ " str: The output of the code execution.",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "code": {"title": "Code", "type": "string"},
+ "env": {
+ "anyOf": [
+ {"additionalProperties": {"type": "string"}, "type": "object"},
+ {"type": "null"},
+ ],
+ "default": None,
+ "title": "Env",
+ },
+ "exec_dir": {"default": "/tmp", "title": "Exec Dir", "type": "string"},
+ "hard_timeout": {
+ "default": 300,
+ "title": "Hard Timeout",
+ "type": "integer",
+ },
+ "language": {"title": "Language", "type": "string"},
+ "max_output_length": {
+ "default": 30000,
+ "title": "Max Output Length",
+ "type": "integer",
+ },
+ "timeout": {"default": 300, "title": "Timeout", "type": "integer"},
+ },
+ "required": ["code", "language"],
+ "title": "run_codeParams",
+ "type": "object",
+ },
+ ),
+ "text_to_speech": (
+ "TTS provides users with the ability to convert text to speech, "
+ "turning the text content of LLM into audio.\n"
+ "Use this tool when you need to convert text content into audible "
+ "speech.\n"
+ "It transforms plain text into natural-sounding speech, as well as "
+ "exporting the generated audio in pcm format.\n"
+ "\n"
+ "Args:\n"
+ " text: The text to convert.\n"
+ "\n"
+ "Returns:\n"
+ " A dict with the saved audio path.",
+ {
+ "additionalProperties": False,
+ "properties": {"text": {"title": "Text", "type": "string"}},
+ "required": ["text"],
+ "title": "text_to_speechParams",
+ "type": "object",
+ },
+ ),
+ "vesearch": (
+ "Search information from Internet, social media, news sites, etc.\n"
+ "\n"
+ "Args:\n"
+ " query: The query string to search.\n"
+ "\n"
+ "Returns:\n"
+ " Summarized search results.",
+ {
+ "additionalProperties": False,
+ "properties": {"query": {"title": "Query", "type": "string"}},
+ "required": ["query"],
+ "title": "vesearchParams",
+ "type": "object",
+ },
+ ),
+ "video_generate": (
+ "Generate videos in batch from text prompts, with support for "
+ "multiple input modes:\n"
+ "text-to-video, image-to-video (first/last frame), and multimodal "
+ "reference generation.\n"
+ "\n"
+ "This API creates video-generation tasks asynchronously. Each item "
+ "in `params` describes\n"
+ "a single video generation request. The function submits all items "
+ "and polls for results.\n"
+ "\n"
+ "If polling times out, the task_id will be returned so you can "
+ "query the status later\n"
+ "using the video_task_query tool.\n"
+ "\n"
+ "Args:\n"
+ " params (list[dict]):\n"
+ " A list of video generation requests. Each item is a dict "
+ "with the following fields.\n"
+ "\n"
+ " Required per item:\n"
+ " - video_name (str):\n"
+ " Name/identifier of the output video file.\n"
+ "\n"
+ " - prompt (str):\n"
+ " Text describing the video to generate. Supports "
+ "Chinese and English.\n"
+ " For multimodal reference generation, use [图1], "
+ "[图2], [视频1], [音频1]\n"
+ " to reference specific input materials in your "
+ "prompt.\n"
+ "\n"
+ " Optional per item - Input Materials:\n"
+ " - first_frame (str):\n"
+ " URL for the first frame image (role = "
+ "first_frame).\n"
+ " Use when you want the video to start from a "
+ "specific image.\n"
+ "\n"
+ " - last_frame (str):\n"
+ " URL for the last frame image (role = "
+ "last_frame).\n"
+ " Use when you want the video to end on a specific "
+ "image.\n"
+ "\n"
+ " - reference_images (list[str]):\n"
+ " 1-4 reference image URLs for style/content "
+ "guidance (role = reference_image).\n"
+ " The model extracts features from these images and "
+ "applies them to the output.\n"
+ " Use [图1], [图2], etc. in prompt to reference "
+ "specific images.\n"
+ "\n"
+ " - reference_videos (list[str]):\n"
+ " 0-3 reference video URLs for multimodal "
+ "generation (role = reference_video).\n"
+ " Video constraints: mp4/mov format, 2-15s duration "
+ "per video,\n"
+ " total duration <= 15s, size <= 50MB, 24-60 FPS.\n"
+ " Use [视频1], [视频2], etc. in prompt to reference "
+ "specific videos.\n"
+ "\n"
+ " - reference_audios (list[str]):\n"
+ " 0-3 reference audio URLs for multimodal "
+ "generation (role = reference_audio).\n"
+ " Audio constraints: mp3/wav format, 2-15s duration "
+ "per audio,\n"
+ " total duration <= 15s, size <= 15MB.\n"
+ " Use [音频1], [音频2], etc. in prompt to reference "
+ "specific audios.\n"
+ " Note: Audio cannot be used alone; must have at "
+ "least one image or video.\n"
+ "\n"
+ " Optional per item - Video Output Parameters:\n"
+ " - ratio (str):\n"
+ ' Aspect ratio. Options: "16:9" (default), "9:16", '
+ '"4:3", "3:4", "1:1",\n'
+ ' "2:1", "21:9", "adaptive" (auto-select based on '
+ "input).\n"
+ " Note: Reference image scenarios do not support "
+ "all ratios.\n"
+ "\n"
+ " - duration (int):\n"
+ " Video length in seconds. Range: 2-12s depending "
+ "on model.\n"
+ " - Seedance 1.5 pro: 4-12s\n"
+ " - Seedance 1.0 pro: 2-12s\n"
+ " - Seedance 1.0 pro-fast: 2-12s\n"
+ "\n"
+ " - resolution (str):\n"
+ ' Video resolution. Options: "480p", "720p", '
+ '"1080p".\n'
+ " Default varies by model (e.g., Seedance 1.0 pro "
+ "defaults to 1080p).\n"
+ " Note: Reference image scenarios do not support "
+ "resolution parameter.\n"
+ "\n"
+ " - frames (int):\n"
+ " Total frame count. Must be in [29, 289] and "
+ "follow format 25 + 4n.\n"
+ " Alternative to duration for controlling video "
+ "length.\n"
+ "\n"
+ " - camera_fixed (bool):\n"
+ " Lock camera movement. true = fixed camera, false "
+ "= allow movement.\n"
+ " Default: false. Note: Not supported in reference "
+ "image scenarios.\n"
+ "\n"
+ " - seed (int):\n"
+ " Random seed for reproducibility. Range: [-1, "
+ "2^32-1].\n"
+ " Default: -1 (auto seed). Same seed may yield "
+ "similar results.\n"
+ "\n"
+ " - watermark (bool):\n"
+ " Whether to add watermark. Default: false.\n"
+ "\n"
+ " - generate_audio (bool):\n"
+ " Whether to generate audio. Only Seedance 1.5 pro "
+ "supports this.\n"
+ " If True, audio (ambi",
+ {
+ "additionalProperties": False,
+ "properties": {
+ "batch_size": {"default": 10, "title": "Batch Size", "type": "integer"},
+ "max_wait_seconds": {
+ "default": 1200,
+ "title": "Max Wait Seconds",
+ "type": "integer",
+ },
+ "model_name": {
+ "default": "doubao-seedance-2-0-260128",
+ "title": "Model Name",
+ "type": "string",
+ },
+ "params": {"items": {}, "title": "Params", "type": "array"},
+ },
+ "required": ["params"],
+ "title": "video_generateParams",
+ "type": "object",
+ },
+ ),
+ "video_task_query": (
+ "Query the status of a video generation task.\n"
+ "\n"
+ "Use this tool to check the status of a previously submitted "
+ "video generation task.\n"
+ "If the task is completed, the video URL will be returned.\n"
+ "\n"
+ "Args:\n"
+ " task_id (str):\n"
+ " The task ID returned from video_generate when the task "
+ "was submitted.\n"
+ ' Format: "cgt-xxxxxxxxxxxx-xxxxx"\n'
+ "\n"
+ " tool_context (ToolContext):\n"
+ " The tool context provided by the ADK framework.\n"
+ "\n"
+ "Returns:\n"
+ " Dict:\n"
+ " {\n"
+ ' "task_id": "cgt-xxxxxxxxxxxx-xxxxx",\n'
+ ' "status": "succeeded" | "running" | "failed" | '
+ '"queued",\n'
+ ' "video_url": "https://..." | None,\n'
+ ' "error": {...} | None,\n'
+ ' "model": "doubao-seedance-x-x",\n'
+ ' "created_at": timestamp,\n'
+ ' "updated_at": timestamp,\n'
+ ' "execution_expires_after": seconds\n'
+ " }\n"
+ "\n"
+ "Status Values:\n"
+ " - queued: Task is waiting in queue\n"
+ " - running: Task is being processed\n"
+ " - succeeded: Task completed, video_url available\n"
+ " - failed: Task failed, check error field\n"
+ "\n"
+ "Example:\n"
+ " # Query a task status\n"
+ ' result = await video_task_query("cgt-20260222165751-wsnw8", '
+ "tool_context)\n"
+ ' if result["status"] == "succeeded":\n'
+ " print(f\"Video ready: {result['video_url']}\")\n"
+ ' elif result["status"] == "running":\n'
+ ' print("Still processing, please wait...")\n'
+ ' elif result["status"] == "failed":\n'
+ " print(f\"Task failed: {result['error']}\")",
+ {
+ "additionalProperties": False,
+ "properties": {"task_id": {"title": "Task Id", "type": "string"}},
+ "required": ["task_id"],
+ "title": "video_task_queryParams",
+ "type": "object",
+ },
+ ),
+ "web_fetch": (
+ "Fetch a web page over HTTP(S) and return its readable main content.\n"
+ "\n"
+ "Performs a plain HTTP GET (no JavaScript execution) and extracts the "
+ "page's\n"
+ "readable text. Handles HTML pages (converted to markdown/text) and "
+ "**PDF**\n"
+ "URLs (text extracted via pypdf). Follows HTTP and `` "
+ "redirects.\n"
+ "Use it to read articles, docs, or any public URL the user references. "
+ "For\n"
+ "pages that require login or render entirely via JavaScript, the "
+ "content may\n"
+ "be incomplete.\n"
+ "\n"
+ "Args:\n"
+ " url: The http(s) URL to fetch.\n"
+ ' extract_mode: "markdown" (default, keeps headings/links/lists) or '
+ '"text"\n'
+ " (plain text with markdown decoration removed).\n"
+ " max_chars: Truncate the extracted content to at most this many "
+ "characters.\n"
+ "\n"
+ "Returns:\n"
+ ' A dict with keys: "url" (final URL after redirects), "title", '
+ '"content",\n'
+ ' and "truncated" (bool). On failure, a dict with an "error" key.',
+ {
+ "additionalProperties": False,
+ "properties": {
+ "extract_mode": {
+ "default": "markdown",
+ "title": "Extract Mode",
+ "type": "string",
+ },
+ "max_chars": {
+ "default": 50000,
+ "title": "Max Chars",
+ "type": "integer",
+ },
+ "url": {"title": "Url", "type": "string"},
+ },
+ "required": ["url"],
+ "title": "web_fetchParams",
+ "type": "object",
+ },
+ ),
+ "web_search": (
+ "Search a query in websites.\n"
+ "\n"
+ "Args:\n"
+ " query: The query to search.\n"
+ "\n"
+ "Returns:\n"
+ " A list of result documents.",
+ {
+ "additionalProperties": False,
+ "properties": {"query": {"title": "Query", "type": "string"}},
+ "required": ["query"],
+ "title": "web_searchParams",
+ "type": "object",
+ },
+ ),
+}
+
+
+__all__ = ["BUILTIN_TOOL_DECLARATIONS"]
diff --git a/frontend/server/studio_tools/local.py b/frontend/server/studio_tools/local.py
index 7b937ee2f..f499efe38 100644
--- a/frontend/server/studio_tools/local.py
+++ b/frontend/server/studio_tools/local.py
@@ -21,24 +21,20 @@
import logging
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from dataclasses import replace
-from typing import Any
+from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
-from google.adk.tools.base_tool import BaseTool
-
from frontend.server.studio_tools.registry import (
StudioToolCatalogSnapshot,
StudioToolExecutionContext,
StudioToolExecutionError,
StudioToolRuntimeError,
)
-from veadk.integrations.agentkit.studio_channel import (
- StudioExternalToolset,
- StudioRemoteTool,
- bind_studio_tools,
-)
from veadk.integrations.agentkit.studio_channel.protocol import StudioToolManifest
+if TYPE_CHECKING:
+ from google.adk.tools.base_tool import BaseTool
+
logger = logging.getLogger(__name__)
ProgressReporter = Callable[[dict[str, Any]], Awaitable[None]]
@@ -142,6 +138,8 @@ def build_local_studio_tools(
) -> tuple[BaseTool, ...]:
"""Build immutable model-visible wrappers for one local Agent run."""
+ from veadk.integrations.agentkit.studio_channel.tool import StudioRemoteTool
+
dispatcher = LocalStudioToolDispatcher(
catalog=catalog,
context=context,
@@ -165,6 +163,8 @@ def ensure_local_studio_toolset(
) -> None:
"""Attach the run-scoped Studio toolset to a cached local ADK runner."""
+ from veadk.integrations.agentkit.studio_channel.tool import StudioExternalToolset
+
app = getattr(runner, "app", None)
root_agent = getattr(app, "root_agent", None)
tools = getattr(root_agent, "tools", None)
@@ -223,14 +223,20 @@ async def stream_local_studio_response(
) -> AsyncIterator[bytes | str]:
"""Merge direct tool progress into the local ADK SSE response."""
+ from veadk.integrations.agentkit.studio_channel.tool import bind_studio_tools
+
source_task: asyncio.Task[bytes | str] | None = None
progress_task: asyncio.Task[bytes] | None = None
iterator = source.__aiter__()
+
+ async def next_source() -> bytes | str:
+ return await anext(iterator)
+
try:
with bind_studio_tools(tools):
while True:
if source_task is None:
- source_task = asyncio.create_task(anext(iterator))
+ source_task = asyncio.create_task(next_source())
if progress_task is None:
progress_task = asyncio.create_task(progress_events.get())
done, _ = await asyncio.wait(
@@ -260,7 +266,7 @@ async def stream_local_studio_response(
)
aclose = getattr(iterator, "aclose", None)
if callable(aclose):
- await aclose()
+ await cast(Callable[[], Awaitable[None]], aclose)()
__all__ = [
diff --git a/frontend/server/studio_tools/registry.py b/frontend/server/studio_tools/registry.py
index 50f145210..e7df4559c 100644
--- a/frontend/server/studio_tools/registry.py
+++ b/frontend/server/studio_tools/registry.py
@@ -28,7 +28,7 @@
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError
-from veadk.integrations.agentkit.studio_channel import (
+from veadk.integrations.agentkit.studio_channel.protocol import (
StudioToolManifest,
catalog_revision,
)
diff --git a/frontend/server/studio_tools/veadk_builtin_tools.py b/frontend/server/studio_tools/veadk_builtin_tools.py
index 75d0ddfe1..9d391d6ae 100644
--- a/frontend/server/studio_tools/veadk_builtin_tools.py
+++ b/frontend/server/studio_tools/veadk_builtin_tools.py
@@ -18,26 +18,44 @@
import asyncio
import copy
-import inspect
from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from urllib.parse import quote
-from google.adk.agents import Agent
-from google.adk.agents.invocation_context import InvocationContext
-from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
-from google.adk.sessions import InMemorySessionService, Session
-from google.adk.tools.function_tool import FunctionTool
-from google.adk.tools.tool_context import ToolContext
-
+from frontend.server.studio_tools.builtin_declarations import (
+ BUILTIN_TOOL_DECLARATIONS,
+)
from frontend.server.studio_tools.registry import (
StudioTool,
StudioToolExecutionContext,
StudioToolRegistry,
)
-from veadk.multimodal.service import MediaService
-from veadk.tools import get_builtin_tool, list_builtin_tools
+
+if TYPE_CHECKING:
+ from veadk.multimodal.service import MediaService
+
+
+def FunctionTool(*args: Any, **kwargs: Any) -> Any: # noqa: N802
+ """Construct ADK's adapter only when a built-in tool is first invoked."""
+
+ from google.adk.tools.function_tool import FunctionTool as _FunctionTool
+
+ return _FunctionTool(*args, **kwargs)
+
+
+def get_builtin_tool(name: str) -> Any:
+ """Resolve a canonical VeADK callable only for an actual tool call."""
+
+ from veadk.tools import get_builtin_tool as _get_builtin_tool
+
+ return _get_builtin_tool(name)
+
+
+def list_builtin_tools() -> list[str]:
+ """Return the declaration-locked built-in catalog without loading VeADK tools."""
+
+ return sorted(BUILTIN_TOOL_DECLARATIONS)
_DISPLAY_NAMES = {
@@ -79,128 +97,54 @@
"web_search",
}
-_DEFERRED_BUILTIN_DECLARATIONS: dict[str, tuple[str, dict[str, Any]]] = {
- "link_reader": (
- inspect.cleandoc(
- """
- Use this tool when you need to fetch content from web pages, PDFs, or Douyin videos.
- It retrieves the title and main content from the provided URLs.
-
- Examples: {"url_list": ["abc.com", "xyz.com"]}
- Args:
- url_list (list[str]): A list of URLs to parse (maximum 3).
- Returns:
- list[dict]: A list of dictionaries, each containing the title and content of the corresponding URL.
- """
- ),
- {
- "properties": {
- "url_list": {
- "items": {"type": "string"},
- "title": "Url List",
- "type": "array",
- }
- },
- "required": ["url_list"],
- "title": "link_readerParams",
- "type": "object",
- },
- ),
- "image_edit": (
- inspect.cleandoc(
- """
- Edit images in batch according to prompts and optional settings.
-
- Each item in `params` describes a single image-edit request.
-
- Args:
- params (list[dict]):
- A list of image editing requests. Each item supports:
-
- Required:
- - origin_image (str):
- The URL or Base64 string of the original image to edit.
- Example:
- * URL: "https://example.com/image.png"
- * Base64: "data:image/png;base64,"
-
- - prompt (str):
- The textual description/instruction for editing the image.
- Supports English and Chinese.
-
- Optional:
- - image_name (str):
- Name/identifier for the generated image.
-
- - response_format (str):
- Format of the returned image.
- * "url": JPEG link (default)
- * "b64_json": Base64 string in JSON
-
- - guidance_scale (float):
- How strongly the prompt affects the result.
- Range: [1.0, 10.0], default 2.5.
-
- - watermark (bool):
- Whether to add watermark.
- Default: True.
-
- - seed (int):
- Random seed for reproducibility.
- Range: [-1, 2^31-1], default -1 (random).
-
- Returns:
- Dict: API response containing generated image metadata.
- Example:
- {
- "status": "success",
- "success_list": [{"image_name": ""}],
- "error_list": [{}]
- }
-
- Notes:
- - Uses SeedEdit 3.0 model.
- - Provide the same `seed` for consistent outputs across runs.
- - A high `guidance_scale` enforces stricter adherence to text prompt.
- """
- ),
- {
- "properties": {
- "params": {
- "items": {},
- "title": "Params",
- "type": "array",
- }
- },
- "required": ["params"],
- "title": "image_editParams",
- "type": "object",
- },
- ),
-}
+_DEFERRED_BUILTIN_DECLARATIONS = BUILTIN_TOOL_DECLARATIONS
@dataclass
class _BuiltinExecutionHost:
"""Own the BFF-local ADK context needed by existing tool callables."""
- session_service: InMemorySessionService = field(
- default_factory=InMemorySessionService
- )
- artifact_service: InMemoryArtifactService = field(
- default_factory=InMemoryArtifactService
- )
+ session_service: Any | None = None
+ artifact_service: Any | None = None
media_service: MediaService | None = None
- agent: Agent = field(default_factory=lambda: Agent(name="studio_bff_agent"))
+ agent: Any | None = None
states: dict[str, dict[str, Any]] = field(default_factory=dict)
locks: dict[str, asyncio.Lock] = field(default_factory=dict)
+ initialization_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+
+ async def _ensure_runtime(self) -> None:
+ if self.session_service is not None:
+ return
+ async with self.initialization_lock:
+ if self.session_service is not None:
+ return
+ from google.adk.agents import Agent
+ from google.adk.artifacts.in_memory_artifact_service import (
+ InMemoryArtifactService,
+ )
+ from google.adk.sessions import InMemorySessionService
+
+ self.session_service = InMemorySessionService()
+ self.artifact_service = InMemoryArtifactService()
+ self.agent = Agent(name="studio_bff_agent")
async def execute(
self,
- function_tool: FunctionTool,
+ function_tool: Any,
arguments: dict[str, Any],
context: StudioToolExecutionContext,
) -> Any:
+ await self._ensure_runtime()
+ from google.adk.agents.invocation_context import InvocationContext
+ from google.adk.sessions import Session
+ from google.adk.tools.tool_context import ToolContext
+
+ session_service = self.session_service
+ artifact_service = self.artifact_service
+ agent = self.agent
+ if session_service is None or artifact_service is None or agent is None:
+ raise RuntimeError("Studio built-in execution runtime is unavailable")
+
lock = self.locks.setdefault(context.scope_id, asyncio.Lock())
async with lock:
session = Session(
@@ -210,10 +154,10 @@ async def execute(
state=dict(self.states.get(context.scope_id, {})),
)
invocation_context = InvocationContext(
- artifact_service=self.artifact_service,
- session_service=self.session_service,
+ artifact_service=artifact_service,
+ session_service=session_service,
invocation_id=context.run_id,
- agent=self.agent,
+ agent=agent,
session=session,
)
tool_context = ToolContext(
@@ -237,16 +181,19 @@ async def execute(
async def _publish_artifacts(
self,
- tool_context: ToolContext,
+ tool_context: Any,
context: StudioToolExecutionContext,
) -> list[dict[str, Any]]:
"""Make ADK artifacts produced in BFF execution available to Studio."""
if self.media_service is None:
return []
+ artifact_service = self.artifact_service
+ if artifact_service is None:
+ raise RuntimeError("Studio built-in artifact runtime is unavailable")
published: list[dict[str, Any]] = []
for filename, version in tool_context.actions.artifact_delta.items():
- artifact = await self.artifact_service.load_artifact(
+ artifact = await artifact_service.load_artifact(
app_name=context.app_name,
user_id=context.user_id,
session_id=context.session_id,
@@ -288,7 +235,7 @@ async def _publish_artifacts(
return published
-def _schema(function_tool: FunctionTool) -> tuple[str, dict[str, Any]]:
+def _schema(function_tool: Any) -> tuple[str, dict[str, Any]]:
declaration = function_tool._get_declaration()
if declaration is None:
raise ValueError(f"Built-in tool has no declaration: {function_tool.name}")
@@ -315,7 +262,7 @@ def register_veadk_builtin_tools(
"""Expose the existing VeADK built-ins through the Studio-owned channel."""
host = _BuiltinExecutionHost(media_service=media_service)
- resolved_tools: dict[str, FunctionTool] = {}
+ resolved_tools: dict[str, Any] = {}
for name in list_builtin_tools():
if name in _DEFERRED_BUILTIN_DECLARATIONS:
description, input_schema = _deferred_schema(name)
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 0eaf475ee..261edb52f 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -183,6 +183,52 @@ def test_studio_tools_defer_builtin_model_clients_until_first_call() -> None:
)
+def test_studio_tool_catalog_defers_builtin_execution_runtime() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+import veadk.cli.studio_start
+from frontend.server.studio_tools.registry import build_studio_tool_registry
+
+registry = build_studio_tool_registry()
+registered = {manifest["name"] for manifest in registry.manifests()}
+required = {
+ "coding",
+ "image_generate",
+ "run_code",
+ "video_generate",
+ "web_search",
+}
+if not required <= registered:
+ raise SystemExit("Studio built-in catalog is incomplete")
+
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "google.adk.auth"
+ or name.startswith("google.adk.auth.")
+ or name == "google.adk.agents"
+ or name.startswith("google.adk.agents.")
+ or name == "google.adk.tools"
+ or name.startswith("google.adk.tools.")
+ or name == "veadk.tools.builtin_tools._agentkit"
+ or name == "veadk.tools.builtin_tools.create_agent"
+ or name.startswith("veadk.tools.builtin_tools.create_agent.")
+)
+if unexpected:
+ raise SystemExit("built-in execution runtime loaded while building Studio catalog")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_frontend_branding_defers_optional_logo_network_stack() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
index 64533cf76..e86744838 100644
--- a/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
+++ b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
@@ -18,6 +18,7 @@
from typing import Any, cast
import pytest
+from veadk.tools import list_builtin_tools as list_canonical_builtin_tools
from frontend.server.studio_tools import veadk_builtin_tools
from frontend.server.studio_tools.registry import (
@@ -38,7 +39,16 @@ def _context() -> StudioToolExecutionContext:
)
-@pytest.mark.parametrize("name", ["image_edit", "link_reader"])
+def test_deferred_builtin_catalog_matches_canonical_names() -> None:
+ assert set(veadk_builtin_tools._DEFERRED_BUILTIN_DECLARATIONS) == set(
+ list_canonical_builtin_tools()
+ )
+
+
+@pytest.mark.parametrize(
+ "name",
+ sorted(veadk_builtin_tools._DEFERRED_BUILTIN_DECLARATIONS),
+)
def test_deferred_builtin_declaration_matches_canonical_tool(name: str) -> None:
function_tool = veadk_builtin_tools.FunctionTool(
cast(Callable[..., Any], veadk_builtin_tools.get_builtin_tool(name))
diff --git a/veadk/integrations/agentkit/studio_channel/__init__.py b/veadk/integrations/agentkit/studio_channel/__init__.py
index 5926246d0..0094ed400 100644
--- a/veadk/integrations/agentkit/studio_channel/__init__.py
+++ b/veadk/integrations/agentkit/studio_channel/__init__.py
@@ -14,21 +14,34 @@
"""Runtime half of Studio's reverse WebSocket tool channel."""
-from veadk.integrations.agentkit.studio_channel.protocol import (
- PROTOCOL_VERSION,
- CatalogSnapshot,
- StudioToolManifest,
- catalog_revision,
-)
-from veadk.integrations.agentkit.studio_channel.routes import (
- StudioChannelRunHandler,
- mount_studio_channel_routes,
-)
-from veadk.integrations.agentkit.studio_channel.tool import (
- StudioExternalToolset,
- StudioRemoteTool,
- bind_studio_tools,
-)
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+
+_EXPORT_MODULES = {
+ "PROTOCOL_VERSION": "protocol",
+ "CatalogSnapshot": "protocol",
+ "StudioChannelRunHandler": "routes",
+ "StudioExternalToolset": "tool",
+ "StudioRemoteTool": "tool",
+ "StudioToolManifest": "protocol",
+ "bind_studio_tools": "tool",
+ "catalog_revision": "protocol",
+ "mount_studio_channel_routes": "routes",
+}
+
+
+def __getattr__(name: str) -> Any:
+ module_name = _EXPORT_MODULES.get(name)
+ if module_name is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ value = getattr(module, name)
+ globals()[name] = value
+ return value
+
__all__ = [
"PROTOCOL_VERSION",
From 40f6e2f5ac3cc79863c584f6cee0070abf9de72c Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 20:13:48 +0800
Subject: [PATCH 37/46] perf(studio): defer request-only runtimes
---
frontend/server/feishu_bot_setup/service.py | 32 +++-
.../studio_tools/builtin_declarations.py | 6 +-
frontend/server/video/routes.py | 108 ++++++++---
tests/cli/test_studio_startup_imports.py | 117 ++++++++++++
.../frontend/server/test_feishu_bot_setup.py | 5 +-
veadk/cli/cli_frontend.py | 13 +-
veadk/cli/studio_self_update_bootstrap.py | 175 ++++++++++++++++++
7 files changed, 412 insertions(+), 44 deletions(-)
create mode 100644 veadk/cli/studio_self_update_bootstrap.py
diff --git a/frontend/server/feishu_bot_setup/service.py b/frontend/server/feishu_bot_setup/service.py
index 3d651fda2..0185c6786 100644
--- a/frontend/server/feishu_bot_setup/service.py
+++ b/frontend/server/feishu_bot_setup/service.py
@@ -141,9 +141,33 @@ def _payload(
return payload
-def create_feishu_bot_setup_service() -> FeishuBotSetupService:
- """Create the service backed by Feishu's official app-registration flow."""
+class _LazyFeishuBotSetupProvider:
+ """Load QR and HTTP dependencies only when setup is first requested."""
+
+ def __init__(self) -> None:
+ self._delegate: FeishuBotSetupProvider | None = None
+ self._lock = threading.Lock()
+
+ def _resolve(self) -> FeishuBotSetupProvider:
+ if self._delegate is not None:
+ return self._delegate
+ with self._lock:
+ if self._delegate is None:
+ from .feishu_app_registration import FeishuAppRegistrationProvider
- from .feishu_app_registration import FeishuAppRegistrationProvider
+ self._delegate = FeishuAppRegistrationProvider()
+ return self._delegate
- return FeishuBotSetupService(FeishuAppRegistrationProvider())
+ def create(self, *, agent_name: str) -> ProviderSession:
+ return self._resolve().create(agent_name=agent_name)
+
+ def poll(self, provider_id: str) -> ProviderResult:
+ return self._resolve().poll(provider_id)
+
+ def cancel(self, provider_id: str) -> None:
+ self._resolve().cancel(provider_id)
+
+
+def create_feishu_bot_setup_service() -> FeishuBotSetupService:
+ """Create the service backed by Feishu's official app-registration flow."""
+ return FeishuBotSetupService(_LazyFeishuBotSetupProvider())
diff --git a/frontend/server/studio_tools/builtin_declarations.py b/frontend/server/studio_tools/builtin_declarations.py
index dcdc7019c..63214d069 100644
--- a/frontend/server/studio_tools/builtin_declarations.py
+++ b/frontend/server/studio_tools/builtin_declarations.py
@@ -34,8 +34,7 @@
},
),
"get_city_weather": (
- "Retrieves the weather information of a given city. the args "
- "must in English",
+ "Retrieves the weather information of a given city. the args must in English",
{
"additionalProperties": False,
"properties": {"city": {"title": "City", "type": "string"}},
@@ -45,8 +44,7 @@
},
),
"get_location_weather": (
- "Retrieves the weather information of a given city. the args "
- "must in English",
+ "Retrieves the weather information of a given city. the args must in English",
{
"additionalProperties": False,
"properties": {"city": {"title": "City", "type": "string"}},
diff --git a/frontend/server/video/routes.py b/frontend/server/video/routes.py
index 43f7611ff..755acf0a3 100644
--- a/frontend/server/video/routes.py
+++ b/frontend/server/video/routes.py
@@ -20,22 +20,14 @@
import tempfile
from collections.abc import Callable
from pathlib import Path
-from typing import Annotated, Literal
+from threading import Lock
+from typing import TYPE_CHECKING, Annotated, Any, Literal
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
-from .client import (
- ArkHttpClient,
- ArkPromptClient,
- ArkServiceError,
- ArkTokenCache,
- ArkTokenProvider,
- ArkVideoClient,
- CredentialResolver,
-)
from .models import (
PromptEnhanceRequest,
PromptEnhanceResponse,
@@ -46,18 +38,11 @@
VideoTaskCreateRequest,
VideoTaskResponse,
)
-from .service import (
- VideoInputError,
- VideoService,
- VideoTaskAccessDenied,
- VideoTaskNotFound,
-)
-from .storage import (
- LazyVideoAssetRepository,
- VideoAssetNotFound,
- VideoAssetStorageUnavailable,
- video_asset_repository_factory,
-)
+
+if TYPE_CHECKING:
+ from .service import VideoService
+
+CredentialResolver = Callable[[], tuple[str, str, str | None]]
IdentityResolver = Callable[[Request], str]
@@ -84,9 +69,72 @@ def build_video_service(
http_client: httpx.AsyncClient | None = None,
token_loader: Callable[..., str] | None = None,
) -> VideoService:
- """Compose production dependencies while keeping each layer testable."""
+ """Compose production dependencies on the first video request."""
if provider not in _PROVIDER_DEFAULTS:
raise ValueError(f"Unsupported video provider: {provider}")
+ return _LazyVideoService(
+ provider=provider,
+ resolve_credentials=resolve_credentials,
+ http_client=http_client,
+ token_loader=token_loader,
+ ) # type: ignore[return-value]
+
+
+class _LazyVideoService:
+ """Keep ModelArk, media storage, and provider SDKs off Studio startup."""
+
+ def __init__(
+ self,
+ *,
+ provider: Literal["volcengine", "byteplus"],
+ resolve_credentials: CredentialResolver,
+ http_client: httpx.AsyncClient | None,
+ token_loader: Callable[..., str] | None,
+ ) -> None:
+ self._provider: Literal["volcengine", "byteplus"] = provider
+ self._resolve_credentials = resolve_credentials
+ self._http_client = http_client
+ self._token_loader = token_loader
+ self._service: Any | None = None
+ self._lock = Lock()
+
+ def _resolve(self) -> VideoService:
+ if self._service is not None:
+ return self._service
+ with self._lock:
+ if self._service is None:
+ self._service = _build_video_service(
+ provider=self._provider,
+ resolve_credentials=self._resolve_credentials,
+ http_client=self._http_client,
+ token_loader=self._token_loader,
+ )
+ return self._service
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(self._resolve(), name)
+
+
+def _build_video_service(
+ *,
+ provider: Literal["volcengine", "byteplus"],
+ resolve_credentials: CredentialResolver,
+ http_client: httpx.AsyncClient | None,
+ token_loader: Callable[..., str] | None,
+) -> VideoService:
+ from .client import (
+ ArkHttpClient,
+ ArkPromptClient,
+ ArkTokenCache,
+ ArkTokenProvider,
+ ArkVideoClient,
+ )
+ from .service import VideoService
+ from .storage import (
+ LazyVideoAssetRepository,
+ video_asset_repository_factory,
+ )
+
defaults = _PROVIDER_DEFAULTS[provider]
config = VideoProviderConfig(
provider=provider,
@@ -190,10 +238,9 @@ async def upload_asset(
declared_mime_type=file.content_type or "",
source=temp_path,
)
- except VideoAssetStorageUnavailable as error:
- raise HTTPException(status_code=503, detail=str(error)) from error
- except ValueError as error:
- raise HTTPException(status_code=400, detail=str(error)) from error
+ except Exception as error:
+ _raise_api_error(error)
+ raise
finally:
await file.close()
if temp_path is not None:
@@ -267,6 +314,13 @@ async def download_task(task_id: str, request: Request) -> StreamingResponse:
def _raise_api_error(error: Exception) -> None:
+ from .client import ArkServiceError
+ from .service import VideoInputError, VideoTaskAccessDenied, VideoTaskNotFound
+ from .storage import (
+ VideoAssetNotFound,
+ VideoAssetStorageUnavailable,
+ )
+
if isinstance(error, (VideoTaskNotFound, VideoAssetNotFound)):
raise HTTPException(status_code=404, detail=str(error)) from error
if isinstance(error, VideoTaskAccessDenied):
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 261edb52f..5f46a5b4f 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -251,6 +251,123 @@ def test_frontend_branding_defers_optional_logo_network_stack() -> None:
)
+def test_video_routes_defer_provider_runtime_until_first_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from frontend.server.video.routes import build_video_service
+
+service = build_video_service(
+ provider="volcengine",
+ resolve_credentials=lambda: ("unused", "unused", None),
+)
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name in {
+ "frontend.server.video.client",
+ "frontend.server.video.service",
+ "frontend.server.video.storage",
+ "veadk.auth.veauth.ark_veauth",
+ }
+)
+if unexpected:
+ raise SystemExit("video request runtime loaded during Studio startup")
+
+assert service.capabilities().provider == "volcengine"
+if "frontend.server.video.service" not in sys.modules:
+ raise SystemExit("video request runtime did not load on first use")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_feishu_setup_defers_qr_provider_until_first_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import sys
+
+from frontend.server.feishu_bot_setup import create_feishu_bot_setup_service
+
+service = create_feishu_bot_setup_service()
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name == "qrcode"
+ or name.startswith("qrcode.")
+ or name == "frontend.server.feishu_bot_setup.feishu_app_registration"
+)
+if unexpected:
+ raise SystemExit("Feishu QR provider loaded during Studio startup")
+
+provider = service._provider._resolve()
+if type(provider).__name__ != "FeishuAppRegistrationProvider":
+ raise SystemExit("Feishu QR provider did not load on first use")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def test_studio_update_routes_defer_updater_runtime_until_first_request() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import asyncio
+import sys
+
+from fastapi import FastAPI
+import httpx
+
+from veadk.cli.studio_self_update_bootstrap import (
+ LazyStudioSelfUpdater,
+ mount_lazy_studio_update_routes,
+)
+
+app = FastAPI()
+updater = LazyStudioSelfUpdater(
+ provider="volcengine",
+ credential_resolver=lambda: ("unused", "unused", None),
+ branding_logo=None,
+)
+mount_lazy_studio_update_routes(app, updater, lambda _request: None)
+if "veadk.cli.studio_self_update" in sys.modules:
+ raise SystemExit("Studio updater runtime loaded during route setup")
+
+async def verify_first_request():
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://test",
+ ) as client:
+ response = await client.get("/web/studio-update")
+ assert response.status_code == 200
+ assert response.json()["enabled"] is False
+
+asyncio.run(verify_first_request())
+if "veadk.cli.studio_self_update" not in sys.modules:
+ raise SystemExit("Studio updater runtime did not load on first request")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_logger_import_does_not_load_general_network_helpers() -> None:
root = Path(__file__).resolve().parents[2]
script = """
diff --git a/tests/frontend/server/test_feishu_bot_setup.py b/tests/frontend/server/test_feishu_bot_setup.py
index 53ad997f1..2c25e96ed 100644
--- a/tests/frontend/server/test_feishu_bot_setup.py
+++ b/tests/frontend/server/test_feishu_bot_setup.py
@@ -66,10 +66,11 @@ def test_session_is_scoped_to_the_current_studio_user() -> None:
service.get(owner="bob", session_id=str(created["id"]))
-def test_default_service_uses_the_real_registration_provider() -> None:
+def test_default_service_resolves_the_real_registration_provider() -> None:
service = create_feishu_bot_setup_service()
- assert isinstance(service._provider, FeishuAppRegistrationProvider)
+ resolve_provider = getattr(service._provider, "_resolve")
+ assert isinstance(resolve_provider(), FeishuAppRegistrationProvider)
def _begin_response() -> dict[str, object]:
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index 7d50257f1..f110f56cf 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -2828,17 +2828,16 @@ def _require_studio_admin(request: Request) -> None:
detail="Only Studio administrators can update Studio",
)
- from veadk.cli.studio_self_update import (
- StudioSelfUpdater,
- StudioUpdateSettings,
+ from veadk.cli.studio_self_update_bootstrap import (
+ LazyStudioSelfUpdater,
current_studio_display_version,
- mount_studio_update_routes,
+ mount_lazy_studio_update_routes,
)
- mount_studio_update_routes(
+ mount_lazy_studio_update_routes(
app,
- StudioSelfUpdater(
- settings=StudioUpdateSettings.from_env(provider=provider),
+ LazyStudioSelfUpdater(
+ provider=provider,
credential_resolver=_resolve_ve_credentials,
branding_logo=branding_logo,
),
diff --git a/veadk/cli/studio_self_update_bootstrap.py b/veadk/cli/studio_self_update_bootstrap.py
new file mode 100644
index 000000000..7b9b76a75
--- /dev/null
+++ b/veadk/cli/studio_self_update_bootstrap.py
@@ -0,0 +1,175 @@
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Lightweight Studio self-update routes for the cold-start path."""
+
+from __future__ import annotations
+
+import asyncio
+import os
+from collections.abc import Callable
+from threading import Lock
+from typing import Any
+
+from fastapi import HTTPException, Request
+
+from veadk.utils.cloud_provider import CloudProvider
+from veadk.version import VERSION
+
+
+Credentials = tuple[str, str, str | None]
+CredentialResolver = Callable[[], Credentials]
+AdminGuard = Callable[[Request], None]
+_UPDATE_HEADER = "X-VeADK-Studio-Update"
+
+
+def current_studio_display_version() -> str:
+ """Return the display version without importing the updater runtime."""
+ release_version = os.getenv("VEADK_STUDIO_RELEASE_VERSION", "bundled")
+ return VERSION if release_version == "bundled" else release_version
+
+
+class LazyStudioSelfUpdater:
+ """Construct the full updater only when an administrator requests it."""
+
+ def __init__(
+ self,
+ *,
+ provider: CloudProvider,
+ credential_resolver: CredentialResolver,
+ branding_logo: Any | None,
+ ) -> None:
+ self._provider: CloudProvider = provider
+ self._credential_resolver = credential_resolver
+ self._branding_logo = branding_logo
+ self._delegate: Any | None = None
+ self._lock = Lock()
+
+ def _resolve(self) -> Any:
+ if self._delegate is not None:
+ return self._delegate
+ with self._lock:
+ if self._delegate is None:
+ from veadk.cli.studio_self_update import (
+ StudioSelfUpdater,
+ StudioUpdateSettings,
+ )
+
+ self._delegate = StudioSelfUpdater(
+ settings=StudioUpdateSettings.from_env(provider=self._provider),
+ credential_resolver=self._credential_resolver,
+ branding_logo=self._branding_logo,
+ )
+ return self._delegate
+
+ def status(self, **kwargs: Any) -> dict[str, Any]:
+ return self._resolve().status(**kwargs)
+
+ def permission_precheck(self) -> dict[str, Any]:
+ return self._resolve().permission_precheck()
+
+ def submit_version(self, version: str | None) -> Any:
+ return self._resolve().submit_version(version)
+
+
+def _is_release_version(value: str) -> bool:
+ return len(value) == 14 and value.isdigit()
+
+
+def _raise_permission_error(error: Exception) -> None:
+ from veadk.cli.studio_release import StudioReleaseError
+
+ if isinstance(error, StudioReleaseError):
+ raise HTTPException(status_code=502, detail=str(error)) from error
+ raise error
+
+
+def _raise_submit_error(error: Exception) -> None:
+ from veadk.cli.studio_release import StudioReleaseError
+ from veadk.cli.studio_self_update import (
+ StudioUpdateConflict,
+ StudioUpdatePermissionRequired,
+ )
+
+ if isinstance(error, (StudioUpdateConflict, StudioUpdatePermissionRequired)):
+ raise HTTPException(status_code=409, detail=str(error)) from error
+ if isinstance(error, StudioReleaseError):
+ raise HTTPException(status_code=502, detail=str(error)) from error
+ raise error
+
+
+def mount_lazy_studio_update_routes(
+ app: Any,
+ updater: LazyStudioSelfUpdater,
+ require_admin: AdminGuard,
+) -> None:
+ """Mount the existing update API without importing its implementation."""
+
+ @app.get("/web/studio-update")
+ async def _studio_update_status(request: Request) -> dict[str, Any]:
+ require_admin(request)
+ target_version = request.query_params.get("targetVersion")
+ if target_version and not _is_release_version(target_version):
+ raise HTTPException(status_code=400, detail="版本号格式无效")
+ raw_started_at = request.query_params.get("startedAt")
+ try:
+ started_at = int(raw_started_at) if raw_started_at else None
+ except ValueError as error:
+ raise HTTPException(status_code=400, detail="更新时间格式无效") from error
+ if started_at is not None and started_at <= 0:
+ raise HTTPException(status_code=400, detail="更新时间格式无效")
+ return await asyncio.to_thread(
+ updater.status,
+ target_version=target_version,
+ started_at=started_at,
+ )
+
+ @app.get("/web/studio-update/permissions")
+ async def _studio_update_permissions(request: Request) -> dict[str, Any]:
+ require_admin(request)
+ try:
+ return await asyncio.to_thread(updater.permission_precheck)
+ except Exception as error:
+ _raise_permission_error(error)
+ raise
+
+ @app.post("/web/studio-update", status_code=202)
+ async def _studio_update_start(request: Request) -> dict[str, Any]:
+ require_admin(request)
+ if request.headers.get(_UPDATE_HEADER) != "1":
+ raise HTTPException(
+ status_code=403,
+ detail="Studio update header is required",
+ )
+ try:
+ version = None
+ if request.headers.get("content-type", "").startswith("application/json"):
+ payload = await request.json()
+ if not isinstance(payload, dict):
+ raise HTTPException(status_code=400, detail="请求格式无效")
+ raw_version = payload.get("version")
+ if raw_version is not None and not isinstance(raw_version, str):
+ raise HTTPException(status_code=400, detail="版本号格式无效")
+ version = raw_version
+ manifest = await asyncio.to_thread(updater.submit_version, version)
+ except HTTPException:
+ raise
+ except Exception as error:
+ _raise_submit_error(error)
+ raise
+ return {
+ "accepted": True,
+ "version": manifest.version,
+ "gitSha": manifest.git_sha,
+ }
From a7de8a25e2588600a20ad6fcde2a16c18f0090a1 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 21:25:46 +0800
Subject: [PATCH 38/46] perf(studio): defer cold-start request runtimes
---
frontend/server/environments/__init__.py | 13 +-
frontend/server/migration/gateway.py | 20 ++-
frontend/server/model_catalog/client.py | 24 +--
frontend/server/model_catalog/protocol.py | 36 ++++
frontend/server/model_catalog/routes.py | 112 +++++++++++--
frontend/server/model_catalog/service.py | 3 +-
frontend/server/skills/devenv.py | 21 ++-
frontend/server/studio_routes/__init__.py | 39 +++--
frontend/server/studio_routes/registry.py | 35 ++--
tests/cli/test_studio_startup_imports.py | 156 +++++++++++++++++-
veadk/cli/cli_frontend.py | 23 ++-
veadk/consts.py | 8 +-
.../agentkit/studio_routes/__init__.py | 39 +++--
veadk/skills/__init__.py | 18 +-
.../builtin_tools/create_agent/__init__.py | 62 +++++--
15 files changed, 503 insertions(+), 106 deletions(-)
create mode 100644 frontend/server/model_catalog/protocol.py
diff --git a/frontend/server/environments/__init__.py b/frontend/server/environments/__init__.py
index 5728c9b75..d1fddfdba 100644
--- a/frontend/server/environments/__init__.py
+++ b/frontend/server/environments/__init__.py
@@ -23,7 +23,6 @@
from frontend.server.agentkit_clients import create_agentkit_client
from frontend.server.storage import StudioProvider, StudioStorageConfig
from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory
-from veadk.auth.veauth.ark_veauth import get_ark_token
from veadk.cli.agentkit_sandbox_region import resolve_sandbox_client_region
from veadk.cli.studio_model_catalog import modelark_base_url, studio_agent_model_name
from veadk.utils.cloud_provider import default_region
@@ -35,6 +34,18 @@
from .tool_provisioning import AgentkitEnvironmentToolProvisioner
+def _get_ark_token() -> Callable[..., str]:
+ from veadk.auth.veauth.ark_veauth import get_ark_token
+
+ return get_ark_token
+
+
+def get_ark_token(*args: Any, **kwargs: Any) -> str:
+ """Resolve Ark auth lazily while preserving the patchable public symbol."""
+
+ return _get_ark_token()(*args, **kwargs)
+
+
def _environment_tool_model_env(
*,
provider: StudioProvider,
diff --git a/frontend/server/migration/gateway.py b/frontend/server/migration/gateway.py
index f2dc25aa8..b1dddc3a3 100644
--- a/frontend/server/migration/gateway.py
+++ b/frontend/server/migration/gateway.py
@@ -23,8 +23,6 @@
from dataclasses import dataclass
from typing import Any, Protocol
-import requests
-
from veadk.cli.agentkit_sandbox_region import (
is_agentkit_resource_not_found,
sandbox_region_candidates,
@@ -66,6 +64,22 @@
logger = logging.getLogger(__name__)
+def _requests() -> Any:
+ import requests
+
+ return requests
+
+
+class _LazyRequests:
+ """Preserve the patchable module surface without importing it at startup."""
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(_requests(), name)
+
+
+requests = _LazyRequests()
+
+
def _tools_types() -> Any:
from agentkit.sdk.tools import types
@@ -780,7 +794,7 @@ def execute_bash(
deadline = time.monotonic() + timeout_seconds + 30
start_marker = _BACKGROUND_START_MARKERS.get(operation, "")
- def response_data(response: requests.Response) -> dict[str, object]:
+ def response_data(response: Any) -> dict[str, object]:
if response.status_code >= 400:
raise MigrationGatewayError(
"MIGRATION_REMOTE_EXEC_FAILED",
diff --git a/frontend/server/model_catalog/client.py b/frontend/server/model_catalog/client.py
index c7667e1f5..3068dcdf3 100644
--- a/frontend/server/model_catalog/client.py
+++ b/frontend/server/model_catalog/client.py
@@ -17,16 +17,19 @@
from __future__ import annotations
import asyncio
-from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any, Literal
+from typing import Any
from frontend.server.video.client import ArkHttpClient, ArkServiceError
from veadk.utils.volcengine_sign import volcengine_signed_request
-CloudCredentials = tuple[str, str, str | None]
-CredentialResolver = Callable[[], CloudCredentials]
-Provider = Literal["volcengine", "byteplus"]
+from .protocol import (
+ CloudCredentials,
+ CredentialResolver,
+ ModelCatalogError,
+ Provider,
+ SignedRequest,
+)
_PAGE_SIZE = 100
_MAX_PAGES = 100
@@ -56,17 +59,6 @@ class ModelCatalogProviderConfig:
}
-class ModelCatalogError(RuntimeError):
- """A sanitized, retryable failure safe to return to a Studio client."""
-
- def __init__(self, message: str, *, status_code: int = 502) -> None:
- super().__init__(message)
- self.status_code = status_code
-
-
-SignedRequest = Callable[..., Any]
-
-
class ModelCatalogClient:
"""Fetch activation metadata and exact data-plane model versions."""
diff --git a/frontend/server/model_catalog/protocol.py b/frontend/server/model_catalog/protocol.py
new file mode 100644
index 000000000..206281ae2
--- /dev/null
+++ b/frontend/server/model_catalog/protocol.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+
+"""Lightweight contracts shared by model-catalog transport and services."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any, Literal
+
+CloudCredentials = tuple[str, str, str | None]
+CredentialResolver = Callable[[], CloudCredentials]
+Provider = Literal["volcengine", "byteplus"]
+SignedRequest = Callable[..., Any]
+
+
+class ModelCatalogError(RuntimeError):
+ """A sanitized, retryable failure safe to return to a Studio client."""
+
+ def __init__(self, message: str, *, status_code: int = 502) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+
+
+__all__ = [
+ "CloudCredentials",
+ "CredentialResolver",
+ "ModelCatalogError",
+ "Provider",
+ "SignedRequest",
+]
diff --git a/frontend/server/model_catalog/routes.py b/frontend/server/model_catalog/routes.py
index 0ae7e6ea9..308710364 100644
--- a/frontend/server/model_catalog/routes.py
+++ b/frontend/server/model_catalog/routes.py
@@ -18,7 +18,8 @@
import os
from collections.abc import Callable
-from typing import Literal
+from threading import Lock
+from typing import TYPE_CHECKING, Any, Literal
import httpx
from fastapi import FastAPI, HTTPException, Query, Request, Response
@@ -27,27 +28,20 @@
provider_allows_studio_development_model,
)
-from frontend.server.video.client import (
- ArkHttpClient,
- ArkTokenCache,
- ArkTokenProvider,
+from .models import (
+ ModelApiKeysResponse,
+ ModelApiKeyValueResponse,
+ ModelOptionsResponse,
)
-
-from .client import (
- PROVIDER_CONFIGS,
+from .protocol import (
CredentialResolver,
- ModelApiKeyClient,
- ModelCatalogClient,
ModelCatalogError,
Provider,
SignedRequest,
)
-from .models import (
- ModelApiKeysResponse,
- ModelApiKeyValueResponse,
- ModelOptionsResponse,
-)
-from .service import ModelApiKeyService, ModelCatalogService
+
+if TYPE_CHECKING:
+ from .service import ModelCatalogService
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
_RAW_KEY_UPSTREAM_ERROR = "无法读取所选 API Key,请检查云账号权限后重试。"
@@ -61,8 +55,92 @@ def build_model_catalog_service(
token_loader: Callable[..., str] | None = None,
signed_request: SignedRequest | None = None,
) -> ModelCatalogService:
- if provider not in PROVIDER_CONFIGS:
+ if provider not in {"volcengine", "byteplus"}:
raise ValueError(f"Unsupported model catalog provider: {provider}")
+ return _LazyModelCatalogService(
+ provider=provider,
+ resolve_credentials=resolve_credentials,
+ http_client=http_client,
+ token_loader=token_loader,
+ signed_request=signed_request,
+ ) # type: ignore[return-value]
+
+
+class _LazyModelCatalogService:
+ """Keep provider clients and request signing off Studio startup."""
+
+ def __init__(
+ self,
+ *,
+ provider: Provider,
+ resolve_credentials: CredentialResolver,
+ http_client: httpx.AsyncClient | None,
+ token_loader: Callable[..., str] | None,
+ signed_request: SignedRequest | None,
+ ) -> None:
+ self._provider: Provider = provider
+ self._resolve_credentials = resolve_credentials
+ self._http_client = http_client
+ self._token_loader = token_loader
+ self._signed_request = signed_request
+ self._service: Any | None = None
+ self._lock = Lock()
+
+ def _resolve(self) -> ModelCatalogService:
+ if self._service is not None:
+ return self._service
+ with self._lock:
+ if self._service is None:
+ self._service = _build_model_catalog_service(
+ provider=self._provider,
+ resolve_credentials=self._resolve_credentials,
+ http_client=self._http_client,
+ token_loader=self._token_loader,
+ signed_request=self._signed_request,
+ )
+ return self._service
+
+ async def list_api_keys(
+ self, *, force_refresh: bool = False
+ ) -> ModelApiKeysResponse:
+ return await self._resolve().list_api_keys(force_refresh=force_refresh)
+
+ async def resolve_raw_key(self, key_id: str) -> str:
+ return await self._resolve().resolve_raw_key(key_id)
+
+ async def list_options(
+ self,
+ *,
+ api_key_id: str | None = None,
+ force_refresh: bool = False,
+ ) -> ModelOptionsResponse:
+ return await self._resolve().list_options(
+ api_key_id=api_key_id,
+ force_refresh=force_refresh,
+ )
+
+
+def _build_model_catalog_service(
+ *,
+ provider: Provider,
+ resolve_credentials: CredentialResolver,
+ http_client: httpx.AsyncClient | None,
+ token_loader: Callable[..., str] | None,
+ signed_request: SignedRequest | None,
+) -> ModelCatalogService:
+ from frontend.server.video.client import (
+ ArkHttpClient,
+ ArkTokenCache,
+ ArkTokenProvider,
+ )
+
+ from .client import (
+ PROVIDER_CONFIGS,
+ ModelApiKeyClient,
+ ModelCatalogClient,
+ )
+ from .service import ModelApiKeyService, ModelCatalogService
+
config = PROVIDER_CONFIGS[provider]
token_cache = (
ArkTokenCache(
diff --git a/frontend/server/model_catalog/service.py b/frontend/server/model_catalog/service.py
index 62fa10914..30437808d 100644
--- a/frontend/server/model_catalog/service.py
+++ b/frontend/server/model_catalog/service.py
@@ -23,13 +23,14 @@
from veadk.utils.logger import get_logger
-from .client import ModelApiKeyClient, ModelCatalogClient, ModelCatalogError, Provider
+from .client import ModelApiKeyClient, ModelCatalogClient
from .models import (
ModelApiKeyOption,
ModelApiKeysResponse,
ModelOption,
ModelOptionsResponse,
)
+from .protocol import ModelCatalogError, Provider
logger = get_logger(__name__)
diff --git a/frontend/server/skills/devenv.py b/frontend/server/skills/devenv.py
index 010a0f5e6..1f7b543f0 100644
--- a/frontend/server/skills/devenv.py
+++ b/frontend/server/skills/devenv.py
@@ -44,8 +44,6 @@
from pathlib import Path, PurePosixPath
from typing import Any, Literal, cast
-import requests
-from agentkit.auth.errors import NetworkError
from fastapi import HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response, StreamingResponse
@@ -68,6 +66,22 @@
logger = get_logger(__name__)
+def _requests() -> Any:
+ import requests
+
+ return requests
+
+
+class _LazyRequests:
+ """Preserve the patchable module surface without importing it at startup."""
+
+ def __getattr__(self, name: str) -> Any:
+ return getattr(_requests(), name)
+
+
+requests = _LazyRequests()
+
+
def AgentkitSkillsClient(*args: Any, **kwargs: Any) -> Any:
"""Construct the Skill client only when a workbench request needs it."""
from agentkit.sdk.skills.client import AgentkitSkillsClient as _Client
@@ -304,6 +318,9 @@ def _exception_chain(error: BaseException) -> list[BaseException]:
def _is_transient_dependency_error(error: BaseException) -> bool:
+ import requests
+ from agentkit.auth.errors import NetworkError
+
for current in _exception_chain(error):
if isinstance(
current,
diff --git a/frontend/server/studio_routes/__init__.py b/frontend/server/studio_routes/__init__.py
index 1a92b9e02..9d52727d6 100644
--- a/frontend/server/studio_routes/__init__.py
+++ b/frontend/server/studio_routes/__init__.py
@@ -14,18 +14,33 @@
"""Studio BFF-owned dynamic HTTP routes and persistent Runtime connector."""
-from frontend.server.studio_routes.connector import (
- StudioRouteChannelError,
- StudioRouteChannelManager,
- runtime_supports_bff_routes,
- serve_studio_route_channel,
-)
-from frontend.server.studio_routes.registry import (
- StudioRoute,
- StudioRouteRegistry,
- StudioRouteResponse,
- build_studio_route_registry,
-)
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+
+_EXPORT_MODULES = {
+ "StudioRouteChannelError": "connector",
+ "StudioRouteChannelManager": "connector",
+ "runtime_supports_bff_routes": "connector",
+ "serve_studio_route_channel": "connector",
+ "StudioRoute": "registry",
+ "StudioRouteRegistry": "registry",
+ "StudioRouteResponse": "registry",
+ "build_studio_route_registry": "registry",
+}
+
+
+def __getattr__(name: str) -> Any:
+ module_name = _EXPORT_MODULES.get(name)
+ if module_name is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ value = getattr(module, name)
+ globals()[name] = value
+ return value
+
__all__ = [
"StudioRoute",
diff --git a/frontend/server/studio_routes/registry.py b/frontend/server/studio_routes/registry.py
index f9229bd3d..96213672a 100644
--- a/frontend/server/studio_routes/registry.py
+++ b/frontend/server/studio_routes/registry.py
@@ -21,19 +21,18 @@
import os
from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import Any
+from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import parse_qs
from frontend.server.storage import StudioProvider
-from frontend.server.studio_routes.skill_catalog import (
- StudioSkillCatalog,
- StudioSkillCatalogError,
-)
-from veadk.integrations.agentkit.studio_routes import (
+from veadk.integrations.agentkit.studio_routes.protocol import (
StudioRouteManifest,
route_catalog_revision,
)
+if TYPE_CHECKING:
+ from frontend.server.studio_routes.skill_catalog import StudioSkillCatalog
+
RouteExecutor = Callable[[dict[str, Any]], Any]
@@ -61,11 +60,11 @@ class StudioRoute:
def manifest(self) -> StudioRouteManifest:
return StudioRouteManifest(
id=self.id,
- method=self.method.upper(),
+ method=cast(Literal["GET", "POST"], self.method.upper()),
path=self.path,
handler_revision=self.handler_revision,
timeout_ms=self.timeout_ms,
- response_mode=self.response_mode,
+ response_mode=cast(Literal["json", "text"], self.response_mode),
)
@@ -130,7 +129,7 @@ async def execute(
def _query_values(request: dict[str, Any]) -> dict[str, list[str]]:
raw_query = request.get("query_string")
if not isinstance(raw_query, str):
- raise StudioSkillCatalogError(400, "invalid route query string")
+ raise _skill_catalog_error(400, "invalid route query string")
try:
return parse_qs(
raw_query,
@@ -139,7 +138,7 @@ def _query_values(request: dict[str, Any]) -> dict[str, list[str]]:
max_num_fields=20,
)
except ValueError as error:
- raise StudioSkillCatalogError(400, "invalid route query string") from error
+ raise _skill_catalog_error(400, "invalid route query string") from error
def _single_query(
@@ -151,7 +150,7 @@ def _single_query(
if not values:
return default
if len(values) != 1:
- raise StudioSkillCatalogError(400, f"duplicate query parameter: {name}")
+ raise _skill_catalog_error(400, f"duplicate query parameter: {name}")
return values[0]
@@ -164,13 +163,19 @@ def _positive_int_query(
try:
return int(raw_value)
except ValueError as error:
- raise StudioSkillCatalogError(
+ raise _skill_catalog_error(
400,
f"invalid integer query parameter: {name}",
) from error
-def _catalog_response(error: StudioSkillCatalogError) -> StudioRouteResponse:
+def _skill_catalog_error(status_code: int, detail: str) -> Exception:
+ from frontend.server.studio_routes.skill_catalog import StudioSkillCatalogError
+
+ return StudioSkillCatalogError(status_code, detail)
+
+
+def _catalog_response(error: Any) -> StudioRouteResponse:
return StudioRouteResponse(
status=error.status_code,
headers={"content-type": "application/json"},
@@ -182,6 +187,8 @@ def _register_skill_catalog_routes(
registry: StudioRouteRegistry,
catalog: StudioSkillCatalog,
) -> None:
+ from frontend.server.studio_routes.skill_catalog import StudioSkillCatalogError
+
async def findskill(request: dict[str, Any]) -> StudioRouteResponse:
try:
query = _query_values(request)
@@ -266,6 +273,8 @@ def build_studio_route_registry(
registry = StudioRouteRegistry()
mode = os.getenv("VEADK_STUDIO_ROUTE_CHANNEL", "").strip().lower()
if mode in {"1", "true", "yes", "demo", "skill-catalog"}:
+ from frontend.server.studio_routes.skill_catalog import StudioSkillCatalog
+
_register_skill_catalog_routes(
registry,
skill_catalog or StudioSkillCatalog(provider),
diff --git a/tests/cli/test_studio_startup_imports.py b/tests/cli/test_studio_startup_imports.py
index 5f46a5b4f..b02dd2c3f 100644
--- a/tests/cli/test_studio_startup_imports.py
+++ b/tests/cli/test_studio_startup_imports.py
@@ -289,6 +289,95 @@ def test_video_routes_defer_provider_runtime_until_first_request() -> None:
)
+def test_complete_studio_app_defers_model_catalog_network_runtime() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import os
+import sys
+import tempfile
+from unittest.mock import patch
+
+os.environ["VOLCENGINE_ACCESS_KEY"] = "unused"
+os.environ["VOLCENGINE_SECRET_KEY"] = "unused"
+os.environ["_VEADK_STUDIO_LAZY_ADK_PACKAGES"] = "1"
+
+from veadk.cli.cli_frontend import _run_frontend_server
+
+captured = {}
+with tempfile.TemporaryDirectory() as agents_dir:
+ with patch("dotenv.find_dotenv", return_value=""), patch(
+ "uvicorn.run",
+ side_effect=lambda app, **kwargs: captured.setdefault("app", app),
+ ):
+ _run_frontend_server(
+ agents_dir=agents_dir,
+ frontend_dir=None,
+ site_logo=None,
+ site_title=None,
+ host="127.0.0.1",
+ port=8765,
+ dev=True,
+ vite=True,
+ oauth2_user_pool=None,
+ oauth2_user_pool_client=None,
+ oauth2_user_pool_uid=None,
+ oauth2_user_pool_client_uid=None,
+ oauth2_redirect_uri=None,
+ oauth2_provider=None,
+ oauth2_provider_label=None,
+ auth_mode="frontend",
+ generated_agent_test_run_ttl=60,
+ open_browser=False,
+ provider="volcengine",
+ studio=True,
+ )
+
+assert captured["app"] is not None
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name in {
+ "frontend.server.video.client",
+ "veadk.auth.veauth.ark_veauth",
+ "veadk.utils.misc",
+ "veadk.utils.volcengine_sign",
+ "requests",
+ }
+)
+if unexpected:
+ raise SystemExit("model catalog network runtime loaded during Studio startup")
+
+import asyncio
+
+from frontend.server.model_catalog.routes import build_model_catalog_service
+
+service = build_model_catalog_service(
+ provider="volcengine",
+ resolve_credentials=lambda: ("unused", "unused", None),
+ signed_request=lambda **_kwargs: {
+ "Result": {"TotalCount": 0, "Items": []},
+ },
+)
+response = asyncio.run(service.list_api_keys())
+assert response.keys == []
+for expected in (
+ "frontend.server.video.client",
+ "veadk.utils.volcengine_sign",
+ "requests",
+):
+ if expected not in sys.modules:
+ raise SystemExit("model catalog network runtime did not load on first use")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_feishu_setup_defers_qr_provider_until_first_request() -> None:
root = Path(__file__).resolve().parents[2]
script = """
@@ -368,6 +457,50 @@ async def verify_first_request():
)
+def test_disabled_studio_route_channel_defers_request_runtime() -> None:
+ root = Path(__file__).resolve().parents[2]
+ script = """
+import os
+import sys
+
+os.environ.pop("VEADK_STUDIO_ROUTE_CHANNEL", None)
+
+from frontend.server.studio_routes.registry import build_studio_route_registry
+
+registry = build_studio_route_registry(provider="volcengine")
+assert registry.enabled is False
+unexpected = sorted(
+ name
+ for name in sys.modules
+ if name in {
+ "frontend.server.studio_routes.connector",
+ "frontend.server.studio_routes.skill_catalog",
+ "frontend.server.skills.repository",
+ "frontend.server.skills.storage",
+ "frontend.server.skills.system_spaces",
+ "veadk.integrations.agentkit.studio_routes.host",
+ }
+)
+if unexpected:
+ raise SystemExit("disabled Studio route channel loaded request runtime")
+
+os.environ["VEADK_STUDIO_ROUTE_CHANNEL"] = "skill-catalog"
+enabled = build_studio_route_registry(provider="volcengine")
+assert enabled.enabled is True
+assert len(enabled.manifests()) == 3
+if "frontend.server.studio_routes.skill_catalog" not in sys.modules:
+ raise SystemExit("Studio route request runtime did not load when enabled")
+"""
+
+ subprocess.run(
+ [sys.executable, "-c", script],
+ cwd=root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
def test_logger_import_does_not_load_general_network_helpers() -> None:
root = Path(__file__).resolve().parents[2]
script = """
@@ -463,7 +596,7 @@ def test_skill_workbench_defers_agentkit_runtime_until_first_call() -> None:
script = """
import sys
-import frontend.server.skills.devenv
+import frontend.server.skills.devenv as devenv
unexpected = sorted(
name
@@ -475,11 +608,17 @@ def test_skill_workbench_defers_agentkit_runtime_until_first_call() -> None:
"agentkit.sdk.tools.types",
"agentkit.toolkit.cli.sandbox.env_config",
"agentkit.toolkit.cli.sandbox.sandbox_client",
+ "agentkit.auth.errors",
+ "requests",
"veadk.skills.skill",
}
)
if unexpected:
raise SystemExit("Skill workbench AgentKit runtime loaded at startup")
+
+assert devenv._is_transient_dependency_error(RuntimeError("test")) is False
+if "requests" not in sys.modules or "agentkit.auth.errors" not in sys.modules:
+ raise SystemExit("Skill workbench network runtime did not load on first use")
"""
subprocess.run(
@@ -496,7 +635,7 @@ def test_migration_gateway_defers_agentkit_runtime_until_first_call() -> None:
script = """
import sys
-import frontend.server.migration.gateway
+import frontend.server.migration.gateway as gateway
unexpected = sorted(
name
@@ -507,10 +646,15 @@ def test_migration_gateway_defers_agentkit_runtime_until_first_call() -> None:
"agentkit.toolkit.cli.sandbox.sandbox_client",
"veadk.cli.agentkit_session_metadata",
"veadk.cli.frontend_skill_creator",
+ "requests",
}
)
if unexpected:
raise SystemExit("Migration Gateway AgentKit runtime loaded at startup")
+
+assert gateway._requests().__name__ == "requests"
+if "requests" not in sys.modules:
+ raise SystemExit("Migration Gateway network runtime did not load on first use")
"""
subprocess.run(
@@ -584,16 +728,22 @@ def test_environment_service_defers_generated_project_runtime_until_build() -> N
script = """
import sys
-import frontend.server.environments
+import frontend.server.environments as environments
unexpected = sorted(
name
for name in sys.modules
if name == "veadk.cli.generated_agent_codegen"
or name == "veadk.cli.generated_agent_skills"
+ or name == "veadk.auth.veauth.ark_veauth"
+ or name == "requests"
)
if unexpected:
raise SystemExit("generated project runtime loaded by environment composition")
+
+assert environments._get_ark_token().__name__ == "get_ark_token"
+if "veadk.auth.veauth.ark_veauth" not in sys.modules:
+ raise SystemExit("environment model auth runtime did not load on first use")
"""
subprocess.run(
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index f110f56cf..c0e4fb282 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -2001,10 +2001,7 @@ def _run_frontend_server(
runtime_request_context,
studio_runtime_context_headers,
)
- from frontend.server.studio_routes import (
- StudioRouteChannelManager,
- build_studio_route_registry,
- )
+ from frontend.server.studio_routes.registry import build_studio_route_registry
from frontend.server.studio_tools import build_studio_tool_registry
from veadk.multimodal.service import MediaService
from veadk.multimodal.storage import create_media_storage
@@ -2020,7 +2017,23 @@ def _run_frontend_server(
)
studio_route_registry = build_studio_route_registry(provider=provider_id)
- studio_route_channels = StudioRouteChannelManager(studio_route_registry)
+ if studio_route_registry.enabled:
+ from frontend.server.studio_routes import StudioRouteChannelManager
+
+ studio_route_channels = StudioRouteChannelManager(studio_route_registry)
+ else:
+
+ class _DisabledStudioRouteChannelManager:
+ async def close(self) -> None:
+ return None
+
+ async def ensure_connected(self, *_args: Any, **_kwargs: Any) -> bool:
+ return False
+
+ def connected(self, *_args: Any, **_kwargs: Any) -> bool:
+ return False
+
+ studio_route_channels = _DisabledStudioRouteChannelManager()
app.state.studio_route_registry = studio_route_registry
app.state.studio_route_channels = studio_route_channels
if studio_route_registry.enabled:
diff --git a/veadk/consts.py b/veadk/consts.py
index 6f502beef..a20c7f0d0 100644
--- a/veadk/consts.py
+++ b/veadk/consts.py
@@ -15,7 +15,6 @@
import os
import time
-from veadk.utils.misc import getenv
from veadk.version import VERSION
DEFAULT_AGENT_NAME = "veAgent"
@@ -25,15 +24,16 @@
DEFAULT_MODEL_AGENT_API_BASE = "https://ark.cn-beijing.volces.com/api/v3/"
DEFAULT_MODEL_EXTRA_CONFIG = {
"extra_headers": {
- "x-is-encrypted": getenv("MODEL_AGENT_ENCRYPTED", "true"),
+ "x-is-encrypted": os.getenv("MODEL_AGENT_ENCRYPTED") or "true",
"veadk-source": "veadk",
"veadk-version": VERSION,
"User-Agent": f"VeADK/{VERSION}",
- "X-Client-Request-Id": getenv("MODEL_AGENT_CLIENT_REQ_ID", f"veadk/{VERSION}"),
+ "X-Client-Request-Id": os.getenv("MODEL_AGENT_CLIENT_REQ_ID")
+ or f"veadk/{VERSION}",
},
"extra_body": {
"caching": {
- "type": getenv("MODEL_AGENT_CACHING", "enabled"),
+ "type": os.getenv("MODEL_AGENT_CACHING") or "enabled",
},
# "thinking": {
# "type": "disabled"
diff --git a/veadk/integrations/agentkit/studio_routes/__init__.py b/veadk/integrations/agentkit/studio_routes/__init__.py
index 82a3c898c..1dc5624db 100644
--- a/veadk/integrations/agentkit/studio_routes/__init__.py
+++ b/veadk/integrations/agentkit/studio_routes/__init__.py
@@ -14,18 +14,33 @@
"""Runtime host for Studio BFF-owned dynamic HTTP routes."""
-from veadk.integrations.agentkit.studio_routes.host import (
- StudioDynamicRouteMiddleware,
- StudioRouteHost,
- mount_studio_route_host,
-)
-from veadk.integrations.agentkit.studio_routes.protocol import (
- ROUTE_PROTOCOL_VERSION,
- RouteCatalogSnapshot,
- StudioRouteManifest,
- match_route_path,
- route_catalog_revision,
-)
+from __future__ import annotations
+
+import importlib
+from typing import Any
+
+
+_EXPORT_MODULES = {
+ "StudioDynamicRouteMiddleware": "host",
+ "StudioRouteHost": "host",
+ "mount_studio_route_host": "host",
+ "ROUTE_PROTOCOL_VERSION": "protocol",
+ "RouteCatalogSnapshot": "protocol",
+ "StudioRouteManifest": "protocol",
+ "match_route_path": "protocol",
+ "route_catalog_revision": "protocol",
+}
+
+
+def __getattr__(name: str) -> Any:
+ module_name = _EXPORT_MODULES.get(name)
+ if module_name is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ value = getattr(module, name)
+ globals()[name] = value
+ return value
+
__all__ = [
"ROUTE_PROTOCOL_VERSION",
diff --git a/veadk/skills/__init__.py b/veadk/skills/__init__.py
index 1e5ab8797..6b8beb803 100644
--- a/veadk/skills/__init__.py
+++ b/veadk/skills/__init__.py
@@ -12,8 +12,24 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
from veadk.skills.exceptions import SkillLoadError, SkillMaterializeError
-from veadk.skills.registry import VeSkillRegistry
+
+if TYPE_CHECKING:
+ from veadk.skills.registry import VeSkillRegistry
+
+
+def __getattr__(name: str) -> Any:
+ if name != "VeSkillRegistry":
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ from veadk.skills.registry import VeSkillRegistry
+
+ globals()[name] = VeSkillRegistry
+ return VeSkillRegistry
+
__all__ = [
"SkillLoadError",
diff --git a/veadk/tools/builtin_tools/create_agent/__init__.py b/veadk/tools/builtin_tools/create_agent/__init__.py
index 05036180f..ebd844c93 100644
--- a/veadk/tools/builtin_tools/create_agent/__init__.py
+++ b/veadk/tools/builtin_tools/create_agent/__init__.py
@@ -14,22 +14,52 @@
"""Dynamic sub-agent creation toolset."""
-from veadk.tools.builtin_tools.create_agent.models import (
- AgentBlueprint,
- AgentCapabilities,
- CollectResourcesResponse,
- CreateAgentsResponse,
- ResourceDescriptor,
-)
-from veadk.tools.builtin_tools.create_agent.resource_store import (
- ResourceStore,
- StoredResource,
-)
-from veadk.tools.builtin_tools.create_agent.sources import (
- ResourceSource,
- SourceCollection,
-)
-from veadk.tools.builtin_tools.create_agent.toolset import CreateAgentToolset
+from __future__ import annotations
+
+import importlib
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from veadk.tools.builtin_tools.create_agent.models import (
+ AgentBlueprint,
+ AgentCapabilities,
+ CollectResourcesResponse,
+ CreateAgentsResponse,
+ ResourceDescriptor,
+ )
+ from veadk.tools.builtin_tools.create_agent.resource_store import (
+ ResourceStore,
+ StoredResource,
+ )
+ from veadk.tools.builtin_tools.create_agent.sources import (
+ ResourceSource,
+ SourceCollection,
+ )
+ from veadk.tools.builtin_tools.create_agent.toolset import CreateAgentToolset
+
+_EXPORT_MODULES = {
+ "AgentBlueprint": "models",
+ "AgentCapabilities": "models",
+ "CollectResourcesResponse": "models",
+ "CreateAgentsResponse": "models",
+ "ResourceDescriptor": "models",
+ "ResourceStore": "resource_store",
+ "StoredResource": "resource_store",
+ "ResourceSource": "sources",
+ "SourceCollection": "sources",
+ "CreateAgentToolset": "toolset",
+}
+
+
+def __getattr__(name: str) -> Any:
+ module_name = _EXPORT_MODULES.get(name)
+ if module_name is None:
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+ module = importlib.import_module(f"{__name__}.{module_name}")
+ value = getattr(module, name)
+ globals()[name] = value
+ return value
+
__all__ = [
"AgentBlueprint",
From d8c395ad38fb3e10e1160cf7b9c0146a62e2d480 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Tue, 15 Sep 2026 21:51:35 +0800
Subject: [PATCH 39/46] fix(release): normalize staged runtime permissions
---
.../harness-sidecar-release-gate.yaml | 1 +
.../service/studio_release_server/deploy.py | 23 ++++++++++++-
tests/test_studio_release_server.py | 32 ++++++++++++++++---
3 files changed, 50 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 896a335fc..17af304d1 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -150,6 +150,7 @@ jobs:
tests/test_studio_release_server.py::test_release_server_agentkit_cli_pin_matches_veadk \
tests/test_studio_release_server.py::test_publisher_repairs_missing_agentkit_cli_before_manifest \
tests/test_studio_release_server.py::test_release_server_zip_normalizes_runtime_file_permissions \
+ tests/test_studio_release_server.py::test_stage_deployment_uses_frontend_service_package \
tests/cli/test_studio_release.py::test_release_zip_normalizes_runtime_file_permissions \
tests/test_studio_release_workflow.py::test_smoke_gate_is_fresh_amd64_and_runs_before_import_validation \
tests/test_studio_release_workflow.py::test_smoke_gate_executes_bundle_as_low_privilege_user \
diff --git a/frontend/service/studio_release_server/deploy.py b/frontend/service/studio_release_server/deploy.py
index 8224af6c9..75bdc0c0a 100644
--- a/frontend/service/studio_release_server/deploy.py
+++ b/frontend/service/studio_release_server/deploy.py
@@ -264,7 +264,28 @@ def _stage_deployment(source_root: Path, destination: Path) -> None:
(site_packages / ".installed").touch()
run_script = destination / "run.sh"
shutil.copy2(deployment_root / "run.sh", run_script)
- run_script.chmod(0o755)
+ _normalize_runtime_permissions(destination)
+
+
+def _normalize_runtime_permissions(deployment_root: Path) -> None:
+ """Make staged files readable by the unprivileged VeFaaS runtime user."""
+ deployment_root.chmod(0o755)
+ for current_root, directory_names, file_names in os.walk(deployment_root):
+ current_path = Path(current_root)
+ for directory_name in directory_names:
+ directory = current_path / directory_name
+ if not directory.is_symlink():
+ directory.chmod(0o755)
+ for file_name in file_names:
+ file_path = current_path / file_name
+ if file_path.is_symlink():
+ continue
+ relative_path = file_path.relative_to(deployment_root)
+ executable = file_path.suffix == ".sh" or relative_path.parts[:2] == (
+ "site-packages",
+ "bin",
+ )
+ file_path.chmod(0o755 if executable else 0o644)
def _stage_node_archive(service_destination: Path) -> None:
diff --git a/tests/test_studio_release_server.py b/tests/test_studio_release_server.py
index 30ea08079..d93f63470 100644
--- a/tests/test_studio_release_server.py
+++ b/tests/test_studio_release_server.py
@@ -1958,7 +1958,9 @@ def test_stage_deployment_uses_frontend_service_package(
for dependency_name in ("tos", "crcmod"):
dependency_root = tmp_path / "dependencies" / dependency_name
dependency_root.mkdir(parents=True)
- (dependency_root / "__init__.py").write_text("", encoding="utf-8")
+ dependency_init = dependency_root / "__init__.py"
+ dependency_init.write_text("", encoding="utf-8")
+ dependency_init.chmod(0o600)
spec = importlib.machinery.ModuleSpec(
dependency_name, loader=None, is_package=True
)
@@ -1966,7 +1968,15 @@ def test_stage_deployment_uses_frontend_service_package(
dependency_specs[dependency_name] = spec
def _install_runtime_wheels(*_args: Any, **_kwargs: Any) -> None:
- (destination / "site-packages").mkdir()
+ site_packages = destination / "site-packages"
+ site_packages.mkdir(mode=0o700)
+ private_module = site_packages / "private_module.py"
+ private_module.write_text("VALUE = 1\n", encoding="utf-8")
+ private_module.chmod(0o600)
+ console_script = site_packages / "bin" / "runtime-helper"
+ console_script.parent.mkdir(mode=0o700)
+ console_script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+ console_script.chmod(0o600)
monkeypatch.setattr(release_deploy.shutil, "which", lambda _name: "uv")
monkeypatch.setattr(release_deploy.subprocess, "run", _install_runtime_wheels)
@@ -1991,9 +2001,21 @@ def _install_runtime_wheels(*_args: Any, **_kwargs: Any) -> None:
assert not (package_root / "deploy.py").exists()
assert not (package_root / "deploy.sh").exists()
assert not (destination / "veadk").exists()
- assert "frontend.service.studio_release_server.app:app" in (
- destination / "run.sh"
- ).read_text(encoding="utf-8")
+ run_script = destination / "run.sh"
+ assert "frontend.service.studio_release_server.app:app" in run_script.read_text(
+ encoding="utf-8"
+ )
+ assert run_script.stat().st_mode & 0o777 == 0o755
+ assert (destination / "site-packages").stat().st_mode & 0o777 == 0o755
+ assert (
+ destination / "site-packages" / "private_module.py"
+ ).stat().st_mode & 0o777 == 0o644
+ assert (
+ destination / "site-packages" / "bin" / "runtime-helper"
+ ).stat().st_mode & 0o777 == 0o755
+ assert (
+ destination / "site-packages" / "tos" / "__init__.py"
+ ).stat().st_mode & 0o777 == 0o644
def test_function_lookup_paginates() -> None:
From 852c2df17716a3f4512a12c266fe3b80a60964ef Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 01:19:44 +0800
Subject: [PATCH 40/46] fix(studio): defer transient identity startup failure
---
.../harness-sidecar-release-gate.yaml | 3 +
frontend/server/user_management/deployment.py | 24 ++++--
frontend/server/user_management/service.py | 37 ++++++++
tests/frontend/server/test_user_management.py | 86 +++++++++++++++++++
tests/test_harness_sidecar_release_gate.py | 12 +++
5 files changed, 156 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 17af304d1..e3aa3755e 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -159,6 +159,9 @@ jobs:
tests/cli/test_studio_update.py::test_studio_update_preserves_custom_function_command \
tests/cli/test_studio_update.py::test_native_python_function_uses_mode_independent_bundle_entrypoint \
tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \
+ tests/frontend/server/test_user_management.py::test_initialized_runtime_defers_transient_identity_directory_failure \
+ tests/frontend/server/test_user_management.py::test_initialized_runtime_deferred_identity_failure_remains_fail_closed \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_still_blocks_startup \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/frontend/server/user_management/deployment.py b/frontend/server/user_management/deployment.py
index 9ecd9886f..82a0d74fc 100644
--- a/frontend/server/user_management/deployment.py
+++ b/frontend/server/user_management/deployment.py
@@ -190,22 +190,34 @@ def initialize_runtime_roles(
client_uid,
provider,
)
+ deferred = False
try:
service.initialize(
super_admin, admins, developers, allow_initialize=not initialized
)
except UserManagementError as error:
- raise click.ClickException(
- f"Studio Identity role setup failed: {error.code}; check Identity user/group "
- "permissions and the legacy role lists, then retry"
- ) from error
+ if initialized and error.code == "identity_unavailable":
+ service.defer_initialization(
+ super_admin,
+ admins,
+ developers,
+ allow_initialize=False,
+ )
+ deferred = True
+ else:
+ raise click.ClickException(
+ f"Studio Identity role setup failed: {error.code}; check Identity "
+ "user/group permissions and the legacy role lists, then retry"
+ ) from error
legacy_keys = (
"VEADK_STUDIO_SUPER_ADMIN",
"VEADK_STUDIO_ADMINS",
"VEADK_STUDIO_DEVELOPERS",
)
- if (not initialized or any(environment.get(key) for key in legacy_keys)) and (
- function_id := environment.get("VEADK_STUDIO_FUNCTION_ID")
+ if (
+ not deferred
+ and (not initialized or any(environment.get(key) for key in legacy_keys))
+ and (function_id := environment.get("VEADK_STUDIO_FUNCTION_ID"))
):
from veadk.integrations.ve_faas.ve_faas import VeFaaS
diff --git a/frontend/server/user_management/service.py b/frontend/server/user_management/service.py
index 0ba19ba01..26a7c553e 100644
--- a/frontend/server/user_management/service.py
+++ b/frontend/server/user_management/service.py
@@ -45,6 +45,41 @@ def __init__(
# Only the immutable subject-to-UID mapping is cached, never permissions
self._subject_uids: dict[str, str] = {}
self._write_lock = RLock()
+ self._deferred_initialization: tuple[str, str, str, bool] | None = None
+
+ def defer_initialization(
+ self,
+ bootstrap: str = "",
+ admins: str = "",
+ developers: str = "",
+ *,
+ allow_initialize: bool = False,
+ ) -> None:
+ """Retry an already-authorized startup initialization on protected access."""
+ with self._write_lock:
+ self._deferred_initialization = (
+ bootstrap,
+ admins,
+ developers,
+ allow_initialize,
+ )
+
+ def _ensure_deferred_initialization(self) -> None:
+ pending = self._deferred_initialization
+ if pending is None:
+ return
+ with self._write_lock:
+ pending = self._deferred_initialization
+ if pending is None:
+ return
+ bootstrap, admins, developers, allow_initialize = pending
+ self.initialize(
+ bootstrap,
+ admins,
+ developers,
+ allow_initialize=allow_initialize,
+ )
+ self._deferred_initialization = None
def initialize(
self,
@@ -179,6 +214,7 @@ def _role(self, user: PoolUser) -> StudioRole:
return roles[0] if len(roles) == 1 else StudioRole.USER
def _resolve_user(self, principal: StudioPrincipal | None) -> PoolUser:
+ self._ensure_deferred_initialization()
if principal is None:
raise UserManagementError(401, "sign_in_required")
subject = principal.owner_id
@@ -301,6 +337,7 @@ def change_role(
expected_role: StudioRole,
) -> dict:
with self._write_lock:
+ self._ensure_deferred_initialization()
self.initialize()
actor_user = self._require_super_admin(actor)
user = self.directory.user(user_uid)
diff --git a/tests/frontend/server/test_user_management.py b/tests/frontend/server/test_user_management.py
index 8799eb37f..c107bdc34 100644
--- a/tests/frontend/server/test_user_management.py
+++ b/tests/frontend/server/test_user_management.py
@@ -645,3 +645,89 @@ def test_failed_runtime_migration_never_clears_old_environment(monkeypatch):
admins="missing@example.com",
)
assert not directory.group_records
+
+
+def test_initialized_runtime_defers_transient_identity_directory_failure(monkeypatch):
+ from frontend.server.user_management import deployment
+
+ directory = Directory()
+ seeded = UserManagementService(directory, "pool", "client", "volcengine")
+ seeded.initialize("owner", allow_initialize=True)
+ original_groups = directory.groups
+ attempts = 0
+
+ def transient_groups():
+ nonlocal attempts
+ attempts += 1
+ if attempts == 1:
+ raise UserManagementError(503, "identity_unavailable")
+ return original_groups()
+
+ directory.groups = transient_groups
+ monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
+
+ service = deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={"VEADK_STUDIO_IDENTITY_ROLES": "1"},
+ )
+
+ principal = service.principal_for(
+ StudioPrincipal.from_claims({"sub": "oidc|owner"})
+ )
+ assert attempts == 2
+ assert principal.role == StudioRole.SUPER_ADMIN
+
+
+def test_initialized_runtime_deferred_identity_failure_remains_fail_closed(
+ monkeypatch,
+):
+ from frontend.server.user_management import deployment
+
+ directory = Directory()
+ seeded = UserManagementService(directory, "pool", "client", "volcengine")
+ seeded.initialize("owner", allow_initialize=True)
+
+ def unavailable_groups():
+ raise UserManagementError(503, "identity_unavailable")
+
+ directory.groups = unavailable_groups
+ monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
+
+ service = deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={"VEADK_STUDIO_IDENTITY_ROLES": "1"},
+ )
+
+ with pytest.raises(UserManagementError, match="identity_unavailable"):
+ service.principal_for(StudioPrincipal.from_claims({"sub": "oidc|owner"}))
+
+
+def test_uninitialized_runtime_identity_failure_still_blocks_startup(monkeypatch):
+ import click
+ from frontend.server.user_management import deployment
+
+ directory = Directory()
+
+ def unavailable_groups():
+ raise UserManagementError(503, "identity_unavailable")
+
+ directory.groups = unavailable_groups
+ monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
+
+ with pytest.raises(click.ClickException, match="identity_unavailable"):
+ deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={},
+ )
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 7aabec057..d15959432 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -77,6 +77,18 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
backend_run
)
assert "test_vefaas_deploy_updates_existing_application_in_place" in backend_run
+ assert (
+ "test_initialized_runtime_defers_transient_identity_directory_failure"
+ in backend_run
+ )
+ assert (
+ "test_initialized_runtime_deferred_identity_failure_remains_fail_closed"
+ in backend_run
+ )
+ assert (
+ "test_uninitialized_runtime_identity_failure_still_blocks_startup"
+ in backend_run
+ )
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
backend_run
)
From ba2b8b00a0166f92c9e359ba4b43ad10fc5a9d34 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 01:53:39 +0800
Subject: [PATCH 41/46] fix(ci): unblock release verification
---
.github/workflows/publish-studio-release.yaml | 2 +-
frontend/server/deployment_resources.py | 2 +-
frontend/server/model_catalog/client.py | 2 +-
frontend/server/model_catalog/protocol.py | 2 +-
frontend/server/model_catalog/routes.py | 2 +-
frontend/server/model_catalog/service.py | 2 +-
frontend/server/runtime_iam.py | 2 +-
tests/cli/conftest.py | 2 +-
tests/cli/provider_environment.py | 2 +-
tests/cli/test_provider_environment_isolation.py | 2 +-
tests/cli/test_studio_deploy_permissions.py | 2 +-
tests/cli/test_studio_deploy_role_confirmation.py | 2 +-
tests/cli/test_studio_update_permissions.py | 2 +-
tests/extensions/harness/sidecar_runtime/test_deploy.py | 2 +-
.../harness/sidecar_runtime/test_mcp_client.py | 2 +-
.../harness/sidecar_runtime/test_mcp_loopback_proxy.py | 2 +-
tests/frontend/server/test_runtime_iam.py | 2 +-
tests/test_studio_release_workflow.py | 9 +++++++++
veadk/cli/studio_deploy_permissions.py | 2 +-
veadk/cli/studio_update_permissions.py | 2 +-
.../tos_context_bucket_backend.py | 2 +-
21 files changed, 29 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/publish-studio-release.yaml b/.github/workflows/publish-studio-release.yaml
index 61af93ce6..3a892b900 100644
--- a/.github/workflows/publish-studio-release.yaml
+++ b/.github/workflows/publish-studio-release.yaml
@@ -230,8 +230,8 @@ jobs:
permission_probe_state="$permission_probe_root/state"
mkdir "$permission_probe" "$permission_probe_bin" "$permission_probe_state"
chmod 755 "$permission_probe_root" "$permission_probe" "$permission_probe_bin"
- sudo chown nobody "$permission_probe_state"
chmod 700 "$permission_probe_state"
+ sudo chown nobody "$permission_probe_state"
unzip -q "${bundle[0]}" -d "$permission_probe"
test "$(stat -c %a "$permission_probe/run.sh")" = "755"
test "$(stat -c %a "$permission_probe/agentkit-linux-x64.tar.gz")" = "644"
diff --git a/frontend/server/deployment_resources.py b/frontend/server/deployment_resources.py
index b35eb7f7e..822b71949 100644
--- a/frontend/server/deployment_resources.py
+++ b/frontend/server/deployment_resources.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/frontend/server/model_catalog/client.py b/frontend/server/model_catalog/client.py
index 3068dcdf3..834eecc6d 100644
--- a/frontend/server/model_catalog/client.py
+++ b/frontend/server/model_catalog/client.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/frontend/server/model_catalog/protocol.py b/frontend/server/model_catalog/protocol.py
index 206281ae2..08757bb07 100644
--- a/frontend/server/model_catalog/protocol.py
+++ b/frontend/server/model_catalog/protocol.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/frontend/server/model_catalog/routes.py b/frontend/server/model_catalog/routes.py
index 308710364..528e65c98 100644
--- a/frontend/server/model_catalog/routes.py
+++ b/frontend/server/model_catalog/routes.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/frontend/server/model_catalog/service.py b/frontend/server/model_catalog/service.py
index 30437808d..29641c3d1 100644
--- a/frontend/server/model_catalog/service.py
+++ b/frontend/server/model_catalog/service.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/frontend/server/runtime_iam.py b/frontend/server/runtime_iam.py
index a9d485577..ff97a5a24 100644
--- a/frontend/server/runtime_iam.py
+++ b/frontend/server/runtime_iam.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py
index eebfac684..94269d1bc 100644
--- a/tests/cli/conftest.py
+++ b/tests/cli/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/provider_environment.py b/tests/cli/provider_environment.py
index 6ebd9d4e2..a8008e650 100644
--- a/tests/cli/provider_environment.py
+++ b/tests/cli/provider_environment.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/test_provider_environment_isolation.py b/tests/cli/test_provider_environment_isolation.py
index 32d11610f..b120d9e34 100644
--- a/tests/cli/test_provider_environment_isolation.py
+++ b/tests/cli/test_provider_environment_isolation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/test_studio_deploy_permissions.py b/tests/cli/test_studio_deploy_permissions.py
index 74c51068b..bc5c61083 100644
--- a/tests/cli/test_studio_deploy_permissions.py
+++ b/tests/cli/test_studio_deploy_permissions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/test_studio_deploy_role_confirmation.py b/tests/cli/test_studio_deploy_role_confirmation.py
index 01d10327c..512618631 100644
--- a/tests/cli/test_studio_deploy_role_confirmation.py
+++ b/tests/cli/test_studio_deploy_role_confirmation.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/cli/test_studio_update_permissions.py b/tests/cli/test_studio_update_permissions.py
index 2e64d0641..e2a926e07 100644
--- a/tests/cli/test_studio_update_permissions.py
+++ b/tests/cli/test_studio_update_permissions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/extensions/harness/sidecar_runtime/test_deploy.py b/tests/extensions/harness/sidecar_runtime/test_deploy.py
index 5f65861b8..a96d32034 100644
--- a/tests/extensions/harness/sidecar_runtime/test_deploy.py
+++ b/tests/extensions/harness/sidecar_runtime/test_deploy.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/extensions/harness/sidecar_runtime/test_mcp_client.py b/tests/extensions/harness/sidecar_runtime/test_mcp_client.py
index 0e30d7b6a..a6d7c5342 100644
--- a/tests/extensions/harness/sidecar_runtime/test_mcp_client.py
+++ b/tests/extensions/harness/sidecar_runtime/test_mcp_client.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/extensions/harness/sidecar_runtime/test_mcp_loopback_proxy.py b/tests/extensions/harness/sidecar_runtime/test_mcp_loopback_proxy.py
index eb261ae3c..e451d6ac1 100644
--- a/tests/extensions/harness/sidecar_runtime/test_mcp_loopback_proxy.py
+++ b/tests/extensions/harness/sidecar_runtime/test_mcp_loopback_proxy.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/frontend/server/test_runtime_iam.py b/tests/frontend/server/test_runtime_iam.py
index 279d0ffde..de7d42052 100644
--- a/tests/frontend/server/test_runtime_iam.py
+++ b/tests/frontend/server/test_runtime_iam.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/tests/test_studio_release_workflow.py b/tests/test_studio_release_workflow.py
index dac1ca2c1..cacb299c4 100644
--- a/tests/test_studio_release_workflow.py
+++ b/tests/test_studio_release_workflow.py
@@ -108,6 +108,15 @@ def test_smoke_gate_executes_bundle_as_low_privilege_user() -> None:
assert '"$permission_probe/run.sh"' in script
+def test_smoke_gate_sets_state_mode_before_transferring_ownership() -> None:
+ script = _smoke_script()
+
+ chmod_state = script.index('chmod 700 "$permission_probe_state"')
+ chown_state = script.index('sudo chown nobody "$permission_probe_state"')
+
+ assert chmod_state < chown_state
+
+
def test_smoke_gate_survives_platform_entrypoint_mode_normalization() -> None:
script = _smoke_script()
diff --git a/veadk/cli/studio_deploy_permissions.py b/veadk/cli/studio_deploy_permissions.py
index 0ac6598d5..018900e53 100644
--- a/veadk/cli/studio_deploy_permissions.py
+++ b/veadk/cli/studio_deploy_permissions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/veadk/cli/studio_update_permissions.py b/veadk/cli/studio_update_permissions.py
index 3f2a53a80..16361adf1 100644
--- a/veadk/cli/studio_update_permissions.py
+++ b/veadk/cli/studio_update_permissions.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
diff --git a/veadk/memory/long_term_memory_backends/tos_context_bucket_backend.py b/veadk/memory/long_term_memory_backends/tos_context_bucket_backend.py
index 3d6337adc..bd9856c77 100644
--- a/veadk/memory/long_term_memory_backends/tos_context_bucket_backend.py
+++ b/veadk/memory/long_term_memory_backends/tos_context_bucket_backend.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
From 4af48e7bead5fbf972172b651a08f4a606006553 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 02:21:34 +0800
Subject: [PATCH 42/46] fix(studio): normalize deferred schema compatibility
---
frontend/server/model_catalog/protocol.py | 6 ++
.../studio_tools/veadk_builtin_tools.py | 50 +++++++++++-
.../test_deferred_veadk_builtin_tools.py | 81 ++++++++++++++++++-
3 files changed, 134 insertions(+), 3 deletions(-)
diff --git a/frontend/server/model_catalog/protocol.py b/frontend/server/model_catalog/protocol.py
index 08757bb07..0dc83bb45 100644
--- a/frontend/server/model_catalog/protocol.py
+++ b/frontend/server/model_catalog/protocol.py
@@ -5,6 +5,12 @@
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
"""Lightweight contracts shared by model-catalog transport and services."""
diff --git a/frontend/server/studio_tools/veadk_builtin_tools.py b/frontend/server/studio_tools/veadk_builtin_tools.py
index 9d391d6ae..a2a5b8428 100644
--- a/frontend/server/studio_tools/veadk_builtin_tools.py
+++ b/frontend/server/studio_tools/veadk_builtin_tools.py
@@ -254,6 +254,52 @@ def _deferred_schema(name: str) -> tuple[str, dict[str, Any]]:
return description, copied_schema
+def _normalize_schema_value(value: Any) -> Any:
+ """Normalize the nullable schema form that varies across Pydantic runtimes."""
+
+ if isinstance(value, list):
+ return [_normalize_schema_value(item) for item in value]
+ if not isinstance(value, dict):
+ return value
+
+ normalized = {key: _normalize_schema_value(item) for key, item in value.items()}
+ any_of = normalized.get("anyOf")
+ if (
+ not isinstance(any_of, list)
+ or len(any_of) != 2
+ or "default" not in normalized
+ or normalized["default"] is not None
+ ):
+ return normalized
+
+ null_branches = [branch for branch in any_of if branch == {"type": "null"}]
+ value_branches = [branch for branch in any_of if branch != {"type": "null"}]
+ if (
+ len(null_branches) != 1
+ or len(value_branches) != 1
+ or not isinstance(value_branches[0], dict)
+ ):
+ return normalized
+
+ merged = {key: item for key, item in normalized.items() if key != "anyOf"}
+ for key, item in value_branches[0].items():
+ if key in merged and merged[key] != item:
+ return normalized
+ merged[key] = item
+ return merged
+
+
+def _schemas_match(
+ left: tuple[str, dict[str, Any]],
+ right: tuple[str, dict[str, Any]],
+) -> bool:
+ """Compare deferred declarations across supported Python/Pydantic forms."""
+
+ return left[0] == right[0] and _normalize_schema_value(
+ left[1]
+ ) == _normalize_schema_value(right[1])
+
+
def register_veadk_builtin_tools(
registry: StudioToolRegistry,
*,
@@ -284,7 +330,9 @@ async def execute(
current_tool = FunctionTool(
cast(Callable[..., Any], get_builtin_tool(current_name))
)
- if _schema(current_tool) != _deferred_schema(current_name):
+ if not _schemas_match(
+ _schema(current_tool), _deferred_schema(current_name)
+ ):
raise RuntimeError(
f"Deferred built-in declaration changed: {current_name}"
)
diff --git a/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
index e86744838..0e544f42e 100644
--- a/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
+++ b/tests/frontend/server/studio_tools/test_deferred_veadk_builtin_tools.py
@@ -54,8 +54,85 @@ def test_deferred_builtin_declaration_matches_canonical_tool(name: str) -> None:
cast(Callable[..., Any], veadk_builtin_tools.get_builtin_tool(name))
)
- assert veadk_builtin_tools._deferred_schema(name) == veadk_builtin_tools._schema(
- function_tool
+ assert veadk_builtin_tools._schemas_match(
+ veadk_builtin_tools._deferred_schema(name),
+ veadk_builtin_tools._schema(function_tool),
+ )
+
+
+def test_schema_match_normalizes_nullable_pydantic_variants() -> None:
+ static_schema = {
+ "type": "object",
+ "properties": {
+ "optional_label": {
+ "default": None,
+ "title": "Optional Label",
+ "type": "string",
+ }
+ },
+ }
+ runtime_schema = {
+ "type": "object",
+ "properties": {
+ "optional_label": {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "default": None,
+ "title": "Optional Label",
+ }
+ },
+ }
+
+ assert veadk_builtin_tools._schemas_match(
+ ("description", static_schema), ("description", runtime_schema)
+ )
+ assert static_schema["properties"]["optional_label"]["type"] == "string"
+ assert "anyOf" in runtime_schema["properties"]["optional_label"]
+
+
+@pytest.mark.parametrize(
+ "runtime_property",
+ [
+ {
+ "anyOf": [
+ {"type": "string"},
+ {"type": "integer"},
+ {"type": "null"},
+ ],
+ "default": None,
+ "title": "Optional Label",
+ },
+ {
+ "anyOf": [{"type": "string"}, {"type": "null"}],
+ "default": "fallback",
+ "title": "Optional Label",
+ },
+ {
+ "anyOf": [{"type": "integer"}, {"type": "null"}],
+ "default": None,
+ "title": "Optional Label",
+ },
+ ],
+)
+def test_schema_match_rejects_non_equivalent_unions(
+ runtime_property: dict[str, Any],
+) -> None:
+ static_schema = {
+ "type": "object",
+ "properties": {
+ "optional_label": {
+ "default": None,
+ "title": "Optional Label",
+ "type": "string",
+ }
+ },
+ }
+ runtime_schema = {
+ "type": "object",
+ "properties": {"optional_label": runtime_property},
+ }
+
+ assert not veadk_builtin_tools._schemas_match(
+ ("description", static_schema), ("description", runtime_schema)
)
From 969923f1e65383aad85fd5062c50e45ab004683d Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 02:33:37 +0800
Subject: [PATCH 43/46] test(studio): await capability terminal response
---
tests/cli/test_studio_rbac.py | 81 ++++++++++++++---------------------
1 file changed, 31 insertions(+), 50 deletions(-)
diff --git a/tests/cli/test_studio_rbac.py b/tests/cli/test_studio_rbac.py
index de37d59a5..544029f82 100644
--- a/tests/cli/test_studio_rbac.py
+++ b/tests/cli/test_studio_rbac.py
@@ -4348,71 +4348,52 @@ async def request(
"region": "cn-beijing",
"appName": "selected-agent",
}
+ headers = {"X-VeADK-Local-User": "developer"}
with TestClient(app) as client:
- incompatible = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+
+ def capability(request_params: dict[str, Any]) -> Any:
+ deadline = time.monotonic() + 2.0
+ while True:
+ response = client.get(
+ "/web/runtime-update-capability",
+ params=request_params,
+ headers=headers,
+ )
+ if response.status_code != 202:
+ return response
+ assert response.json()["recoveryStatus"] == "preparing"
+ if time.monotonic() >= deadline:
+ pytest.fail("Runtime update capability remained pending")
+ time.sleep(0.01)
+
+ incompatible = capability(params)
mode = "agent-unsupported"
- agent_unsupported = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+ agent_unsupported = capability(params)
mode = "empty"
- no_apps = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+ no_apps = capability(params)
mode = "multiple"
- multiple_apps = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+ multiple_apps = capability(params)
mode = "network"
- network_error = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
- network_without_app = client.get(
- "/web/runtime-update-capability",
- params={
+ network_error = capability(params)
+ network_without_app = capability(
+ {
"runtimeId": runtime.runtime_id,
"region": "cn-beijing",
- },
- headers={"X-VeADK-Local-User": "developer"},
+ }
)
mode = "server-error"
- server_error = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
- server_error_without_app = client.get(
- "/web/runtime-update-capability",
- params={
+ server_error = capability(params)
+ server_error_without_app = capability(
+ {
"runtimeId": runtime.runtime_id,
"region": "cn-beijing",
- },
- headers={"X-VeADK-Local-User": "developer"},
+ }
)
mode = "agent-server-error"
- agent_server_error = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+ agent_server_error = capability(params)
mode = "forbidden"
- forbidden = client.get(
- "/web/runtime-update-capability",
- params=params,
- headers={"X-VeADK-Local-User": "developer"},
- )
+ forbidden = capability(params)
assert incompatible.status_code == 200
assert incompatible.json()["canUpdate"] is False
From c01cd8e5f6ce466a41f4e6a1ed0828f36f3b924d Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 03:15:03 +0800
Subject: [PATCH 44/46] fix(studio): defer legacy identity initialization
---
.../harness-sidecar-release-gate.yaml | 4 +-
frontend/server/user_management/deployment.py | 4 +-
frontend/server/user_management/directory.py | 10 ++-
tests/frontend/server/test_user_management.py | 81 ++++++++++++++++---
tests/test_harness_sidecar_release_gate.py | 6 +-
5 files changed, 89 insertions(+), 16 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index e3aa3755e..0610afdfd 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -161,7 +161,9 @@ jobs:
tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \
tests/frontend/server/test_user_management.py::test_initialized_runtime_defers_transient_identity_directory_failure \
tests/frontend/server/test_user_management.py::test_initialized_runtime_deferred_identity_failure_remains_fail_closed \
- tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_still_blocks_startup \
+ tests/frontend/server/test_user_management.py::test_directory_normalizes_credential_resolver_failure \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_remains_fail_closed \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_retries_original_initialization_after_recovery \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/frontend/server/user_management/deployment.py b/frontend/server/user_management/deployment.py
index 82a0d74fc..60e884cf1 100644
--- a/frontend/server/user_management/deployment.py
+++ b/frontend/server/user_management/deployment.py
@@ -196,12 +196,12 @@ def initialize_runtime_roles(
super_admin, admins, developers, allow_initialize=not initialized
)
except UserManagementError as error:
- if initialized and error.code == "identity_unavailable":
+ if error.code == "identity_unavailable":
service.defer_initialization(
super_admin,
admins,
developers,
- allow_initialize=False,
+ allow_initialize=not initialized,
)
deferred = True
else:
diff --git a/frontend/server/user_management/directory.py b/frontend/server/user_management/directory.py
index 56f3bded8..758faf0bb 100644
--- a/frontend/server/user_management/directory.py
+++ b/frontend/server/user_management/directory.py
@@ -85,13 +85,21 @@ def __init__(
self.region = region
self.credentials = credentials
+ def _resolve_credentials(self) -> tuple[str, str, str | None]:
+ try:
+ return self.credentials()
+ except UserManagementError:
+ raise
+ except Exception as error:
+ raise UserManagementError(503, "identity_unavailable") from error
+
def _call(self, action: str, body: Any) -> Any:
import volcenginesdkcore
from volcenginesdkcore.rest import ApiException
sdk = _identity_sdk()
# Resolve credentials for each call so rotating cloud credentials remain valid
- ak, sk, token = self.credentials()
+ ak, sk, token = self._resolve_credentials()
# The generated SDK types host as None although it accepts URL strings
config: Any = volcenginesdkcore.Configuration()
config.ak, config.sk, config.session_token = ak, sk, token or ""
diff --git a/tests/frontend/server/test_user_management.py b/tests/frontend/server/test_user_management.py
index c107bdc34..8fe7b87c0 100644
--- a/tests/frontend/server/test_user_management.py
+++ b/tests/frontend/server/test_user_management.py
@@ -523,6 +523,24 @@ def list_users(self, body, **kwargs):
)
+def test_directory_normalizes_credential_resolver_failure():
+ from frontend.server.user_management.directory import IdentityDirectory
+
+ def unavailable_credentials():
+ raise RuntimeError("credential source unavailable")
+
+ directory = IdentityDirectory(
+ "pool",
+ "volcengine",
+ "cn-shanghai",
+ unavailable_credentials,
+ )
+ with pytest.raises(UserManagementError) as error:
+ directory._resolve_credentials()
+ assert error.value.status == 503
+ assert error.value.code == "identity_unavailable"
+
+
@pytest.mark.parametrize(
"provider,region",
[
@@ -710,8 +728,7 @@ def unavailable_groups():
service.principal_for(StudioPrincipal.from_claims({"sub": "oidc|owner"}))
-def test_uninitialized_runtime_identity_failure_still_blocks_startup(monkeypatch):
- import click
+def test_uninitialized_runtime_identity_failure_remains_fail_closed(monkeypatch):
from frontend.server.user_management import deployment
directory = Directory()
@@ -722,12 +739,54 @@ def unavailable_groups():
directory.groups = unavailable_groups
monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
- with pytest.raises(click.ClickException, match="identity_unavailable"):
- deployment.initialize_runtime_roles(
- pool_uid="pool",
- client_uid="client",
- provider="volcengine",
- identity_region="cn-shanghai",
- credentials=lambda: ("ak", "sk", "token"),
- environment={},
- )
+ service = deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={},
+ )
+
+ assert not directory.group_records
+ with pytest.raises(UserManagementError, match="identity_unavailable"):
+ service.principal_for(StudioPrincipal.from_claims({"sub": "oidc|owner"}))
+ assert not directory.group_records
+
+
+def test_uninitialized_runtime_retries_original_initialization_after_recovery(
+ monkeypatch,
+):
+ from frontend.server.user_management import deployment
+
+ directory = Directory()
+ original_groups = directory.groups
+ attempts = 0
+
+ def transient_groups():
+ nonlocal attempts
+ attempts += 1
+ if attempts == 1:
+ raise UserManagementError(503, "identity_unavailable")
+ return original_groups()
+
+ directory.groups = transient_groups
+ monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
+
+ service = deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={},
+ )
+
+ assert attempts == 1
+ assert not directory.group_records
+ principal = service.principal_for(
+ StudioPrincipal.from_claims({"sub": "oidc|owner"})
+ )
+ assert attempts == 2
+ assert principal.role == StudioRole.ADMIN
+ assert len(directory.group_records) == len(StudioRole)
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index d15959432..8012359ed 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -85,8 +85,12 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
"test_initialized_runtime_deferred_identity_failure_remains_fail_closed"
in backend_run
)
+ assert "test_directory_normalizes_credential_resolver_failure" in backend_run
assert (
- "test_uninitialized_runtime_identity_failure_still_blocks_startup"
+ "test_uninitialized_runtime_identity_failure_remains_fail_closed" in backend_run
+ )
+ assert (
+ "test_uninitialized_runtime_retries_original_initialization_after_recovery"
in backend_run
)
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
From d4beba2adb3979f61a765371bb2bba9b2f624ef8 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 08:42:00 +0800
Subject: [PATCH 45/46] fix(studio): require identity initialization at startup
---
.../harness-sidecar-release-gate.yaml | 6 +-
frontend/server/user_management/deployment.py | 24 +---
frontend/server/user_management/service.py | 37 -----
tests/frontend/server/test_user_management.py | 130 +++++-------------
tests/test_harness_sidecar_release_gate.py | 12 +-
5 files changed, 42 insertions(+), 167 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 0610afdfd..54c721f33 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -159,11 +159,9 @@ jobs:
tests/cli/test_studio_update.py::test_studio_update_preserves_custom_function_command \
tests/cli/test_studio_update.py::test_native_python_function_uses_mode_independent_bundle_entrypoint \
tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \
- tests/frontend/server/test_user_management.py::test_initialized_runtime_defers_transient_identity_directory_failure \
- tests/frontend/server/test_user_management.py::test_initialized_runtime_deferred_identity_failure_remains_fail_closed \
+ tests/frontend/server/test_user_management.py::test_initialized_runtime_identity_failure_blocks_studio_startup \
tests/frontend/server/test_user_management.py::test_directory_normalizes_credential_resolver_failure \
- tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_remains_fail_closed \
- tests/frontend/server/test_user_management.py::test_uninitialized_runtime_retries_original_initialization_after_recovery \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_blocks_startup_without_writes \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/frontend/server/user_management/deployment.py b/frontend/server/user_management/deployment.py
index 60e884cf1..9ecd9886f 100644
--- a/frontend/server/user_management/deployment.py
+++ b/frontend/server/user_management/deployment.py
@@ -190,34 +190,22 @@ def initialize_runtime_roles(
client_uid,
provider,
)
- deferred = False
try:
service.initialize(
super_admin, admins, developers, allow_initialize=not initialized
)
except UserManagementError as error:
- if error.code == "identity_unavailable":
- service.defer_initialization(
- super_admin,
- admins,
- developers,
- allow_initialize=not initialized,
- )
- deferred = True
- else:
- raise click.ClickException(
- f"Studio Identity role setup failed: {error.code}; check Identity "
- "user/group permissions and the legacy role lists, then retry"
- ) from error
+ raise click.ClickException(
+ f"Studio Identity role setup failed: {error.code}; check Identity user/group "
+ "permissions and the legacy role lists, then retry"
+ ) from error
legacy_keys = (
"VEADK_STUDIO_SUPER_ADMIN",
"VEADK_STUDIO_ADMINS",
"VEADK_STUDIO_DEVELOPERS",
)
- if (
- not deferred
- and (not initialized or any(environment.get(key) for key in legacy_keys))
- and (function_id := environment.get("VEADK_STUDIO_FUNCTION_ID"))
+ if (not initialized or any(environment.get(key) for key in legacy_keys)) and (
+ function_id := environment.get("VEADK_STUDIO_FUNCTION_ID")
):
from veadk.integrations.ve_faas.ve_faas import VeFaaS
diff --git a/frontend/server/user_management/service.py b/frontend/server/user_management/service.py
index 26a7c553e..0ba19ba01 100644
--- a/frontend/server/user_management/service.py
+++ b/frontend/server/user_management/service.py
@@ -45,41 +45,6 @@ def __init__(
# Only the immutable subject-to-UID mapping is cached, never permissions
self._subject_uids: dict[str, str] = {}
self._write_lock = RLock()
- self._deferred_initialization: tuple[str, str, str, bool] | None = None
-
- def defer_initialization(
- self,
- bootstrap: str = "",
- admins: str = "",
- developers: str = "",
- *,
- allow_initialize: bool = False,
- ) -> None:
- """Retry an already-authorized startup initialization on protected access."""
- with self._write_lock:
- self._deferred_initialization = (
- bootstrap,
- admins,
- developers,
- allow_initialize,
- )
-
- def _ensure_deferred_initialization(self) -> None:
- pending = self._deferred_initialization
- if pending is None:
- return
- with self._write_lock:
- pending = self._deferred_initialization
- if pending is None:
- return
- bootstrap, admins, developers, allow_initialize = pending
- self.initialize(
- bootstrap,
- admins,
- developers,
- allow_initialize=allow_initialize,
- )
- self._deferred_initialization = None
def initialize(
self,
@@ -214,7 +179,6 @@ def _role(self, user: PoolUser) -> StudioRole:
return roles[0] if len(roles) == 1 else StudioRole.USER
def _resolve_user(self, principal: StudioPrincipal | None) -> PoolUser:
- self._ensure_deferred_initialization()
if principal is None:
raise UserManagementError(401, "sign_in_required")
subject = principal.owner_id
@@ -337,7 +301,6 @@ def change_role(
expected_role: StudioRole,
) -> dict:
with self._write_lock:
- self._ensure_deferred_initialization()
self.initialize()
actor_user = self._require_super_admin(actor)
user = self.directory.user(user_uid)
diff --git a/tests/frontend/server/test_user_management.py b/tests/frontend/server/test_user_management.py
index 8fe7b87c0..b8e4474d5 100644
--- a/tests/frontend/server/test_user_management.py
+++ b/tests/frontend/server/test_user_management.py
@@ -665,70 +665,39 @@ def test_failed_runtime_migration_never_clears_old_environment(monkeypatch):
assert not directory.group_records
-def test_initialized_runtime_defers_transient_identity_directory_failure(monkeypatch):
+def test_initialized_runtime_identity_failure_blocks_studio_startup(monkeypatch):
+ import click
from frontend.server.user_management import deployment
directory = Directory()
seeded = UserManagementService(directory, "pool", "client", "volcengine")
seeded.initialize("owner", allow_initialize=True)
- original_groups = directory.groups
attempts = 0
- def transient_groups():
+ def unavailable_groups():
nonlocal attempts
attempts += 1
- if attempts == 1:
- raise UserManagementError(503, "identity_unavailable")
- return original_groups()
-
- directory.groups = transient_groups
- monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
-
- service = deployment.initialize_runtime_roles(
- pool_uid="pool",
- client_uid="client",
- provider="volcengine",
- identity_region="cn-shanghai",
- credentials=lambda: ("ak", "sk", "token"),
- environment={"VEADK_STUDIO_IDENTITY_ROLES": "1"},
- )
-
- principal = service.principal_for(
- StudioPrincipal.from_claims({"sub": "oidc|owner"})
- )
- assert attempts == 2
- assert principal.role == StudioRole.SUPER_ADMIN
-
-
-def test_initialized_runtime_deferred_identity_failure_remains_fail_closed(
- monkeypatch,
-):
- from frontend.server.user_management import deployment
-
- directory = Directory()
- seeded = UserManagementService(directory, "pool", "client", "volcengine")
- seeded.initialize("owner", allow_initialize=True)
-
- def unavailable_groups():
raise UserManagementError(503, "identity_unavailable")
directory.groups = unavailable_groups
monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
- service = deployment.initialize_runtime_roles(
- pool_uid="pool",
- client_uid="client",
- provider="volcengine",
- identity_region="cn-shanghai",
- credentials=lambda: ("ak", "sk", "token"),
- environment={"VEADK_STUDIO_IDENTITY_ROLES": "1"},
- )
-
- with pytest.raises(UserManagementError, match="identity_unavailable"):
- service.principal_for(StudioPrincipal.from_claims({"sub": "oidc|owner"}))
+ with pytest.raises(click.ClickException, match="identity_unavailable"):
+ deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={"VEADK_STUDIO_IDENTITY_ROLES": "1"},
+ )
+ assert attempts == 1
-def test_uninitialized_runtime_identity_failure_remains_fail_closed(monkeypatch):
+def test_uninitialized_runtime_identity_failure_blocks_startup_without_writes(
+ monkeypatch,
+):
+ import click
from frontend.server.user_management import deployment
directory = Directory()
@@ -738,55 +707,20 @@ def unavailable_groups():
directory.groups = unavailable_groups
monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
-
- service = deployment.initialize_runtime_roles(
- pool_uid="pool",
- client_uid="client",
- provider="volcengine",
- identity_region="cn-shanghai",
- credentials=lambda: ("ak", "sk", "token"),
- environment={},
- )
-
- assert not directory.group_records
- with pytest.raises(UserManagementError, match="identity_unavailable"):
- service.principal_for(StudioPrincipal.from_claims({"sub": "oidc|owner"}))
- assert not directory.group_records
-
-
-def test_uninitialized_runtime_retries_original_initialization_after_recovery(
- monkeypatch,
-):
- from frontend.server.user_management import deployment
-
- directory = Directory()
- original_groups = directory.groups
- attempts = 0
-
- def transient_groups():
- nonlocal attempts
- attempts += 1
- if attempts == 1:
- raise UserManagementError(503, "identity_unavailable")
- return original_groups()
-
- directory.groups = transient_groups
- monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
-
- service = deployment.initialize_runtime_roles(
- pool_uid="pool",
- client_uid="client",
- provider="volcengine",
- identity_region="cn-shanghai",
- credentials=lambda: ("ak", "sk", "token"),
- environment={},
+ monkeypatch.setattr(
+ deployment,
+ "clear_legacy_role_environment",
+ lambda **kwargs: pytest.fail("must not clear after failed Identity startup"),
)
- assert attempts == 1
- assert not directory.group_records
- principal = service.principal_for(
- StudioPrincipal.from_claims({"sub": "oidc|owner"})
- )
- assert attempts == 2
- assert principal.role == StudioRole.ADMIN
- assert len(directory.group_records) == len(StudioRole)
+ with pytest.raises(click.ClickException, match="identity_unavailable"):
+ deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={"VEADK_STUDIO_FUNCTION_ID": "function"},
+ )
+ assert directory.group_records == {}
+ assert all(not user.groups for user in directory.records.values())
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 8012359ed..4e489d23f 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -78,19 +78,11 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
)
assert "test_vefaas_deploy_updates_existing_application_in_place" in backend_run
assert (
- "test_initialized_runtime_defers_transient_identity_directory_failure"
- in backend_run
- )
- assert (
- "test_initialized_runtime_deferred_identity_failure_remains_fail_closed"
- in backend_run
+ "test_initialized_runtime_identity_failure_blocks_studio_startup" in backend_run
)
assert "test_directory_normalizes_credential_resolver_failure" in backend_run
assert (
- "test_uninitialized_runtime_identity_failure_remains_fail_closed" in backend_run
- )
- assert (
- "test_uninitialized_runtime_retries_original_initialization_after_recovery"
+ "test_uninitialized_runtime_identity_failure_blocks_startup_without_writes"
in backend_run
)
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
From 6dfd17306c86f90d5456d74d3adb37e376a8c926 Mon Sep 17 00:00:00 2001
From: "yanan.zhangyn"
Date: Wed, 16 Sep 2026 12:03:54 +0800
Subject: [PATCH 46/46] fix(studio): migrate identity roles before runtime
startup
---
.../harness-sidecar-release-gate.yaml | 7 +-
frontend/server/user_management/deployment.py | 39 +--
tests/cli/test_studio_self_update.py | 74 +++++-
tests/frontend/server/test_user_management.py | 223 +++++++++++-------
tests/test_harness_sidecar_release_gate.py | 15 +-
veadk/cli/cli_frontend.py | 3 -
6 files changed, 237 insertions(+), 124 deletions(-)
diff --git a/.github/workflows/harness-sidecar-release-gate.yaml b/.github/workflows/harness-sidecar-release-gate.yaml
index 54c721f33..6a40c69d7 100644
--- a/.github/workflows/harness-sidecar-release-gate.yaml
+++ b/.github/workflows/harness-sidecar-release-gate.yaml
@@ -161,7 +161,12 @@ jobs:
tests/test_cloud.py::test_vefaas_deploy_updates_existing_application_in_place \
tests/frontend/server/test_user_management.py::test_initialized_runtime_identity_failure_blocks_studio_startup \
tests/frontend/server/test_user_management.py::test_directory_normalizes_credential_resolver_failure \
- tests/frontend/server/test_user_management.py::test_uninitialized_runtime_identity_failure_blocks_startup_without_writes \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_with_readable_identity_never_writes \
+ tests/frontend/server/test_user_management.py::test_missing_marker_blocks_runtime_even_after_identity_was_prepared \
+ tests/frontend/server/test_user_management.py::test_deployment_identity_migration_allows_read_only_runtime_start \
+ tests/frontend/server/test_user_management.py::test_uninitialized_runtime_blocks_before_identity_access_or_writes \
+ tests/cli/test_studio_self_update.py::test_submit_latest_uses_fixed_deployment_ids_and_sts \
+ tests/cli/test_studio_self_update.py::test_self_update_identity_migration_failure_blocks_function_submit \
tests/test_studio_release_server.py::test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version \
tests/test_studio_release_server.py::test_tos_dependency_store_rejects_untrusted_agentkit_cli_url \
tests/test_harness_sidecar_release_gate.py
diff --git a/frontend/server/user_management/deployment.py b/frontend/server/user_management/deployment.py
index 9ecd9886f..571212ea0 100644
--- a/frontend/server/user_management/deployment.py
+++ b/frontend/server/user_management/deployment.py
@@ -176,14 +176,16 @@ def initialize_runtime_roles(
identity_region: str,
credentials: Callable[[], tuple[str, str, str | None]],
environment: Mapping[str, str],
- super_admin: str = "",
- admins: str = "",
- developers: str = "",
) -> UserManagementService:
- """Migrate older releases on startup before accepting authenticated requests"""
+ """Read and validate roles that a deployment identity already initialized."""
initialized = environment.get(
"VEADK_STUDIO_IDENTITY_ROLES", ""
).strip().lower() in {"1", "true", "yes"}
+ if not initialized:
+ raise click.ClickException(
+ "Studio Identity role setup failed: roles_not_initialized; run the "
+ "deployment Identity migration before starting Studio"
+ )
service = UserManagementService(
IdentityDirectory(pool_uid, provider, identity_region, credentials),
pool_uid,
@@ -191,37 +193,10 @@ def initialize_runtime_roles(
provider,
)
try:
- service.initialize(
- super_admin, admins, developers, allow_initialize=not initialized
- )
+ service.initialize(allow_initialize=False)
except UserManagementError as error:
raise click.ClickException(
f"Studio Identity role setup failed: {error.code}; check Identity user/group "
"permissions and the legacy role lists, then retry"
) from error
- legacy_keys = (
- "VEADK_STUDIO_SUPER_ADMIN",
- "VEADK_STUDIO_ADMINS",
- "VEADK_STUDIO_DEVELOPERS",
- )
- if (not initialized or any(environment.get(key) for key in legacy_keys)) and (
- function_id := environment.get("VEADK_STUDIO_FUNCTION_ID")
- ):
- from veadk.integrations.ve_faas.ve_faas import VeFaaS
-
- ak, sk, token = credentials()
- deployment = VeFaaS(
- access_key=ak,
- secret_key=sk,
- session_token=token or "",
- region=environment.get("VEADK_STUDIO_DEPLOY_REGION") or identity_region,
- project_name=environment.get("VEADK_STUDIO_PROJECT", "default"),
- provider=provider,
- )
- clear_legacy_role_environment(
- function_client=deployment.client,
- function_id=function_id,
- pool_uid=pool_uid,
- client_uid=client_uid,
- )
return service
diff --git a/tests/cli/test_studio_self_update.py b/tests/cli/test_studio_self_update.py
index a9042d140..88e18a04b 100644
--- a/tests/cli/test_studio_self_update.py
+++ b/tests/cli/test_studio_self_update.py
@@ -216,6 +216,7 @@ def _bundle(
unsafe_name: str | None = None,
include_cli_archive: bool = True,
cli_archive_content: bytes = b"pinned-cli",
+ identity_roles_support: bool = False,
) -> None:
veadk_wheel = "veadk_python-1.2.3-py3-none-any.whl"
wheel_path = path.parent / veadk_wheel
@@ -224,6 +225,11 @@ def _bundle(
"veadk_python-1.2.3.dist-info/METADATA",
"Metadata-Version: 2.1\nName: veadk-python\nVersion: 1.2.3\n",
)
+ if identity_roles_support:
+ wheel.writestr(
+ "frontend/server/user_management/service.py",
+ '"""Identity-backed Studio role management."""\n',
+ )
wheel_digest = hashlib.sha256(wheel_path.read_bytes()).hexdigest()
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("run.sh", "#!/bin/bash\n")
@@ -575,7 +581,7 @@ def test_submit_latest_uses_fixed_deployment_ids_and_sts(
tmp_path: Path,
) -> None:
archive = tmp_path / "source.zip"
- _bundle(archive)
+ _bundle(archive, identity_roles_support=True)
content = archive.read_bytes()
manifest = StudioReleaseManifest(
version="20260724153045",
@@ -605,6 +611,7 @@ def __init__(self, **kwargs: str) -> None:
self.client = object()
def submit_application_code_bundle_update(self, **kwargs: Any) -> None:
+ captured.setdefault("events", []).append("function_submit")
package = Path(str(kwargs["path"]))
assert "--provider byteplus" in (package / "run.sh").read_text(
encoding="utf-8"
@@ -613,6 +620,7 @@ def submit_application_code_bundle_update(self, **kwargs: Any) -> None:
captured["update"] = kwargs
def _resources(**kwargs: Any) -> dict[str, str]:
+ captured.setdefault("events", []).append("identity_migration")
captured["resource_request"] = kwargs
return {
"VEADK_STUDIO_TOS_BUCKET": "studio-bucket",
@@ -688,6 +696,8 @@ def _deploy_scheduler_for_update(
assert resource_request["application_id"] == "application-id"
assert resource_request["function_id"] == "function-id"
assert resource_request["function_client"] is not None
+ assert resource_request["migrate_identity_roles"] is True
+ assert captured["events"] == ["identity_migration", "function_submit"]
scheduler_update = captured["scheduler_update"]
assert scheduler_update["studio_function_id"] == "function-id"
assert scheduler_update["package_root"].name == "package"
@@ -708,6 +718,68 @@ def _deploy_scheduler_for_update(
assert status["startedAt"] > 0
+def test_self_update_identity_migration_failure_blocks_function_submit(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ archive = tmp_path / "source.zip"
+ _bundle(archive, identity_roles_support=True)
+ content = archive.read_bytes()
+ manifest = StudioReleaseManifest(
+ version="20260724153045",
+ git_sha="a" * 40,
+ sha256=hashlib.sha256(content).hexdigest(),
+ size=len(content),
+ created_at="2026-07-24T15:30:45+08:00",
+ )
+ submits = 0
+
+ class _Store:
+ def latest_manifest(self) -> StudioReleaseManifest:
+ return manifest
+
+ def release_catalog(self) -> list[StudioReleaseManifest]:
+ return [manifest]
+
+ def download_bundle(
+ self, release: StudioReleaseManifest, destination: Path
+ ) -> None:
+ assert release == manifest
+ destination.write_bytes(content)
+
+ class _VeFaaS:
+ def __init__(self, **_kwargs: str) -> None:
+ self.client = object()
+
+ def submit_application_code_bundle_update(self, **_kwargs: Any) -> None:
+ nonlocal submits
+ submits += 1
+
+ def _migration_failure(**kwargs: Any) -> dict[str, str]:
+ assert kwargs["migrate_identity_roles"] is True
+ raise StudioReleaseError("Identity deployment migration failed")
+
+ updater = StudioSelfUpdater(
+ settings=_settings(deployment_region="cn-shanghai"),
+ credential_resolver=lambda: ("sts-ak", "sts-sk", "sts-token"),
+ branding_logo=None,
+ )
+ monkeypatch.setattr(updater, "_store", lambda *_args: _Store())
+ monkeypatch.setattr("veadk.integrations.ve_faas.ve_faas.VeFaaS", _VeFaaS)
+ monkeypatch.setattr(
+ "frontend.server.studio_update_resources.reconcile_studio_update_resources",
+ _migration_failure,
+ )
+ monkeypatch.setenv("VEADK_STUDIO_RELEASE_VERSION", "bundled")
+
+ with pytest.raises(StudioReleaseError, match="Identity deployment migration"):
+ updater.submit_latest()
+ assert submits == 0
+ status = updater.status()
+ assert status["progressStage"] == "error"
+ assert status["errorStage"] == "provisioning"
+
+
def test_submit_latest_continues_main_function_when_scheduler_update_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
diff --git a/tests/frontend/server/test_user_management.py b/tests/frontend/server/test_user_management.py
index b8e4474d5..63cb23828 100644
--- a/tests/frontend/server/test_user_management.py
+++ b/tests/frontend/server/test_user_management.py
@@ -81,6 +81,39 @@ def remove(self, group_uid, user_uid):
)
+class ReadOnlyDirectory:
+ """Expose Identity reads while failing the test on every write attempt."""
+
+ def __init__(self, source: Directory):
+ self.source = source
+ self.write_attempts: list[str] = []
+
+ def users(self):
+ return self.source.users()
+
+ def user(self, uid):
+ return self.source.user(uid)
+
+ def groups(self):
+ return self.source.groups()
+
+ def _reject(self, operation: str):
+ self.write_attempts.append(operation)
+ pytest.fail(f"Runtime must not write Identity through {operation}")
+
+ def create_group(self, *_args, **_kwargs):
+ self._reject("create_group")
+
+ def describe_group(self, *_args, **_kwargs):
+ self._reject("describe_group")
+
+ def add(self, *_args, **_kwargs):
+ self._reject("add")
+
+ def remove(self, *_args, **_kwargs):
+ self._reject("remove")
+
+
@pytest.fixture
def setup_service():
directory = Directory()
@@ -549,120 +582,136 @@ def unavailable_credentials():
("byteplus", "ap-southeast-1"),
],
)
-@pytest.mark.parametrize(
- "admins,developers,expected",
- [
- ("", "", (StudioRole.ADMIN, StudioRole.ADMIN)),
- (
- "owner@example.com",
- "member@example.com",
- (StudioRole.ADMIN, StudioRole.DEVELOPER),
- ),
- ],
-)
-def test_old_updater_runtime_migrates_then_cleans_cloud_environment(
- monkeypatch, provider, region, admins, developers, expected
+def test_old_updater_runtime_requires_predeployment_identity_migration(
+ monkeypatch, provider, region
):
- from types import SimpleNamespace
+ import click
from frontend.server.user_management import deployment
- directory = Directory()
+ directory = ReadOnlyDirectory(Directory())
monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
environment = {
"OAUTH2_USER_POOL_ID": "pool",
"OAUTH2_USER_POOL_CLIENT_ID": "client",
"VEADK_STUDIO_FUNCTION_ID": "function",
"VEADK_STUDIO_DEPLOY_REGION": region,
- "VEADK_STUDIO_ADMINS": admins,
- "VEADK_STUDIO_DEVELOPERS": developers,
+ "VEADK_STUDIO_ADMINS": "owner@example.com",
+ "VEADK_STUDIO_DEVELOPERS": "member@example.com",
"UNRELATED": "preserve",
}
- updates = []
- connections = []
+ monkeypatch.setattr(
+ deployment,
+ "clear_legacy_role_environment",
+ lambda **kwargs: pytest.fail("Runtime must not update Function configuration"),
+ )
- def update(request):
- assert any(
- '"initialized": true' in group.description for group in directory.groups()
+ with pytest.raises(click.ClickException, match="roles_not_initialized"):
+ deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider=provider,
+ identity_region=region,
+ credentials=lambda: ("ak", "sk", "token"),
+ environment=environment,
)
- updates.append(request)
- environment.update({item.key: item.value for item in request.envs})
-
- client = SimpleNamespace(
- get_function=lambda request: SimpleNamespace(
- envs=[SimpleNamespace(key=k, value=v) for k, v in environment.items()]
- ),
- update_function=update,
- )
+ assert directory.write_attempts == []
+
+
+def test_uninitialized_runtime_with_readable_identity_never_writes(monkeypatch):
+ import click
+ from frontend.server.user_management import deployment
+
+ directory = ReadOnlyDirectory(Directory())
+ monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
monkeypatch.setattr(
- "veadk.integrations.ve_faas.ve_faas.VeFaaS",
- lambda **kwargs: connections.append(kwargs) or SimpleNamespace(client=client),
- )
- options = dict(
- pool_uid="pool",
- client_uid="client",
- provider=provider,
- identity_region=region,
- credentials=lambda: ("ak", "sk", "token"),
- admins=admins,
- developers=developers,
- )
- stale_instance_environment = dict(environment)
- service = deployment.initialize_runtime_roles(
- **options, environment=stale_instance_environment
- )
- assert len(updates) == 1
- assert (
- environment["VEADK_STUDIO_ADMINS"]
- == environment["VEADK_STUDIO_DEVELOPERS"]
- == ""
+ deployment,
+ "clear_legacy_role_environment",
+ lambda **kwargs: pytest.fail("Runtime must not update Function configuration"),
)
- assert environment["VEADK_STUDIO_IDENTITY_ROLES"] == "1"
- assert environment["UNRELATED"] == "preserve"
- assert connections[0]["provider"] == provider
- assert connections[0]["region"] == region
- for uid, role in zip(("owner", "member"), expected):
- assert (
- service.principal_for(
- StudioPrincipal.from_claims({"sub": f"oidc|{uid}"})
- ).role
- == role
+
+ with pytest.raises(click.ClickException, match="roles_not_initialized"):
+ deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
+ environment={"VEADK_STUDIO_FUNCTION_ID": "function"},
)
- # Another instance of the same immutable revision still has old env values
- service._set_role(directory.user("member"), StudioRole.USER)
- restarted = deployment.initialize_runtime_roles(
- **options, environment=stale_instance_environment
- )
- assert len(updates) == 1
- assert (
- restarted.principal_for(
- StudioPrincipal.from_claims({"sub": "oidc|member"})
- ).role
- == StudioRole.USER
- )
+ assert directory.write_attempts == []
-def test_failed_runtime_migration_never_clears_old_environment(monkeypatch):
+def test_missing_marker_blocks_runtime_even_after_identity_was_prepared(monkeypatch):
import click
from frontend.server.user_management import deployment
- directory = Directory()
+ backing = Directory()
+ UserManagementService(backing, "pool", "client", "volcengine").initialize(
+ allow_initialize=True
+ )
+ directory = ReadOnlyDirectory(backing)
monkeypatch.setattr(deployment, "IdentityDirectory", lambda *args: directory)
monkeypatch.setattr(
deployment,
"clear_legacy_role_environment",
- lambda **kwargs: pytest.fail("must not clear before successful migration"),
+ lambda **kwargs: pytest.fail("Runtime must not update Function configuration"),
)
- with pytest.raises(click.ClickException, match="legacy_role_member_not_unique"):
+
+ with pytest.raises(click.ClickException, match="roles_not_initialized"):
deployment.initialize_runtime_roles(
pool_uid="pool",
client_uid="client",
provider="volcengine",
- identity_region="cn-beijing",
- credentials=lambda: ("ak", "sk", ""),
+ identity_region="cn-shanghai",
+ credentials=lambda: ("ak", "sk", "token"),
environment={"VEADK_STUDIO_FUNCTION_ID": "function"},
- admins="missing@example.com",
)
- assert not directory.group_records
+ assert directory.write_attempts == []
+
+
+def test_deployment_identity_migration_allows_read_only_runtime_start(monkeypatch):
+ from frontend.server.user_management import deployment
+
+ backing = Directory()
+ selected_directory = backing
+ monkeypatch.setattr(
+ deployment, "IdentityDirectory", lambda *args: selected_directory
+ )
+ environment = deployment.prepare_identity_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ region="cn-shanghai",
+ access_key="ak",
+ secret_key="sk",
+ session_token="token",
+ )
+ assert len(backing.group_records) == len(StudioRole)
+ assert environment == {
+ "VEADK_STUDIO_IDENTITY_ROLES": "1",
+ "VEADK_STUDIO_SUPER_ADMIN": "",
+ "VEADK_STUDIO_ADMINS": "",
+ "VEADK_STUDIO_DEVELOPERS": "",
+ }
+
+ directory = ReadOnlyDirectory(backing)
+ selected_directory = directory
+ monkeypatch.setattr(
+ deployment,
+ "clear_legacy_role_environment",
+ lambda **kwargs: pytest.fail("Runtime must not update Function configuration"),
+ )
+ service = deployment.initialize_runtime_roles(
+ pool_uid="pool",
+ client_uid="client",
+ provider="volcengine",
+ identity_region="cn-shanghai",
+ credentials=lambda: ("runtime-ak", "runtime-sk", "runtime-token"),
+ environment=environment,
+ )
+
+ assert len(service.role_groups) == len(StudioRole)
+ assert directory.write_attempts == []
def test_initialized_runtime_identity_failure_blocks_studio_startup(monkeypatch):
@@ -694,15 +743,16 @@ def unavailable_groups():
assert attempts == 1
-def test_uninitialized_runtime_identity_failure_blocks_startup_without_writes(
- monkeypatch,
-):
+def test_uninitialized_runtime_blocks_before_identity_access_or_writes(monkeypatch):
import click
from frontend.server.user_management import deployment
directory = Directory()
+ reads = 0
def unavailable_groups():
+ nonlocal reads
+ reads += 1
raise UserManagementError(503, "identity_unavailable")
directory.groups = unavailable_groups
@@ -713,7 +763,7 @@ def unavailable_groups():
lambda **kwargs: pytest.fail("must not clear after failed Identity startup"),
)
- with pytest.raises(click.ClickException, match="identity_unavailable"):
+ with pytest.raises(click.ClickException, match="roles_not_initialized"):
deployment.initialize_runtime_roles(
pool_uid="pool",
client_uid="client",
@@ -722,5 +772,6 @@ def unavailable_groups():
credentials=lambda: ("ak", "sk", "token"),
environment={"VEADK_STUDIO_FUNCTION_ID": "function"},
)
+ assert reads == 0
assert directory.group_records == {}
assert all(not user.groups for user in directory.records.values())
diff --git a/tests/test_harness_sidecar_release_gate.py b/tests/test_harness_sidecar_release_gate.py
index 4e489d23f..6f87a53bd 100644
--- a/tests/test_harness_sidecar_release_gate.py
+++ b/tests/test_harness_sidecar_release_gate.py
@@ -81,8 +81,21 @@ def test_sidecar_release_gate_runs_backend_and_frontend_in_parallel() -> None:
"test_initialized_runtime_identity_failure_blocks_studio_startup" in backend_run
)
assert "test_directory_normalizes_credential_resolver_failure" in backend_run
+ assert "test_uninitialized_runtime_with_readable_identity_never_writes" in (
+ backend_run
+ )
+ assert "test_missing_marker_blocks_runtime_even_after_identity_was_prepared" in (
+ backend_run
+ )
+ assert "test_deployment_identity_migration_allows_read_only_runtime_start" in (
+ backend_run
+ )
+ assert "test_uninitialized_runtime_blocks_before_identity_access_or_writes" in (
+ backend_run
+ )
+ assert "test_submit_latest_uses_fixed_deployment_ids_and_sts" in backend_run
assert (
- "test_uninitialized_runtime_identity_failure_blocks_startup_without_writes"
+ "test_self_update_identity_migration_failure_blocks_function_submit"
in backend_run
)
assert "test_tos_dependency_store_accepts_manifest_pinned_agentkit_cli_version" in (
diff --git a/veadk/cli/cli_frontend.py b/veadk/cli/cli_frontend.py
index c0e4fb282..4b57f3f3d 100644
--- a/veadk/cli/cli_frontend.py
+++ b/veadk/cli/cli_frontend.py
@@ -11647,9 +11647,6 @@ async def _body():
identity_region=_identity_region(),
credentials=_resolve_ve_credentials,
environment=os.environ,
- super_admin=studio_super_admin or "",
- admins=studio_admins or "",
- developers=studio_developers or "",
)
os.environ["VEADK_STUDIO_IDENTITY_ROLES"] = "1"
for legacy_key in (