From 0868120b8df5d953723e2f4407147fad1089ccc5 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Mon, 7 Sep 2026 15:59:00 +0800 Subject: [PATCH 1/3] fix(skills)!: prepare bundled runtime skills before agent startup Replace managed runtime network installation with the image-owned offline helper and await Chat discovery. BREAKING CHANGE: managed runtimes require a bundled-skills sandbox image; remove DEPLOY_SKILL_SOURCE overrides. --- apps/ui/.env.example | 5 +- .../features/chat/devbox/chat-runtime.test.ts | 80 ++++++++++-- .../src/features/chat/devbox/chat-runtime.ts | 30 ++--- apps/ui/src/features/chat/runtime/tools.ts | 8 +- .../chat/tool/chat-skill-tool.test.ts | 6 +- .../src/features/chat/tool/chat-skill-tool.ts | 4 +- .../deploy/task/failure-summary.test.ts | 4 +- .../features/deploy/task/failure-summary.ts | 2 +- .../task/runner.github-ai-proxy.test.ts | 49 +++---- .../src/features/deploy/task/runner.test.ts | 36 +----- apps/ui/src/features/deploy/task/runner.ts | 30 +++-- .../features/deploy/task/runtime-config.ts | 12 -- .../deploy/task/timeout-policy.test.ts | 1 + .../features/deploy/task/timeout-policy.ts | 2 +- .../features/sealos-skills/install.test.ts | 121 +++--------------- apps/ui/src/features/sealos-skills/install.ts | 96 ++------------ charts/brain-system/values.local.example.yaml | 4 - charts/brain-system/values.yaml | 3 - ...ks-under-leases-and-guarded-transitions.md | 10 ++ turbo.json | 1 - 20 files changed, 179 insertions(+), 325 deletions(-) diff --git a/apps/ui/.env.example b/apps/ui/.env.example index d009f848c..7dfa3979b 100644 --- a/apps/ui/.env.example +++ b/apps/ui/.env.example @@ -56,10 +56,9 @@ DEVBOX_API_BASE_URL= TEMPLATE_PROVIDER_URL= DEVBOX_JWT_SIGNING_KEY= -DEVBOX_RUNTIME_IMAGE=ghcr.io/labring-actions/devbox-base-images/sandbox-v1:2026-6-stable-zh-cn-amd64 +# Required for managed Skills: immutable sandbox/v1 image built with the offline Skill bundle. +DEVBOX_RUNTIME_IMAGE= DEVBOX_JWT_TTL_SECONDS=14400 -# Skill repository source installed into Chat Agent and AI deployment Devboxes. -DEPLOY_SKILL_SOURCE= # Public Streamable HTTP endpoint reachable from deployment Devboxes. DEPLOY_AGENT_MCP_URL=https://brain.example.com/api/deploy-agent/mcp/v1 # Optional comma-separated browser Origin allowlist. Codex MCP clients do not diff --git a/apps/ui/src/features/chat/devbox/chat-runtime.test.ts b/apps/ui/src/features/chat/devbox/chat-runtime.test.ts index 1ce660ca2..b6e9f56b5 100644 --- a/apps/ui/src/features/chat/devbox/chat-runtime.test.ts +++ b/apps/ui/src/features/chat/devbox/chat-runtime.test.ts @@ -6,8 +6,6 @@ mock.module("./lifecycle-registration", () => ({ recordChatDevboxActivity: () => Promise.resolve(), })); -const INSTALL_MARKER_RE = /sealos-skills-install\.marker/; - const { bootstrapChatDevboxIfNeeded, getChatDevboxSkillsSnapshot, @@ -30,6 +28,12 @@ test("background Skill warmup is shared and publishes metadata after discovery", const originalToken = process.env.DEVBOX_TOKEN; let execCalls = 0; let installCommand = ""; + let emptyDiscovery = false; + let preparationFails = false; + let discoveryGate: Promise | undefined; + let onDiscovery: (() => void) | undefined; + const upstreamIds: string[] = []; + const originalImage = process.env.DEVBOX_RUNTIME_IMAGE; process.env.DEVBOX_API_BASE_URL = "https://devbox.test"; process.env.DEVBOX_TOKEN = "test-token"; @@ -39,6 +43,7 @@ test("background Skill warmup is shared and publishes metadata after discovery", ) => { const url = new URL(String(input)); if (url.searchParams.has("upstreamID")) { + upstreamIds.push(url.searchParams.get("upstreamID") ?? ""); return Response.json({ data: { items: [{ name: "existing-runtime" }] }, }); @@ -57,18 +62,28 @@ test("background Skill warmup is shared and publishes metadata after discovery", command?: string[]; }; const command = body.command?.at(-1) ?? ""; - if (command.includes("skills@1.5.20 add")) { + if (command.includes("sealai-prepare-skills")) { installCommand = command; + if (preparationFails) { + return Response.json({ + data: { exitCode: 1, stderr: "private raw error", stdout: "" }, + }); + } } if (command.includes("find ")) { - return Response.json({ + onDiscovery?.(); + const response = Response.json({ data: { exitCode: 0, stderr: "", - stdout: - "/home/devbox/project/.agents/skills/sealos-deploy/SKILL.md\n", + stdout: emptyDiscovery + ? "" + : "/home/devbox/project/.agents/skills/sealos-deploy/SKILL.md\n", }, }); + return discoveryGate == null + ? response + : discoveryGate.then(() => response); } if (command.includes("cat --")) { return Response.json({ @@ -102,12 +117,57 @@ test("background Skill warmup is shared and publishes metadata after discovery", ["sealos-deploy"] ); assert.ok(execCalls >= 3); - assert.match(installCommand, INSTALL_MARKER_RE); - assert.ok( - installCommand.indexOf('cat -- "$install_marker"') < - installCommand.indexOf("install_output") + assert.ok(installCommand.includes("/usr/local/bin/sealai-prepare-skills")); + assert.ok(!installCommand.includes("npx")); + emptyDiscovery = true; + await assert.rejects(warmChatDevboxSkills(options)); + emptyDiscovery = false; + preparationFails = true; + await assert.rejects( + warmChatDevboxSkills(options), + (error: unknown) => + error instanceof Error && !error.message.includes("private raw error") ); + preparationFails = false; + assert.equal( + (await warmChatDevboxSkills(options))[0]?.name, + "sealos-deploy" + ); + const previousIdentity = upstreamIds.at(-1); + process.env.DEVBOX_RUNTIME_IMAGE = + "example.test/sandbox@sha256:new-test-image"; + assert.deepEqual(getChatDevboxSkillsSnapshot(options), []); + await warmChatDevboxSkills(options); + assert.notEqual(upstreamIds.at(-1), previousIdentity); + + const { buildChatToolset } = await import("../runtime/tools"); + const gate = Promise.withResolvers(); + const started = Promise.withResolvers(); + discoveryGate = gate.promise; + onDiscovery = started.resolve; + let toolsetReady = false; + const toolset = buildChatToolset({ + chatId: "skills-first-turn", + kubeconfig: options.kubeconfig, + kubernetesNamespace: options.namespace, + workspaceActor: "test-actor", + workspaceUserUid: "test-user", + }).then((result) => { + toolsetReady = true; + return result; + }); + await started.promise; + assert.equal(toolsetReady, false); + gate.resolve(); + const ready = await toolset; + assert.ok(ready.systemPrompt.includes("sealos-deploy")); + assert.ok(ready.tools.loadSkill); } finally { + if (originalImage === undefined) { + delete process.env.DEVBOX_RUNTIME_IMAGE; + } else { + process.env.DEVBOX_RUNTIME_IMAGE = originalImage; + } globalThis.fetch = originalFetch; if (originalBaseUrl === undefined) { delete process.env.DEVBOX_API_BASE_URL; diff --git a/apps/ui/src/features/chat/devbox/chat-runtime.ts b/apps/ui/src/features/chat/devbox/chat-runtime.ts index 1992858ab..9491e014e 100644 --- a/apps/ui/src/features/chat/devbox/chat-runtime.ts +++ b/apps/ui/src/features/chat/devbox/chat-runtime.ts @@ -8,9 +8,9 @@ import { discoverChatDevboxSkills, } from "@/features/chat/tool/devbox-skills"; import { + assertBundledSkillsConfiguration, buildSealosSkillsInstallCommand, - getSealosSkillsSourceFromEnv, - SEALOS_SKILLS_CLI_VERSION, + SEALOS_SKILLS_RUNTIME_CONTRACT, } from "@/features/sealos-skills/install"; import { createDevbox, @@ -35,7 +35,7 @@ const DEVBOX_COMMAND_TIMEOUT_SECONDS = 60; const DEVBOX_WRITE_TIMEOUT_SECONDS = 60; const DEVBOX_READ_TIMEOUT_SECONDS = 60; const DEVBOX_WARMUP_TIMEOUT_SECONDS = 30; -const DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS = 180; +const DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS = 30; const chatDevboxSkillSnapshots = new Map(); const chatDevboxSkillWarmups = new Map>(); @@ -79,6 +79,9 @@ export function warmChatDevboxSkills( const sandbox = createChatDevboxSandbox(options); const warmup = discoverChatDevboxSkills(sandbox) .then((skills) => { + if (skills.length === 0) { + throw new Error("Chat runtime has no usable Skills."); + } chatDevboxSkillSnapshots.set(cacheKey, skills); return skills.map((skill) => ({ ...skill })); }) @@ -118,7 +121,9 @@ export function hashKubeconfigForDevbox(kubeconfig: string): string { function hashRuntimeIdentity(kubeconfig: string, namespace: string): string { return createHash("sha256") - .update(`${namespace}|${hashKubeconfigForDevbox(kubeconfig)}`) + .update( + `${namespace}|${hashKubeconfigForDevbox(kubeconfig)}|${getDevboxDefaultImage() ?? "provider-default"}|${SEALOS_SKILLS_RUNTIME_CONTRACT}` + ) .digest("hex") .slice(0, 32); } @@ -128,8 +133,7 @@ function chatDevboxSkillCacheKey(options: ChatDevboxRuntimeOptions): string { normalizeKubeconfig(options.kubeconfig), options.namespace ); - const skillSource = getSealosSkillsSourceFromEnv(process.env); - return `${runtimeHash}|${skillSource}|${SEALOS_SKILLS_CLI_VERSION}`; + return runtimeHash; } function runtimeName(runtimeHash: string): string { @@ -307,23 +311,14 @@ async function installChatSkills( const result = await runDevboxCommand( authNamespace, name, - buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: true, - skillSource: getSealosSkillsSourceFromEnv(process.env), - timeoutSeconds: DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS, - }), + buildSealosSkillsInstallCommand(), DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS, signal ); if (result.exitCode !== 0) { throw new Error( - [ - "Chat Devbox Sealos skills installation failed.", - result.stderr.trim() || result.stdout.trim(), - ] - .filter(Boolean) - .join(" ") + "Chat runtime Skill preparation failed. Check the runtime image bundle." ); } } @@ -334,6 +329,7 @@ async function ensureChatDevbox( ): Promise<{ name: string; skippedExisting: boolean }> { signal?.throwIfAborted(); const kubeconfig = normalizeKubeconfig(options.kubeconfig); + assertBundledSkillsConfiguration(process.env); const authNamespace = options.namespace; const runtimeHash = hashRuntimeIdentity(kubeconfig, options.namespace); const name = runtimeName(runtimeHash); diff --git a/apps/ui/src/features/chat/runtime/tools.ts b/apps/ui/src/features/chat/runtime/tools.ts index 77898c392..3d18c0350 100644 --- a/apps/ui/src/features/chat/runtime/tools.ts +++ b/apps/ui/src/features/chat/runtime/tools.ts @@ -6,7 +6,7 @@ import { executeEmitGenUISpec, genUISpecInputSchema, } from "@/features/chat/agui/gen-ui-tool"; -import { getChatDevboxSkillsSnapshot } from "@/features/chat/devbox/chat-runtime"; +import { warmChatDevboxSkills } from "@/features/chat/devbox/chat-runtime"; import type { AssistantContextPayload } from "@/features/chat/persistence/types"; import { createChatBashTool } from "@/features/chat/tool/chat-bash-tool"; import { createSearchDeployCatalogTool } from "@/features/chat/tool/chat-deploy-catalog-tool"; @@ -54,8 +54,8 @@ export interface ChatToolset { * Assemble the per-request tool registry + system prompt. * * - Skill index drives both the `loadSkill` tool and the discovery prompt addendum. - * - The shared Chat Devbox remains lazy; Skill metadata comes from the - * background warmup cache and never blocks the chat stream preflight. + * - Await shared Devbox preparation/discovery before the model starts; + * page-load background warmup is only a latency optimization. */ export async function buildChatToolset({ billingActor, @@ -79,7 +79,7 @@ export async function buildChatToolset({ kubeconfig, namespace: kubernetesNamespace, }); - const skillIndex = getChatDevboxSkillsSnapshot({ + const skillIndex = await warmChatDevboxSkills({ kubeconfig, namespace: kubernetesNamespace, }); diff --git a/apps/ui/src/features/chat/tool/chat-skill-tool.test.ts b/apps/ui/src/features/chat/tool/chat-skill-tool.test.ts index 8ec5d680d..f5485d568 100644 --- a/apps/ui/src/features/chat/tool/chat-skill-tool.test.ts +++ b/apps/ui/src/features/chat/tool/chat-skill-tool.test.ts @@ -56,10 +56,14 @@ test("Skill reads forward the AI SDK abort signal to the Devbox sandbox", async const loadSkill = createLoadSkillTool([skill], sandbox); const loadResource = createLoadSkillResourceTool([skill], sandbox); - await getExecute(loadSkill)( + const loaded = await getExecute(loadSkill)( { intention: "test skill loading", name: "sealos-deploy" }, { abortSignal: controller.signal } ); + assert.equal( + Reflect.get(loaded as object, "skillDirectory"), + skill.skillDirectory + ); await getExecute(loadResource)( { intention: "test skill resource loading", diff --git a/apps/ui/src/features/chat/tool/chat-skill-tool.ts b/apps/ui/src/features/chat/tool/chat-skill-tool.ts index f40bf1768..95b809b07 100644 --- a/apps/ui/src/features/chat/tool/chat-skill-tool.ts +++ b/apps/ui/src/features/chat/tool/chat-skill-tool.ts @@ -98,7 +98,7 @@ export function createLoadSkillTool( ); return { name: skill.name, - skillDirectory: skill.folderName, + skillDirectory: skill.skillDirectory, content: stripSkillFrontmatter(raw), }; }, @@ -138,7 +138,7 @@ export function createLoadSkillResourceTool( ); return { name: skill.name, - skillDirectory: skill.folderName, + skillDirectory: skill.skillDirectory, path: resourcePath.trim(), content, }; diff --git a/apps/ui/src/features/deploy/task/failure-summary.test.ts b/apps/ui/src/features/deploy/task/failure-summary.test.ts index 3cbac2c43..710ef5ff8 100644 --- a/apps/ui/src/features/deploy/task/failure-summary.test.ts +++ b/apps/ui/src/features/deploy/task/failure-summary.test.ts @@ -103,7 +103,7 @@ describe("deploymentFailureReason", () => { surfacesRaw: false, }) ).toBe( - "Deploy skill installation failed. Redeploy; if the problem continues, contact support." + "Runtime Skill preparation failed. Contact support to check the runtime image, then redeploy." ); expect( deploymentFailureReason({ @@ -138,7 +138,7 @@ describe("deploymentFailureReason", () => { cancelled: "was cancelled", "deploy-configuration-invalid": "not configured correctly", "deploy-runtime-unavailable": "workspace did not become ready", - "deploy-skill-install-failed": "skill installation failed", + "deploy-skill-install-failed": "Skill preparation failed", "deployment-output-missing": "without a deployable result", "template-output-invalid": "invalid deployment template", "gateway-not-exposed": "did not expose", diff --git a/apps/ui/src/features/deploy/task/failure-summary.ts b/apps/ui/src/features/deploy/task/failure-summary.ts index 332ed0d40..c35e34225 100644 --- a/apps/ui/src/features/deploy/task/failure-summary.ts +++ b/apps/ui/src/features/deploy/task/failure-summary.ts @@ -18,7 +18,7 @@ const FAILURE_MESSAGES = { "build-runtime-unavailable": "The deployment workspace does not expose the required build service. Redeploy; if the problem continues, contact support.", "deploy-skill-install-failed": - "Deploy skill installation failed. Redeploy; if the problem continues, contact support.", + "Runtime Skill preparation failed. Contact support to check the runtime image, then redeploy.", "buildkit-start-failed": "BuildKit could not start. Redeploy; if the problem continues, contact support.", "image-build-failed": diff --git a/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts b/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts index 1af9f5281..7e9592882 100644 --- a/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts +++ b/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts @@ -13,7 +13,6 @@ import type { DeployTaskRow } from "./schema"; const requireModule = createRequire(import.meta.url); const originalFetch = globalThis.fetch; -const PINNED_SKILL_COMMIT_SOURCE_RE = /sealos-skills\.git#[0-9a-f]{7,}/; const ENV_KEYS = [ "AI_PROXY_TOKEN_NAME", "ASSISTANT_GATEWAY_MODEL", @@ -22,6 +21,7 @@ const ENV_KEYS = [ "DEV_OPENAI_API_KEY", "DEV_OPENAI_API_BASE_URL", "DEPLOY_DEVBOX_STORAGE_LIMIT", + "DEPLOY_SKILL_SOURCE", "DEVBOX_API_BASE_URL", "DEVBOX_TOKEN", "GITHUB_DEPLOY_MODEL", @@ -55,9 +55,6 @@ const { githubDeployOpenAiOverride, resolveCodexGatewayCredentials, } = requireModule("./runner") as typeof import("./runner"); -const { getDeploySkillSourceFromEnv } = requireModule( - "./runtime-config" -) as typeof import("./runtime-config"); const { attachedDeployFailureReason } = requireModule( "./failure-details" ) as typeof import("./failure-details"); @@ -128,36 +125,24 @@ function devbox(name: string, phase = "Running") { }; } -describe("deploy skill installation", () => { - it("installs from the configured branch source without pinning a commit", () => { - const command = buildDeploySkillInstallCommand( - "https://github.com/labring/sealos-skills/tree/brain-deploy-preview" - ); - - expect(command).toContain( - "https://github.com/labring/sealos-skills/tree/brain-deploy-preview" - ); - expect(command).toContain( - 'npx --yes skills@1.5.20 add "$skill_source" --agent codex -y' - ); - expect(command).not.toContain("rm -rf"); - expect(command).not.toContain("skills-lock.json"); - expect(command).not.toContain("required_skill_names"); - expect(command).not.toContain("deploy-skills-revision"); - expect(command).not.toContain("sealos-skills-install.marker"); - expect(command).not.toMatch(PINNED_SKILL_COMMIT_SOURCE_RE); +describe("deploy skill preparation", () => { + it("uses only the runtime-owned offline helper", () => { + delete process.env.DEPLOY_SKILL_SOURCE; + const command = buildDeploySkillInstallCommand(); + expect(command).toContain("/usr/local/bin/sealai-prepare-skills"); + expect(command).not.toContain("npx"); + expect(command).not.toContain("https://"); }); - it("defaults to the unified Brain deployment branch via runtime config", () => { - expect(getDeploySkillSourceFromEnv({})).toBe( - "https://github.com/labring/sealos-skills.git#codex/unify-main-brain-deploy" - ); - const command = buildDeploySkillInstallCommand( - getDeploySkillSourceFromEnv({}) - ); - expect(command).toContain( - "https://github.com/labring/sealos-skills.git#codex/unify-main-brain-deploy" - ); + it("rejects legacy source configuration", () => { + process.env.DEPLOY_SKILL_SOURCE = "https://example.test/skills"; + try { + expect(() => buildDeploySkillInstallCommand()).toThrow( + "DEVBOX_RUNTIME_IMAGE" + ); + } finally { + delete process.env.DEPLOY_SKILL_SOURCE; + } }); }); diff --git a/apps/ui/src/features/deploy/task/runner.test.ts b/apps/ui/src/features/deploy/task/runner.test.ts index adbd02758..93da725cc 100644 --- a/apps/ui/src/features/deploy/task/runner.test.ts +++ b/apps/ui/src/features/deploy/task/runner.test.ts @@ -9,10 +9,8 @@ import { import { deployOutputProgressSummary } from "./output-progress"; import { DEFAULT_DEPLOY_DEVBOX_STORAGE_LIMIT, - DEFAULT_DEPLOY_SKILL_SOURCE, DEPLOY_DEVBOX_RUNTIME_READY_TIMEOUT_MS, getDeployDevboxStorageLimitFromEnv, - getDeploySkillSourceFromEnv, } from "./runtime-config"; describe("deploy task runner failure summaries", () => { @@ -22,7 +20,7 @@ describe("deploy task runner failure summaries", () => { new Error("No valid skills found. Skills require a SKILL.md") ) ).toBe( - "Deploy skill installation failed. Redeploy; if the problem continues, contact support." + "Runtime Skill preparation failed. Contact support to check the runtime image, then redeploy." ); }); @@ -122,38 +120,6 @@ describe("deploy task runtime config", () => { it("waits up to five minutes for deploy DevBox runtime readiness", () => { expect(DEPLOY_DEVBOX_RUNTIME_READY_TIMEOUT_MS).toBe(5 * 60_000); }); - - it("defaults the deploy skill source to the unified Brain deployment branch", () => { - expect(DEFAULT_DEPLOY_SKILL_SOURCE).toBe( - "https://github.com/labring/sealos-skills.git#codex/unify-main-brain-deploy" - ); - expect(getDeploySkillSourceFromEnv({})).toBe(DEFAULT_DEPLOY_SKILL_SOURCE); - expect( - getDeploySkillSourceFromEnv({ - DEPLOY_SKILL_SOURCE: " ", - }) - ).toBe(DEFAULT_DEPLOY_SKILL_SOURCE); - }); - - it("uses a configured deploy skill source", () => { - expect( - getDeploySkillSourceFromEnv({ - DEPLOY_SKILL_SOURCE: - " https://github.com/labring/sealos-skills/tree/brain-deploy-preview ", - }) - ).toBe( - "https://github.com/labring/sealos-skills/tree/brain-deploy-preview" - ); - }); - - it("uses the configured branch source", () => { - expect( - getDeploySkillSourceFromEnv({ - DEPLOY_SKILL_SOURCE: - "https://github.com/labring/sealos-skills.git#main", - }) - ).toBe("https://github.com/labring/sealos-skills.git#main"); - }); }); describe("deploy task output progress summary", () => { diff --git a/apps/ui/src/features/deploy/task/runner.ts b/apps/ui/src/features/deploy/task/runner.ts index b3205bb7f..8fe9608cb 100644 --- a/apps/ui/src/features/deploy/task/runner.ts +++ b/apps/ui/src/features/deploy/task/runner.ts @@ -30,7 +30,10 @@ import { } from "@/features/projects/derived-project-display-name"; import { projectResourceDisplayNames } from "@/features/resource-display-name/project-resource-display-names"; import { uniqueResourceDisplayName } from "@/features/resource-display-name/resource-display-name"; -import { buildSealosSkillsInstallCommand } from "@/features/sealos-skills/install"; +import { + assertBundledSkillsConfiguration, + buildSealosSkillsInstallCommand, +} from "@/features/sealos-skills/install"; import { resolveUserAiProxyCredentials } from "@/lib/ai-proxy/resolve-user-ai-proxy-credentials"; import { BRAIN_DEPLOYMENT_KIND_LABEL, @@ -147,7 +150,6 @@ import { import { DEPLOY_DEVBOX_RUNTIME_READY_TIMEOUT_MS, getDeployDevboxStorageLimitFromEnv, - getDeploySkillSourceFromEnv, } from "./runtime-config"; import { CURRENT_AI_ARTIFACT_PUBLIC_PROJECTION_VERSION, @@ -1764,13 +1766,10 @@ export function buildManagedWorkspacePurgeCommand(): string { ].join("\n"); } -/** Branch/tree URL for `skills add`; override via DEPLOY_SKILL_SOURCE. */ -export function buildDeploySkillInstallCommand(skillSource: string): string { - return buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: false, - skillSource, - timeoutSeconds: DEPLOY_TIMEOUT_POLICY.skillInstallMs / 1000, - }); +/** Materialize the image-owned bundle after workspace preparation. */ +export function buildDeploySkillInstallCommand(): string { + assertBundledSkillsConfiguration(process.env); + return buildSealosSkillsInstallCommand(); } function deployOutputReadScript(): string { @@ -3157,6 +3156,13 @@ export async function ensureAiDeploymentDevbox(input: { task: DeployTaskRow; taskDeadlineAtMs: number; }): Promise>> { + try { + assertBundledSkillsConfiguration(process.env); + } catch (error) { + throw withDeployFailureDetails(error, { + reason: "deploy-configuration-invalid", + }); + } // Codex loads MCP servers when its app-server starts. Write the native // config before the first Gateway session instead of teaching Gateway about // deployment-specific profiles. @@ -4099,14 +4105,12 @@ async function runAiDeploymentTask(input: { if (!managedResume) { await recordDeployTaskEvent(input.task.id, { kind: "deployment_task.skill_install_started", - message: "Installing deploy skills into workspace.", + message: "Preparing bundled deploy skills in workspace.", phase: "prepare", }); try { await execOrThrow({ - command: buildDeploySkillInstallCommand( - getDeploySkillSourceFromEnv(process.env) - ), + command: buildDeploySkillInstallCommand(), deadlineAtMs: prepareDeadlineAtMs, namespace: input.task.namespace, runtimeName: runtime.name, diff --git a/apps/ui/src/features/deploy/task/runtime-config.ts b/apps/ui/src/features/deploy/task/runtime-config.ts index 93d1e5283..ee2ccb1f2 100644 --- a/apps/ui/src/features/deploy/task/runtime-config.ts +++ b/apps/ui/src/features/deploy/task/runtime-config.ts @@ -1,13 +1,7 @@ -import { - DEFAULT_SEALOS_SKILLS_SOURCE, - getSealosSkillsSourceFromEnv, -} from "@/features/sealos-skills/install"; import { DEPLOY_TIMEOUT_POLICY } from "./timeout-policy"; export const DEFAULT_DEPLOY_DEVBOX_STORAGE_LIMIT = "10Gi"; -export const DEFAULT_DEPLOY_SKILL_SOURCE = DEFAULT_SEALOS_SKILLS_SOURCE; - export const DEPLOY_DEVBOX_RUNTIME_READY_TIMEOUT_MS = DEPLOY_TIMEOUT_POLICY.devboxReadyMs; @@ -19,9 +13,3 @@ export function getDeployDevboxStorageLimitFromEnv( DEFAULT_DEPLOY_DEVBOX_STORAGE_LIMIT ); } - -export function getDeploySkillSourceFromEnv( - env: Record -): string { - return getSealosSkillsSourceFromEnv(env); -} diff --git a/apps/ui/src/features/deploy/task/timeout-policy.test.ts b/apps/ui/src/features/deploy/task/timeout-policy.test.ts index 2d9694538..7e53f49cf 100644 --- a/apps/ui/src/features/deploy/task/timeout-policy.test.ts +++ b/apps/ui/src/features/deploy/task/timeout-policy.test.ts @@ -15,6 +15,7 @@ describe("deployment timeout policy", () => { it("keeps shared task and direct apply budgets stable", () => { expect(DEPLOY_TIMEOUT_POLICY.overallMs).toBe(70 * MINUTE_MS); expect(DEPLOY_TIMEOUT_POLICY.gatewayCleanupMs).toBe(5000); + expect(DEPLOY_TIMEOUT_POLICY.skillInstallMs).toBe(30_000); expect(DEPLOY_TIMEOUT_POLICY.prepareMs).toBe(8 * MINUTE_MS); expect(DEPLOY_TIMEOUT_POLICY.applyMs).toBe(5 * MINUTE_MS); expect(DEPLOY_TIMEOUT_POLICY.readinessMs).toBe(10 * MINUTE_MS); diff --git a/apps/ui/src/features/deploy/task/timeout-policy.ts b/apps/ui/src/features/deploy/task/timeout-policy.ts index 06c8a6567..94d22cebc 100644 --- a/apps/ui/src/features/deploy/task/timeout-policy.ts +++ b/apps/ui/src/features/deploy/task/timeout-policy.ts @@ -14,7 +14,7 @@ const COMMON_DEPLOY_TIMEOUT_POLICY = { overallMs: 70 * MINUTE_MS, prepareMs: 8 * MINUTE_MS, repositoryCloneMs: 5 * MINUTE_MS, - skillInstallMs: 3 * MINUTE_MS, + skillInstallMs: 30 * SECOND_MS, } as const; /** Shared task infrastructure plus direct/template apply and readiness limits. */ diff --git a/apps/ui/src/features/sealos-skills/install.test.ts b/apps/ui/src/features/sealos-skills/install.test.ts index b4aa03d0e..b4df1b983 100644 --- a/apps/ui/src/features/sealos-skills/install.test.ts +++ b/apps/ui/src/features/sealos-skills/install.test.ts @@ -1,109 +1,26 @@ -import { test } from "bun:test"; -import assert from "node:assert/strict"; +import { expect, test } from "bun:test"; import { + assertBundledSkillsConfiguration, buildSealosSkillsInstallCommand, - DEFAULT_SEALOS_SKILLS_SOURCE, - getSealosSkillsSourceFromEnv, } from "./install"; -const INSTALL_MARKER_RE = /sealos-skills-install\.marker/; -const CLI_VERSION_RE = /skills@1\.5\.20/; -const CODEX_AGENT_RE = /--agent codex -y/; -const INSTALL_FAILURE_RE = /Failed to install/; -const INSTALL_OUTPUT_STDERR_RE = /install_output" >&2/g; -const MARKER_CLI_VERSION_RE = /cli_version=1\.5\.20/; -const MARKER_SCHEMA_RE = /marker_schema=source-install-v1/; -const MARKER_SOURCE_RE = /source=.*sealos-skills/; -const QUOTED_SOURCE_RE = /branch\/it.*s-safe/; -const LOCK_RE = /flock --wait/; -const SKILL_NAME_VALIDATION_RE = - /required_skill_names|required Sealos skill|cloud-native-readiness|sealos-deploy|k8s-kaniko-job/; -const WORKSPACE_CLEANUP_RE = - /rm -rf.*(?:\.agents\/skills|\.codex\/skills)|rm -f.*skills-lock\.json/; - -test("Sealos Skills source defaults to the shared deployment branch", () => { - assert.equal(getSealosSkillsSourceFromEnv({}), DEFAULT_SEALOS_SKILLS_SOURCE); - assert.equal( - getSealosSkillsSourceFromEnv({ DEPLOY_SKILL_SOURCE: " " }), - DEFAULT_SEALOS_SKILLS_SOURCE - ); - assert.equal( - getSealosSkillsSourceFromEnv({ - DEPLOY_SKILL_SOURCE: " https://example.test/sealos-skills.git#main ", - }), - "https://example.test/sealos-skills.git#main" - ); -}); - -test("shared install command always installs the configured source for Codex", () => { - const command = buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: false, - skillSource: DEFAULT_SEALOS_SKILLS_SOURCE, - timeoutSeconds: 180, - }); - - assert.doesNotMatch(command, INSTALL_MARKER_RE); - assert.match(command, LOCK_RE); - assert.ok( - command.indexOf("flock --wait") < command.indexOf("install_output") - ); - assert.match(command, CLI_VERSION_RE); - assert.match(command, CODEX_AGENT_RE); - assert.match(command, INSTALL_FAILURE_RE); - assert.doesNotMatch(command, WORKSPACE_CLEANUP_RE); - assert.doesNotMatch(command, SKILL_NAME_VALIDATION_RE); +test("managed runtimes prepare Skills locally without a download fallback", () => { + const command = buildSealosSkillsInstallCommand(); + expect(command).toContain("test -x /usr/local/bin/sealai-prepare-skills"); + expect(command).toContain("\n/usr/local/bin/sealai-prepare-skills"); + for (const forbidden of ["npx", "npm", "git clone", "https://", "rm -rf"]) { + expect(command).not.toContain(forbidden); + } }); -test("installation command shell-quotes the source and preserves the workspace", () => { - const source = "https://example.test/sealos-skills.git#branch/it's-safe"; - const command = buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: false, - skillSource: source, - timeoutSeconds: 180, - }); - - assert.match(command, LOCK_RE); - assert.match(command, QUOTED_SOURCE_RE); - assert.doesNotMatch(command, WORKSPACE_CLEANUP_RE); - assert.doesNotMatch(command, SKILL_NAME_VALIDATION_RE); -}); - -test("Chat install cache skips only a matching successful source and CLI version", () => { - const command = buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: true, - skillSource: DEFAULT_SEALOS_SKILLS_SOURCE, - timeoutSeconds: 180, - }); - - assert.match(command, INSTALL_MARKER_RE); - assert.match(command, MARKER_SCHEMA_RE); - assert.match(command, MARKER_SOURCE_RE); - assert.match(command, MARKER_CLI_VERSION_RE); - assert.ok( - command.indexOf("flock --wait") < - command.indexOf('cat -- "$install_marker"') - ); - assert.ok( - command.indexOf('cat -- "$install_marker"') < - command.indexOf("install_output") - ); - assert.ok( - command.indexOf("install_output") < - command.lastIndexOf('> "$install_marker"') - ); -}); - -test("installation failures send captured CLI output to stderr before exiting", () => { - const command = buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches: true, - skillSource: DEFAULT_SEALOS_SKILLS_SOURCE, - timeoutSeconds: 180, - }); - - const stderrWrites = command.match(INSTALL_OUTPUT_STDERR_RE) ?? []; - assert.equal(stderrWrites.length, 2); - assert.ok( - command.indexOf("CLI reported installation failures") < - command.lastIndexOf('> "$install_marker"') - ); +test("legacy source overrides must be migrated, not silently ignored", () => { + expect(() => assertBundledSkillsConfiguration({})).not.toThrow(); + expect(() => + assertBundledSkillsConfiguration({ DEPLOY_SKILL_SOURCE: " " }) + ).not.toThrow(); + expect(() => + assertBundledSkillsConfiguration({ + DEPLOY_SKILL_SOURCE: "https://example.test/skills", + }) + ).toThrow("DEVBOX_RUNTIME_IMAGE"); }); diff --git a/apps/ui/src/features/sealos-skills/install.ts b/apps/ui/src/features/sealos-skills/install.ts index 03d0f36e1..dd589529a 100644 --- a/apps/ui/src/features/sealos-skills/install.ts +++ b/apps/ui/src/features/sealos-skills/install.ts @@ -1,96 +1,28 @@ export const DEFAULT_SEALOS_SKILLS_SOURCE = "https://github.com/labring/sealos-skills.git#codex/unify-main-brain-deploy"; +/** Only used by the user-facing local installation guide. */ export const SEALOS_SKILLS_CLI_VERSION = "1.5.20"; export const SEALOS_SKILLS_WORKSPACE_DIR = "/home/devbox/project"; -export const SEALOS_SKILLS_INSTALL_MARKER = `${SEALOS_SKILLS_WORKSPACE_DIR}/.sealos/sealos-skills-install.marker`; -const SEALOS_SKILLS_INSTALL_MARKER_SCHEMA = "source-install-v1"; - -/** Internal deployment executor; it is not exposed by the chat Skill loader. */ +export const SEALOS_SKILLS_RUNTIME_CONTRACT = "bundled-skills-v1"; export const SEALOS_INTERNAL_CHAT_SKILL_NAMES = ["k8s-kaniko-job"] as const; -export function getSealosSkillsSourceFromEnv( +/** Legacy configuration is rejected instead of silently using another source. */ +export function assertBundledSkillsConfiguration( env: Record -): string { - return env.DEPLOY_SKILL_SOURCE?.trim() || DEFAULT_SEALOS_SKILLS_SOURCE; -} - -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; +): void { + if (env.DEPLOY_SKILL_SOURCE?.trim()) { + throw new Error( + "DEPLOY_SKILL_SOURCE is no longer supported. Select a runtime image containing the desired Skill revision using DEVBOX_RUNTIME_IMAGE." + ); + } } -export interface BuildSealosSkillsInstallCommandOptions { - skillSource: string; - skipIfInstallMarkerMatches: boolean; - timeoutSeconds: number; -} - -/** - * Builds the single installation flow shared by Chat Devboxes and deployment - * task Devboxes. The configured source owns what gets installed; existing - * workspace skills and lock files are intentionally preserved. - */ -export function buildSealosSkillsInstallCommand({ - skipIfInstallMarkerMatches, - skillSource, - timeoutSeconds, -}: BuildSealosSkillsInstallCommandOptions): string { - const marker = shellQuote(SEALOS_SKILLS_INSTALL_MARKER); - const markerContent = `marker_schema=${SEALOS_SKILLS_INSTALL_MARKER_SCHEMA}\nsource=${skillSource}\ncli_version=${SEALOS_SKILLS_CLI_VERSION}`; - const markerSetup = skipIfInstallMarkerMatches - ? [ - `install_marker=${marker}`, - `marker_content=${shellQuote(markerContent)}`, - ] - : []; - const cachedInstallCheck = skipIfInstallMarkerMatches - ? [ - 'if [ -f "$install_marker" ] && [ "$(cat -- "$install_marker")" = "$marker_content" ]; then', - " exit 0", - "fi", - ] - : []; - const markerWrite = skipIfInstallMarkerMatches - ? ['printf \'%s\' "$marker_content" > "$install_marker"'] - : []; - +/** Offline preparation is owned by the runtime image; never invoke npx here. */ +export function buildSealosSkillsInstallCommand(): string { return [ "set -euo pipefail", - `workspace_dir=${shellQuote(SEALOS_SKILLS_WORKSPACE_DIR)}`, - `skill_source=${shellQuote(skillSource)}`, - 'install_lock_path="$workspace_dir/.sealos/sealos-skills-install.lock"', - `install_lock_wait_seconds=${timeoutSeconds}`, - ...markerSetup, - 'mkdir -p -- "$workspace_dir/.sealos"', - "if ! command -v npx >/dev/null 2>&1; then", - " printf 'ERROR: npx is required to install Sealos skills\\n' >&2", - " exit 1", - "fi", - "if ! command -v flock >/dev/null 2>&1; then", - " printf 'ERROR: flock is required to install Sealos skills safely\\n' >&2", - " exit 1", - "fi", - 'exec 9>"$install_lock_path"', - 'if ! flock --wait "$install_lock_wait_seconds" 9; then', - " printf 'ERROR: timed out waiting for the Sealos skills install lock\\n' >&2", - " exit 1", - "fi", - ...cachedInstallCheck, - 'cd -- "$workspace_dir"', - `if install_output=$(timeout ${timeoutSeconds} npx --yes skills@${SEALOS_SKILLS_CLI_VERSION} add "$skill_source" --agent codex -y 2>&1); then`, - " :", - "else", - " install_exit_code=$?", - " printf '%s\\n' \"$install_output\" >&2", - " printf 'ERROR: Sealos skills CLI failed with exit code %s\\n' \"$install_exit_code\" >&2", - ' exit "$install_exit_code"', - "fi", - `if printf '%s\\n' "$install_output" | grep -Eq 'Failed to install [1-9][0-9]*'; then`, - " printf '%s\\n' \"$install_output\" >&2", - " printf 'ERROR: Sealos skills CLI reported installation failures\\n' >&2", - " exit 1", - "fi", - "printf '%s\\n' \"$install_output\"", - ...markerWrite, + "test -x /usr/local/bin/sealai-prepare-skills", + "/usr/local/bin/sealai-prepare-skills", ].join("\n"); } diff --git a/charts/brain-system/values.local.example.yaml b/charts/brain-system/values.local.example.yaml index 356a4c187..8b47f8a8d 100644 --- a/charts/brain-system/values.local.example.yaml +++ b/charts/brain-system/values.local.example.yaml @@ -89,10 +89,6 @@ ui: DEVBOX_JWT_TTL_SECONDS: "14400" # Storage ceiling for newly created deployment runtimes; existing Devboxes are unchanged. DEPLOY_DEVBOX_STORAGE_LIMIT: "10Gi" - # Optional. Leave empty to use the unified Brain-compatible sealos-skills branch: - # https://github.com/labring/sealos-skills.git#codex/unify-main-brain-deploy - # Set explicitly to another tested branch, tag, or commit when needed. - DEPLOY_SKILL_SOURCE: "" imagePullSecret: # App workload pods reference ghcr-cred when this chart creates it. diff --git a/charts/brain-system/values.yaml b/charts/brain-system/values.yaml index e54523de8..fc50fa9b1 100644 --- a/charts/brain-system/values.yaml +++ b/charts/brain-system/values.yaml @@ -169,9 +169,6 @@ ui: # public URL + /api/deploy-agent/mcp/v1; set explicitly when the UI is # not publicly reachable from deployment Devboxes. DEPLOY_AGENT_MCP_URL: "" - # Optional. Leave empty to use the unified Brain sealos-skills branch. Set this to a - # branch, tag, or commit ref when selecting another tested Skill source. - DEPLOY_SKILL_SOURCE: "" resources: requests: cpu: 500m diff --git a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md index 5e39aaf09..7c66ba377 100644 --- a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md +++ b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md @@ -30,6 +30,16 @@ Reaching `completed` still requires Deployment Result Readiness (ADR 0028), and One global Postgres NOTIFY channel replaces the in-process listener maps, so projection and timeline streams no longer assume a single server process. The payload carries only identifiers (namespace, project, task); subscribers re-read the row, and stream handlers subscribe before their bootstrap read so no update falls between snapshot and subscription. The listener holds a dedicated connection and re-bootstraps subscribers after a reconnect, because notifications during the gap are lost. Retention-purge removals are the one case where re-reading is impossible, so the purge notification's identifiers are themselves the removal event. +## Amendment (2026-09-07): Image-owned managed Skills + +This amendment supersedes only the runtime Skill-source paragraph above. Managed Chat and Deployment Tasks now prepare Skills from the sandbox/v1 image's offline bundle. The devbox-runtime build resolves a full source commit, validates the Skill tree, and packages a versioned manifest with per-file SHA-256. codex-gateway remains unchanged. Brain neither vendors the source nor invokes a network installer in managed runtimes. + +The fixed local preparation entry point verifies the bundle and stages workspace Skills under a process lock. It replaces bundled names, preserves unrelated project Skills and lock files, rejects symbolic links, and restores the previous directory on a caught replacement failure. This is an operational integrity check, not an adversarial boundary against a running Agent with filesystem access; process termination between renames may leave a backup requiring recovery. No online fallback is permitted. The operation has a 30-second exec cap within the existing preparation deadline. + +Chat requests await successful discovery before assembling the system prompt and loadSkill tools; background warmup is only an optimization. Runtime identity includes the configured image reference and the bundled-Skills contract version. Rollouts must select immutable images; changing the contents behind the same tag is not detected. Old Chat runtimes retain their existing lifecycle and data. + +Nonempty legacy DEPLOY_SKILL_SOURCE is rejected and must be removed during migration. Select a tested Skill revision by building a sandbox image and setting DEVBOX_RUNTIME_IMAGE. Publish and validate that image before upgrading Brain; let active and blocked Deployment Tasks finish before switching versions. Roll back Brain and the runtime image together without deleting business resources. ADR 0042 still forbids persistence of raw AI command errors; the existing skill-install failure code remains compatible, with preparation-oriented wording. + ## Considered Options - Keep launching runners as fire-and-forget promises from route handlers: rejected because a server restart leaves `running` rows orphaned forever, cancellation has no enforcement point, and a re-run can start a second concurrent runner against the same task. diff --git a/turbo.json b/turbo.json index 8ba0c6e7c..4980c6faf 100644 --- a/turbo.json +++ b/turbo.json @@ -27,7 +27,6 @@ "DEPLOY_DEVBOX_STORAGE_LIMIT", "DEPLOY_AGENT_MCP_ALLOWED_ORIGINS", "DEPLOY_AGENT_MCP_URL", - "DEPLOY_SKILL_SOURCE", "DIRECT_AP_READINESS_TIMEOUT_MS", "FREE_CHAT_TURNS", "GTM_ID", From c5b1fdcba0beceef0d4c014aa05585bf1441ae3b Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Mon, 7 Sep 2026 18:02:10 +0800 Subject: [PATCH 2/3] fix(chat): explicitly initialize the bundled skills workspace --- apps/ui/src/features/chat/devbox/chat-runtime.ts | 2 +- apps/ui/src/features/sealos-skills/install.test.ts | 5 +++++ apps/ui/src/features/sealos-skills/install.ts | 6 ++++-- ...deployment-tasks-under-leases-and-guarded-transitions.md | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/ui/src/features/chat/devbox/chat-runtime.ts b/apps/ui/src/features/chat/devbox/chat-runtime.ts index 9491e014e..37af294fe 100644 --- a/apps/ui/src/features/chat/devbox/chat-runtime.ts +++ b/apps/ui/src/features/chat/devbox/chat-runtime.ts @@ -311,7 +311,7 @@ async function installChatSkills( const result = await runDevboxCommand( authNamespace, name, - buildSealosSkillsInstallCommand(), + buildSealosSkillsInstallCommand(true), DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS, signal ); diff --git a/apps/ui/src/features/sealos-skills/install.test.ts b/apps/ui/src/features/sealos-skills/install.test.ts index b4df1b983..1cba3503c 100644 --- a/apps/ui/src/features/sealos-skills/install.test.ts +++ b/apps/ui/src/features/sealos-skills/install.test.ts @@ -24,3 +24,8 @@ test("legacy source overrides must be migrated, not silently ignored", () => { }) ).toThrow("DEVBOX_RUNTIME_IMAGE"); }); + +test("only Chat explicitly initializes a workspace without repository cloning", () => { + expect(buildSealosSkillsInstallCommand(true)).toContain(" --init-workspace"); + expect(buildSealosSkillsInstallCommand()).not.toContain(" --init-workspace"); +}); diff --git a/apps/ui/src/features/sealos-skills/install.ts b/apps/ui/src/features/sealos-skills/install.ts index dd589529a..53aa890ac 100644 --- a/apps/ui/src/features/sealos-skills/install.ts +++ b/apps/ui/src/features/sealos-skills/install.ts @@ -19,10 +19,12 @@ export function assertBundledSkillsConfiguration( } /** Offline preparation is owned by the runtime image; never invoke npx here. */ -export function buildSealosSkillsInstallCommand(): string { +export function buildSealosSkillsInstallCommand( + initializeWorkspace = false +): string { return [ "set -euo pipefail", "test -x /usr/local/bin/sealai-prepare-skills", - "/usr/local/bin/sealai-prepare-skills", + `/usr/local/bin/sealai-prepare-skills${initializeWorkspace ? " --init-workspace" : ""}`, ].join("\n"); } diff --git a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md index 7c66ba377..1927329fa 100644 --- a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md +++ b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md @@ -34,7 +34,7 @@ One global Postgres NOTIFY channel replaces the in-process listener maps, so pro This amendment supersedes only the runtime Skill-source paragraph above. Managed Chat and Deployment Tasks now prepare Skills from the sandbox/v1 image's offline bundle. The devbox-runtime build resolves a full source commit, validates the Skill tree, and packages a versioned manifest with per-file SHA-256. codex-gateway remains unchanged. Brain neither vendors the source nor invokes a network installer in managed runtimes. -The fixed local preparation entry point verifies the bundle and stages workspace Skills under a process lock. It replaces bundled names, preserves unrelated project Skills and lock files, rejects symbolic links, and restores the previous directory on a caught replacement failure. This is an operational integrity check, not an adversarial boundary against a running Agent with filesystem access; process termination between renames may leave a backup requiring recovery. No online fallback is permitted. The operation has a 30-second exec cap within the existing preparation deadline. +The fixed local preparation entry point verifies the bundle and stages workspace Skills under a process lock. It installs every Skill from the pinned norberia/sealos-skills-next source, with no fixed name/count allowlist. It replaces bundled names, preserves unrelated project Skills and lock files, and rejects symbolic links. Transactions outside `.agents` restore the previous directory on retry after an interrupted replacement; ambiguous state fails closed. This is an operational integrity check, not an adversarial boundary against a running Agent with filesystem access or a power-loss durability guarantee. No online fallback is permitted. Lock waiting and preparation share a 28-second budget with a one-second kill grace, below the 30-second exec cap within the existing preparation deadline. Deploy preparation requires an existing workspace; Chat explicitly requests workspace initialization because it does not clone a repository. Chat requests await successful discovery before assembling the system prompt and loadSkill tools; background warmup is only an optimization. Runtime identity includes the configured image reference and the bundled-Skills contract version. Rollouts must select immutable images; changing the contents behind the same tag is not detected. Old Chat runtimes retain their existing lifecycle and data. From 384c2eec20ac82c0359df824df9089fd103392f5 Mon Sep 17 00:00:00 2001 From: zjy365 <3161362058@qq.com> Date: Tue, 8 Sep 2026 10:30:03 +0800 Subject: [PATCH 3/3] fix(skills): reuse prepared chat skills and restore lint env declarations --- .../features/chat/devbox/chat-runtime.test.ts | 64 +++++++++++++++---- .../src/features/chat/devbox/chat-runtime.ts | 44 +++++++++++-- .../task/runner.github-ai-proxy.test.ts | 1 + .../features/sealos-skills/install.test.ts | 4 +- apps/ui/src/features/sealos-skills/install.ts | 8 ++- ...ks-under-leases-and-guarded-transitions.md | 2 +- turbo.json | 2 + 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/apps/ui/src/features/chat/devbox/chat-runtime.test.ts b/apps/ui/src/features/chat/devbox/chat-runtime.test.ts index b6e9f56b5..853e55ff2 100644 --- a/apps/ui/src/features/chat/devbox/chat-runtime.test.ts +++ b/apps/ui/src/features/chat/devbox/chat-runtime.test.ts @@ -30,6 +30,8 @@ test("background Skill warmup is shared and publishes metadata after discovery", let installCommand = ""; let emptyDiscovery = false; let preparationFails = false; + let prepareCalls = 0; + let creationTimestamp: string | null = "2026-09-07T00:00:00Z"; let discoveryGate: Promise | undefined; let onDiscovery: (() => void) | undefined; const upstreamIds: string[] = []; @@ -53,7 +55,7 @@ test("background Skill warmup is shared and publishes metadata after discovery", } if (url.pathname.endsWith("/existing-runtime")) { return Response.json({ - data: { state: { phase: "Running" } }, + data: { creationTimestamp, state: { phase: "Running" } }, }); } if (url.pathname.endsWith("/exec")) { @@ -63,6 +65,7 @@ test("background Skill warmup is shared and publishes metadata after discovery", }; const command = body.command?.at(-1) ?? ""; if (command.includes("sealai-prepare-skills")) { + prepareCalls += 1; installCommand = command; if (preparationFails) { return Response.json({ @@ -119,18 +122,31 @@ test("background Skill warmup is shared and publishes metadata after discovery", assert.ok(execCalls >= 3); assert.ok(installCommand.includes("/usr/local/bin/sealai-prepare-skills")); assert.ok(!installCommand.includes("npx")); + assert.ok(installCommand.includes(" --init-workspace")); + const warmedExecCalls = execCalls; + await warmChatDevboxSkills(options); + assert.equal(execCalls, warmedExecCalls); + const copiedSnapshot = await warmChatDevboxSkills(options); + assert.ok(copiedSnapshot[0]); + copiedSnapshot[0].name = "modified-by-caller"; + assert.equal( + (await warmChatDevboxSkills(options))[0]?.name, + "sealos-deploy" + ); + const failureOptions = { ...options, namespace: "ns-warmup-failure" }; emptyDiscovery = true; - await assert.rejects(warmChatDevboxSkills(options)); + await assert.rejects(warmChatDevboxSkills(failureOptions)); emptyDiscovery = false; + creationTimestamp = "2026-09-07T01:00:00Z"; preparationFails = true; await assert.rejects( - warmChatDevboxSkills(options), + warmChatDevboxSkills(failureOptions), (error: unknown) => error instanceof Error && !error.message.includes("private raw error") ); preparationFails = false; assert.equal( - (await warmChatDevboxSkills(options))[0]?.name, + (await warmChatDevboxSkills(failureOptions))[0]?.name, "sealos-deploy" ); const previousIdentity = upstreamIds.at(-1); @@ -141,18 +157,19 @@ test("background Skill warmup is shared and publishes metadata after discovery", assert.notEqual(upstreamIds.at(-1), previousIdentity); const { buildChatToolset } = await import("../runtime/tools"); + const toolsetOptions = { + chatId: "skills-first-turn", + kubeconfig: options.kubeconfig, + kubernetesNamespace: "ns-toolset-first-turn", + workspaceActor: "test-actor", + workspaceUserUid: "test-user", + }; const gate = Promise.withResolvers(); const started = Promise.withResolvers(); discoveryGate = gate.promise; onDiscovery = started.resolve; let toolsetReady = false; - const toolset = buildChatToolset({ - chatId: "skills-first-turn", - kubeconfig: options.kubeconfig, - kubernetesNamespace: options.namespace, - workspaceActor: "test-actor", - workspaceUserUid: "test-user", - }).then((result) => { + const toolset = buildChatToolset(toolsetOptions).then((result) => { toolsetReady = true; return result; }); @@ -162,6 +179,31 @@ test("background Skill warmup is shared and publishes metadata after discovery", const ready = await toolset; assert.ok(ready.systemPrompt.includes("sealos-deploy")); assert.ok(ready.tools.loadSkill); + const firstTurnExecCalls = execCalls; + const secondTurn = await buildChatToolset(toolsetOptions); + assert.equal(execCalls, firstTurnExecCalls); + const prepared = prepareCalls; + const loadSkill = secondTurn.tools.loadSkill?.execute; + assert.ok(loadSkill); + await loadSkill( + { name: "sealos-deploy", intention: "Deploy an app" }, + { toolCallId: "load-skill", messages: [], context: undefined } + ); + assert.equal(prepareCalls, prepared); + creationTimestamp = "2026-09-07T02:00:00Z"; + await bootstrapChatDevboxIfNeeded({ + kubeconfig: options.kubeconfig, + namespace: toolsetOptions.kubernetesNamespace, + }); + assert.equal(prepareCalls, prepared + 1); + creationTimestamp = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + await bootstrapChatDevboxIfNeeded({ + kubeconfig: options.kubeconfig, + namespace: toolsetOptions.kubernetesNamespace, + }); + } + assert.equal(prepareCalls, prepared + 3); } finally { if (originalImage === undefined) { delete process.env.DEVBOX_RUNTIME_IMAGE; diff --git a/apps/ui/src/features/chat/devbox/chat-runtime.ts b/apps/ui/src/features/chat/devbox/chat-runtime.ts index 37af294fe..ea9276c88 100644 --- a/apps/ui/src/features/chat/devbox/chat-runtime.ts +++ b/apps/ui/src/features/chat/devbox/chat-runtime.ts @@ -38,6 +38,7 @@ const DEVBOX_WARMUP_TIMEOUT_SECONDS = 30; const DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS = 30; const chatDevboxSkillSnapshots = new Map(); const chatDevboxSkillWarmups = new Map>(); +const chatDevboxPreparedSkills = new Map(); export interface ChatDevboxRuntimeOptions { kubeconfig: string; @@ -70,7 +71,12 @@ export function getChatDevboxSkillsSnapshot( export function warmChatDevboxSkills( options: ChatDevboxRuntimeOptions ): Promise { + assertBundledSkillsConfiguration(process.env); const cacheKey = chatDevboxSkillCacheKey(options); + const snapshot = chatDevboxSkillSnapshots.get(cacheKey); + if (snapshot != null) { + return Promise.resolve(snapshot.map((skill) => ({ ...skill }))); + } const existing = chatDevboxSkillWarmups.get(cacheKey); if (existing != null) { return existing; @@ -304,14 +310,26 @@ async function assertDevboxKubectlReady( } async function installChatSkills( - authNamespace: string, + options: ChatDevboxRuntimeOptions, name: string, + creationTimestamp: string | null | undefined, signal?: AbortSignal ): Promise { + const cacheKey = chatDevboxSkillCacheKey(options); + // Names may be reused after deletion; only cache a known runtime generation. + const generation = creationTimestamp ? `${name}|${creationTimestamp}` : null; + if ( + generation != null && + chatDevboxPreparedSkills.get(cacheKey) === generation + ) { + return; + } + chatDevboxPreparedSkills.delete(cacheKey); + chatDevboxSkillSnapshots.delete(cacheKey); const result = await runDevboxCommand( - authNamespace, + options.namespace, name, - buildSealosSkillsInstallCommand(true), + buildSealosSkillsInstallCommand({ initializeWorkspace: true }), DEVBOX_SKILL_INSTALL_TIMEOUT_SECONDS, signal ); @@ -321,6 +339,9 @@ async function installChatSkills( "Chat runtime Skill preparation failed. Check the runtime image bundle." ); } + if (generation != null) { + chatDevboxPreparedSkills.set(cacheKey, generation); + } } async function ensureChatDevbox( @@ -349,7 +370,11 @@ async function ensureChatDevbox( const existing = (await listDevboxes(authNamespace, upstreamID, signal)).data .items[0]; if (existing != null) { - await ensureRunningDevbox(authNamespace, existing.name, signal); + const info = await ensureRunningDevbox( + authNamespace, + existing.name, + signal + ); await refreshLease(authNamespace, existing.name, pauseAt, signal); await assertDevboxKubectlReady( authNamespace, @@ -357,7 +382,12 @@ async function ensureChatDevbox( options.namespace, signal ); - await installChatSkills(authNamespace, existing.name, signal); + await installChatSkills( + options, + existing.name, + info.creationTimestamp, + signal + ); return { name: existing.name, skippedExisting: true }; } @@ -384,14 +414,14 @@ async function ensureChatDevbox( signal ); - await waitForRunningDevbox(authNamespace, name, signal); + const info = await waitForRunningDevbox(authNamespace, name, signal); await assertDevboxKubectlReady( authNamespace, name, options.namespace, signal ); - await installChatSkills(authNamespace, name, signal); + await installChatSkills(options, name, info.creationTimestamp, signal); return { name, skippedExisting: false }; } diff --git a/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts b/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts index 7e9592882..1f4fe83fa 100644 --- a/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts +++ b/apps/ui/src/features/deploy/task/runner.github-ai-proxy.test.ts @@ -130,6 +130,7 @@ describe("deploy skill preparation", () => { delete process.env.DEPLOY_SKILL_SOURCE; const command = buildDeploySkillInstallCommand(); expect(command).toContain("/usr/local/bin/sealai-prepare-skills"); + expect(command).not.toContain("--init-workspace"); expect(command).not.toContain("npx"); expect(command).not.toContain("https://"); }); diff --git a/apps/ui/src/features/sealos-skills/install.test.ts b/apps/ui/src/features/sealos-skills/install.test.ts index 1cba3503c..4388e23ad 100644 --- a/apps/ui/src/features/sealos-skills/install.test.ts +++ b/apps/ui/src/features/sealos-skills/install.test.ts @@ -26,6 +26,8 @@ test("legacy source overrides must be migrated, not silently ignored", () => { }); test("only Chat explicitly initializes a workspace without repository cloning", () => { - expect(buildSealosSkillsInstallCommand(true)).toContain(" --init-workspace"); + expect( + buildSealosSkillsInstallCommand({ initializeWorkspace: true }) + ).toContain(" --init-workspace"); expect(buildSealosSkillsInstallCommand()).not.toContain(" --init-workspace"); }); diff --git a/apps/ui/src/features/sealos-skills/install.ts b/apps/ui/src/features/sealos-skills/install.ts index 53aa890ac..746cd3428 100644 --- a/apps/ui/src/features/sealos-skills/install.ts +++ b/apps/ui/src/features/sealos-skills/install.ts @@ -19,9 +19,11 @@ export function assertBundledSkillsConfiguration( } /** Offline preparation is owned by the runtime image; never invoke npx here. */ -export function buildSealosSkillsInstallCommand( - initializeWorkspace = false -): string { +export function buildSealosSkillsInstallCommand({ + initializeWorkspace = false, +}: { + initializeWorkspace?: boolean; +} = {}): string { return [ "set -euo pipefail", "test -x /usr/local/bin/sealai-prepare-skills", diff --git a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md index 1927329fa..39faaa9e1 100644 --- a/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md +++ b/docs/adr/0037-execute-deployment-tasks-under-leases-and-guarded-transitions.md @@ -36,7 +36,7 @@ This amendment supersedes only the runtime Skill-source paragraph above. Managed The fixed local preparation entry point verifies the bundle and stages workspace Skills under a process lock. It installs every Skill from the pinned norberia/sealos-skills-next source, with no fixed name/count allowlist. It replaces bundled names, preserves unrelated project Skills and lock files, and rejects symbolic links. Transactions outside `.agents` restore the previous directory on retry after an interrupted replacement; ambiguous state fails closed. This is an operational integrity check, not an adversarial boundary against a running Agent with filesystem access or a power-loss durability guarantee. No online fallback is permitted. Lock waiting and preparation share a 28-second budget with a one-second kill grace, below the 30-second exec cap within the existing preparation deadline. Deploy preparation requires an existing workspace; Chat explicitly requests workspace initialization because it does not clone a repository. -Chat requests await successful discovery before assembling the system prompt and loadSkill tools; background warmup is only an optimization. Runtime identity includes the configured image reference and the bundled-Skills contract version. Rollouts must select immutable images; changing the contents behind the same tag is not detected. Old Chat runtimes retain their existing lifecycle and data. +Chat requests await successful discovery before assembling the system prompt and loadSkill tools; background warmup is only an optimization. Successful discovery snapshots are reused across turns in the same process, with concurrent cache misses sharing discovery. Tool execution still checks runtime readiness and renews its lease. Offline preparation is reused only for a successfully prepared Devbox name and creation timestamp; replacement or missing generation information requires preparation again and invalidates the discovery snapshot. Runtime identity includes the configured image reference and the bundled-Skills contract version. Rollouts must select immutable images; changing the contents behind the same tag is not detected. Old Chat runtimes retain their existing lifecycle and data. Nonempty legacy DEPLOY_SKILL_SOURCE is rejected and must be removed during migration. Select a tested Skill revision by building a sandbox image and setting DEVBOX_RUNTIME_IMAGE. Publish and validate that image before upgrading Brain; let active and blocked Deployment Tasks finish before switching versions. Roll back Brain and the runtime image together without deleting business resources. ADR 0042 still forbids persistence of raw AI command errors; the existing skill-install failure code remains compatible, with preparation-oriented wording. diff --git a/turbo.json b/turbo.json index 4980c6faf..eeb0ea058 100644 --- a/turbo.json +++ b/turbo.json @@ -19,12 +19,14 @@ "CODEX_GATEWAY_OPENAI_BASE_URL", "DATABASE_URL", "DEVBOX_API_BASE_URL", + "DEVBOX_RUNTIME_IMAGE", "DEVBOX_TOKEN", "DEV_APP_TOKEN_USER_ID", "DEV_APP_TOKEN_USER_UID", "DEV_OPENAI_API_BASE_URL", "DEV_OPENAI_API_KEY", "DEPLOY_DEVBOX_STORAGE_LIMIT", + "DEPLOY_SKILL_SOURCE", "DEPLOY_AGENT_MCP_ALLOWED_ORIGINS", "DEPLOY_AGENT_MCP_URL", "DIRECT_AP_READINESS_TIMEOUT_MS",