From c00849da659e663b6cf8d30d0cb790480f415c0f Mon Sep 17 00:00:00 2001 From: heimanba <371510756@qq.com> Date: Sun, 19 Jul 2026 14:27:46 +0800 Subject: [PATCH] feat(byoc): runtime config, sessions and vaults updates Change-Id: I5d340bb58f1d0d9a68607024aa49fe0b37c1eab6 Co-developed-by: Qoder --- apps/server/src/lib/build-runtime-config.ts | 6 ++ apps/server/src/lib/state-scope.ts | 73 +++++++++++++++++-- apps/server/src/routes/vaults.ts | 4 +- apps/server/src/schemas/sessions.ts | 5 +- apps/server/src/schemas/vaults.ts | 5 +- apps/server/src/services/agents/catalog.ts | 43 ++++++++++- apps/server/src/services/files/upload.ts | 3 +- .../playbook-session-adapter/index.ts | 8 +- .../playbook-session-adapter/sessions.ts | 14 ++-- apps/server/src/services/sessions/runner.ts | 8 +- apps/server/src/services/skills/manage.ts | 2 +- apps/server/tests/runtime-config.test.ts | 7 +- apps/server/tests/session-agents.test.ts | 13 +++- .../lib/domain/resource-center/resources.ts | 4 +- apps/webui/src/lib/domain/vault.ts | 17 +++-- apps/webui/src/lib/playbooks/index.ts | 14 ---- .../webui/tests/scan-lifecycle-parity.test.ts | 43 +++-------- packages/playbooks/src/resolve.ts | 2 +- packages/playbooks/src/session-runtime.ts | 8 +- .../sdk/src/internal/core/agent-builder.ts | 2 + .../internal/core/session-event-sanitizer.ts | 6 +- .../providers/bailian/event-mapper.ts | 6 +- packages/sdk/src/internal/types/dto.ts | 16 ++-- packages/sdk/src/redaction.ts | 2 +- packages/sdk/src/scan-lifecycle.ts | 23 +----- packages/sdk/src/session-events.ts | 6 +- .../sdk/tests/e2e/bailian-adapter.test.ts | 3 +- 27 files changed, 195 insertions(+), 148 deletions(-) diff --git a/apps/server/src/lib/build-runtime-config.ts b/apps/server/src/lib/build-runtime-config.ts index fd462a7..9a5c70e 100644 --- a/apps/server/src/lib/build-runtime-config.ts +++ b/apps/server/src/lib/build-runtime-config.ts @@ -33,6 +33,10 @@ export async function buildRuntimeConfig(): Promise { // Every provider provisions a cloud sandbox environment; bailian additionally installs // `bailian-cli` in it. The vault is provider-conditional: bailian holds its DASHSCOPE_API_KEY; // other providers currently define no vault. + // + // Metadata stamps (`agents.base` / `agents.vault`) let the webui identify managed base + // resources via remote listing (findBaseEnvironment / findBaseVault), complementing the + // state-tracked identity the plan/apply engine provides. const vaults = vault ? { [vault.name]: { @@ -45,6 +49,7 @@ export async function buildRuntimeConfig(): Promise { secret_value: requireEnv(cred.secret_name), ...(cred.networking ? { networking: cred.networking } : {}), })), + metadata: { "agents.vault": "true" }, }, } : {}; @@ -52,6 +57,7 @@ export async function buildRuntimeConfig(): Promise { [environment.name]: { ...(environment.description ? { description: environment.description } : {}), config: environment.config, + metadata: { "agents.base": "true" }, }, }; diff --git a/apps/server/src/lib/state-scope.ts b/apps/server/src/lib/state-scope.ts index 4fb26f0..50ed0dc 100644 --- a/apps/server/src/lib/state-scope.ts +++ b/apps/server/src/lib/state-scope.ts @@ -1,16 +1,75 @@ -import { resolve } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { LocalFileStateBackend, type StateScope } from "@openagentpack/sdk"; import { RUNTIME_PROJECT_NAME } from "@/lib/build-runtime-config"; -// Historical on-disk state anchor. Previously derived via deriveStatePath() from the -// demo file examples/bailian/bailian-cli/agents.yaml → agents.state.json. We pin the default -// to that exact location so previously provisioned remote_ids (agents/vault/environment) -// keep resolving and are not re-created. Override with AGENTS_STATE_PATH. -const DEFAULT_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json"; +/** + * Default playground state lives in the user home directory, alongside the + * provider config (~/.agents/config.json). This avoids colliding with CLI + * example configs that happen to live in the repo's examples/ directory. + * + * Override with AGENTS_STATE_PATH for custom deployment layouts. + */ +const DEFAULT_STATE_PATH = join(homedir(), ".agents", "playground.state.json"); + +/** + * Legacy state path. Before this migration the server stored its state inside + * the repo's example directory. We migrate it once so previously-provisioned + * remote_ids are preserved. + */ +const LEGACY_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json"; + +let migrationChecked = false; + +/** + * One-time migration: if the legacy state file exists and the new default does + * not, copy it over and log a warning. Idempotent — subsequent calls after a + * successful migration (or when no migration is needed) are no-ops. A failed + * copy allows retry on the next call so transient I/O errors don't permanently + * disable migration for the process lifetime. + */ +function ensureMigrated(newPath: string, cwd: string): void { + if (migrationChecked) return; + + if (existsSync(newPath)) { + migrationChecked = true; + return; + } + const legacyPath = resolve(cwd, LEGACY_STATE_PATH); + if (!existsSync(legacyPath)) { + migrationChecked = true; + return; + } + + try { + mkdirSync(dirname(newPath), { recursive: true }); + copyFileSync(legacyPath, newPath); + // Rename the legacy file so a version rollback won't silently read stale state. + try { + renameSync(legacyPath, `${legacyPath}.migrated`); + } catch { + // Non-fatal: the copy succeeded, state is in the new location. + } + migrationChecked = true; + console.warn( + `[state] Migrated playground state from legacy path:\n` + + ` ${legacyPath}\n` + + ` → ${newPath}\n` + + ` The legacy file has been renamed to ${legacyPath}.migrated.`, + ); + } catch (error) { + // Don't set migrationChecked — allow retry on next call. + console.warn(`[state] Failed to migrate legacy state file: ${error instanceof Error ? error.message : error}`); + } +} export function resolveStatePath(env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd()): string { const configured = env.AGENTS_STATE_PATH?.trim(); - return configured ? resolve(cwd, configured) : resolve(cwd, DEFAULT_STATE_PATH); + if (configured) return resolve(cwd, configured); + + ensureMigrated(DEFAULT_STATE_PATH, cwd); + return DEFAULT_STATE_PATH; } export function deriveWebUiStateScope(): StateScope { diff --git a/apps/server/src/routes/vaults.ts b/apps/server/src/routes/vaults.ts index 2df759e..e6ceaba 100644 --- a/apps/server/src/routes/vaults.ts +++ b/apps/server/src/routes/vaults.ts @@ -68,8 +68,8 @@ vaultsRoute.openapi(createVaultRoute, async (c) => { const secretValue = key ?? (primarySecret ? requireEnv(primarySecret) : ""); const vault = await withAgentRuntime(DEFAULT_AGENT_ID, (ctx) => createCloudVault(ctx, name, { - // display_name carries the base-vault identity (Agents/secrets) — a vault has no - // separate `name` field, so findBaseVault nets it by display_name + the stamp. + // display_name is used as the vault's human-readable label on the provider. + // findBaseVault identifies the managed vault by its metadata stamp (agents.vault). display_name: name, metadata, credentials: structure.credentials.map((cred) => ({ diff --git a/apps/server/src/schemas/sessions.ts b/apps/server/src/schemas/sessions.ts index 5a7bd05..582247d 100644 --- a/apps/server/src/schemas/sessions.ts +++ b/apps/server/src/schemas/sessions.ts @@ -38,7 +38,7 @@ export const SessionDeleteResponseSchema = z }) .openapi("SessionDeleteResponse"); -// Mode A REST request ergonomics (server-owned input, not the shared DTO). +// REST request ergonomics (server-owned input, not the shared DTO). export const SessionsQuerySchema = z.object({ // Non-numeric values resolve to `undefined` so the handler clamps to a // default instead of the request being rejected with a 400. @@ -71,8 +71,7 @@ export const SessionParamsSchema = z.object({ export const CreateSessionBodySchema = z.object({ agentId: z.string(), prompt: z.string().min(1), - // Required: a session must be pinned to a cloud environment (sandbox). Both transports - // enforce this so Mode A (REST/OpenAPI) and Mode B (console) reject env-less creates. + // Required: a session must be pinned to a cloud environment (sandbox). environmentId: z.string().min(1), // Optional: cloud vault ids to bind a user-supplied credential so the sandbox receives it. // Top-level binding shape matches the console createSession. diff --git a/apps/server/src/schemas/vaults.ts b/apps/server/src/schemas/vaults.ts index 53c49ab..2095f03 100644 --- a/apps/server/src/schemas/vaults.ts +++ b/apps/server/src/schemas/vaults.ts @@ -10,9 +10,8 @@ export const CloudVaultsResponseSchema = z.object({ export const CreateVaultBodySchema = z.object({ name: z.string().min(1), metadata: z.record(z.string(), z.string()).optional(), - // The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional: Mode B - // supplies the user's key; Mode A (local) omits it and the server injects it from its own - // DASHSCOPE_API_KEY env. (Mode B never reaches this REST route — it goes via console RPC.) + // The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional: + // when omitted the server injects it from its own DASHSCOPE_API_KEY env. key: z.string().min(1).optional(), }); diff --git a/apps/server/src/services/agents/catalog.ts b/apps/server/src/services/agents/catalog.ts index 83dab79..1be7b05 100644 --- a/apps/server/src/services/agents/catalog.ts +++ b/apps/server/src/services/agents/catalog.ts @@ -3,6 +3,7 @@ import { DEFAULT_PLAYBOOK_PROVIDER, getDefaultPlaybook, getPlaybook, + getVaultProfile, type PlaybookTemplate, type ResolvedPlaybook, resolvePlaybookModel, @@ -80,9 +81,10 @@ export function compileAgentRuntime( const config = cloneConfig(baseConfig); const resolved = resolveSeedPlaybook(effectiveId, provider); const runtimeAgentId = resolved.agent.name; - const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, modelOverride)); - // The agent declares neither environment nor vault: base resources are provisioned eagerly - // and bound per-session via explicit environment_id + vault_ids. + const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, baseConfig, modelOverride)); + // The agent declares environment and vault so syncAgentResources manages them + // through the plan/apply engine — giving base resources state tracking, drift + // detection, and the same content-hash identity as agent/skill resources. config.agents = { ...(config.agents ?? {}), @@ -154,12 +156,45 @@ export function computeAgentConfigHash(config: ResolvedProjectConfig, agentId: s return createHash("sha256").update(stableStringify({ agentId, config })).digest("hex").slice(0, 16); } -function toAgentBuildInput(resolved: ResolvedPlaybook, provider: string, modelOverride?: string): AgentBuildInput { +function toAgentBuildInput( + resolved: ResolvedPlaybook, + provider: string, + baseConfig: ResolvedProjectConfig, + modelOverride?: string, +): AgentBuildInput { const model = resolvePlaybookModel(resolved, provider, modelOverride); + // Resolve environment and vault names from the assembled config so the compiled + // agent declares them. This lets syncAgentResources manage base resources + // through the plan/apply engine, giving them state tracking and drift detection. + // Invariant: buildRuntimeConfig produces exactly one environment (and at most one + // vault for providers that need credentials). If this assumption breaks, the agent + // would silently bind the wrong resource — fail fast instead. + const envKeys = Object.keys(baseConfig.environments ?? {}); + if (envKeys.length !== 1) { + throw new Error( + `Expected exactly 1 environment in runtime config, got ${envKeys.length}. ` + + `The agent compile path assumes a single base environment per provider.`, + ); + } + const environmentName = envKeys[0]!; + const vaultProfile = getVaultProfile(provider); + let vaultName: string | undefined; + if (vaultProfile) { + const vaultKeys = Object.keys(baseConfig.vaults ?? {}); + if (vaultKeys.length !== 1) { + throw new Error( + `Expected exactly 1 vault in runtime config for provider '${provider}', got ${vaultKeys.length}. ` + + `The agent compile path assumes a single base vault per provider.`, + ); + } + vaultName = vaultKeys[0]!; + } return { description: resolved.agent.description, model, instructions: resolved.agent.system, + environment: environmentName, + vault: vaultName, provider, builtinTools: resolved.agent.builtinTools, skills: resolved.agent.skills.map((skill) => diff --git a/apps/server/src/services/files/upload.ts b/apps/server/src/services/files/upload.ts index a217906..f00a968 100644 --- a/apps/server/src/services/files/upload.ts +++ b/apps/server/src/services/files/upload.ts @@ -47,8 +47,7 @@ export async function listUserFiles(): Promise { /** * Resolve a short-lived presigned download URL for a file (e.g. an agent-delivered artifact). - * Throws when the resolved provider has no download endpoint (bailian/claude); the webui's Mode A - * transport surfaces that as an error, while Mode B never calls this route. + * Throws when the resolved provider has no download endpoint (bailian/claude). */ export async function getUserFileDownloadUrl(id: string): Promise<{ url: string; expires_at?: string }> { return withAgentRuntime(DEFAULT_AGENT_ID, async (ctx, compiled) => { diff --git a/apps/server/src/services/sessions/playbook-session-adapter/index.ts b/apps/server/src/services/sessions/playbook-session-adapter/index.ts index 2b44098..533e02a 100644 --- a/apps/server/src/services/sessions/playbook-session-adapter/index.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/index.ts @@ -26,10 +26,10 @@ import { sendPlaybookSessionMessage, } from "./sessions"; -export type { ModeAPlaybookSessionDetail } from "./sessions"; +export type { PlaybookSessionDetail } from "./sessions"; -export function createModeAPlaybookSessionRuntime() { - return createPlaybookSessionRuntime({ +export function createServerPlaybookSessionRuntime() { + return createPlaybookSessionRuntime({ identity: { appId: getPlaybookAppId(), expectedAgentName: getSeedPlaybookAgentName, @@ -95,4 +95,4 @@ export function createModeAPlaybookSessionRuntime() { }); } -type ModeAPlaybookSessionDetail = import("./sessions").ModeAPlaybookSessionDetail; +type PlaybookSessionDetail = import("./sessions").PlaybookSessionDetail; diff --git a/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts b/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts index d8121e8..665c85b 100644 --- a/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts @@ -16,18 +16,18 @@ import { withAgentRuntime } from "@/services/runtime-factory"; import { createEventBuffer, seedCompletedBuffer } from "@/services/sessions/event-buffer"; import { sortByUpdatedDesc, toSession } from "./dto"; -export type ModeAPlaybookSessionDetail = { +export type PlaybookSessionDetail = { session: Session; events: ProviderSessionEvent[]; eventsNextPageToken?: string; }; -export type ModeASessionEventsPage = { +export type SessionEventsPage = { events: ProviderSessionEvent[]; eventsNextPageToken?: string; }; -type ModeAListPlaybookSessionsInput = { +type ListPlaybookSessionsInput = { playbookId?: string; remoteAgentId?: string; limit?: number; @@ -35,7 +35,7 @@ type ModeAListPlaybookSessionsInput = { }; export async function listPlaybookSessions( - input: ModeAListPlaybookSessionsInput, + input: ListPlaybookSessionsInput, ): Promise<{ sessions: Session[]; nextPageToken?: string }> { const limit = input.limit ?? 50; const page = input.pageToken?.trim() || undefined; @@ -77,7 +77,7 @@ async function listProviderSessionEventsPage( sessionId: string, provider: string, options: { pageToken?: string; limit?: number } = {}, -): Promise { +): Promise { const limit = options.limit ?? 100; const eventList = await listSessionEvents(ctx, sessionId, { provider, @@ -96,7 +96,7 @@ export async function listProviderSessionEvents( agentId = DEFAULT_AGENT_ID, pageToken?: string, limit?: number, -): Promise { +): Promise { const catalogAgentId = getSessionAgent(agentId) ? agentId : DEFAULT_AGENT_ID; return withAgentRuntime(catalogAgentId, async (ctx, compiled) => { const agent = getAgent(ctx, compiled.agentId); @@ -107,7 +107,7 @@ export async function listProviderSessionEvents( export async function readProviderSessionDetail( sessionId: string, agentId = DEFAULT_AGENT_ID, -): Promise { +): Promise { const catalogAgentId = getSessionAgent(agentId) ? agentId : DEFAULT_AGENT_ID; return withAgentRuntime(catalogAgentId, async (ctx, compiled) => { const agent = getAgent(ctx, compiled.agentId); diff --git a/apps/server/src/services/sessions/runner.ts b/apps/server/src/services/sessions/runner.ts index 1b9fe35..fa90a9d 100644 --- a/apps/server/src/services/sessions/runner.ts +++ b/apps/server/src/services/sessions/runner.ts @@ -16,8 +16,8 @@ import { import { DEFAULT_AGENT_ID, getSessionAgent } from "@/services/agents/catalog"; import { loadAgentRuntimeInput, withAgentRuntime } from "@/services/runtime-factory"; import { - createModeAPlaybookSessionRuntime, - type ModeAPlaybookSessionDetail, + createServerPlaybookSessionRuntime, + type PlaybookSessionDetail, } from "@/services/sessions/playbook-session-adapter"; import { listProviderSessionEvents } from "@/services/sessions/playbook-session-adapter/sessions"; @@ -43,9 +43,9 @@ export async function updatePlaybookAgentModel(slug: string, model: string): Pro import { createEventBuffer, seedCompletedBuffer } from "@/services/sessions/event-buffer"; /** A session plus its raw provider events (events are mapped to the contract at the route boundary). */ -export type SessionWithEvents = ModeAPlaybookSessionDetail; +export type SessionWithEvents = PlaybookSessionDetail; -const playbookSessionRuntime = createModeAPlaybookSessionRuntime(); +const playbookSessionRuntime = createServerPlaybookSessionRuntime(); export async function listSessionsForAgent(input: { agentId?: string; diff --git a/apps/server/src/services/skills/manage.ts b/apps/server/src/services/skills/manage.ts index 0e3e487..32cdf02 100644 --- a/apps/server/src/services/skills/manage.ts +++ b/apps/server/src/services/skills/manage.ts @@ -84,7 +84,7 @@ export async function deleteUserSkill(id: string): Promise { // Warm polling: the custom-skill security scan runs 3–5 min on the production workspace, so poll // gently (one provider call every SCAN_POLL_INTERVAL_MS) to keep load light — warming is off the // user's critical path, so cadence matters more than latency. Timeouts and cadence come from the -// shared scan-lifecycle module so they cannot drift from the SDK/Mode B copies. +// shared scan-lifecycle module so they cannot drift. /** * Pre-provision a seed-declared custom skill (name + downloadable url) ahead of first click: dedupe diff --git a/apps/server/tests/runtime-config.test.ts b/apps/server/tests/runtime-config.test.ts index 927711a..077c26f 100644 --- a/apps/server/tests/runtime-config.test.ts +++ b/apps/server/tests/runtime-config.test.ts @@ -12,9 +12,10 @@ describe("WebUI state scope", () => { expect(deriveWebUiStateScope()).toEqual({ projectId: RUNTIME_PROJECT_NAME }); }); - test("default state path preserves the historical on-disk location", () => { - const cwd = "/repo"; - expect(resolveStatePath({}, cwd)).toBe(resolve(cwd, "examples/bailian/bailian-cli/agents.state.json")); + test("default state path points to ~/.agents/playground.state.json", () => { + const { homedir } = require("node:os"); + const { join } = require("node:path"); + expect(resolveStatePath({}, "/repo")).toBe(join(homedir(), ".agents", "playground.state.json")); }); test("AGENTS_STATE_PATH overrides the default", () => { diff --git a/apps/server/tests/session-agents.test.ts b/apps/server/tests/session-agents.test.ts index 9a861e1..f3da19b 100644 --- a/apps/server/tests/session-agents.test.ts +++ b/apps/server/tests/session-agents.test.ts @@ -46,7 +46,7 @@ describe("session agent compiler", () => { expect(agent?.model).toBe("glm-5.1"); }); - test("stamps playbook metadata as the shared App identity (same key Mode B writes)", () => { + test("stamps playbook metadata as the shared App identity", () => { const compiled = compileAgentRuntime("base", baseConfig()); const agent = compiled.config.agents?.[compiled.agentId]; @@ -64,9 +64,12 @@ describe("session agent compiler", () => { const agent = compiled.config.agents?.[compiled.agentId]; expect(agent?.instructions).toContain("【角色设定】"); - // The agent declares no environment: the base sandbox is a standalone resource bound - // per-session via environment_id, not owned by the agent decl. - expect(agent?.environment).toBeUndefined(); + // The agent declares its environment and vault so syncAgentResources manages + // base resources through the plan/apply engine (state tracking + drift detection). + const environment = getEnvironmentProfile(); + const vault = getVaultProfile(); + expect(agent?.environment).toBe(environment.name); + expect(agent?.vault).toBe(vault?.name); }); test("emits official skills when playbook template declares them", () => { @@ -145,6 +148,7 @@ function baseConfig(provider = "bailian"): ResolvedProjectConfig { secret_value: "test-secret", ...(cred.networking ? { networking: cred.networking } : {}), })), + metadata: { "agents.vault": "true" }, }, } : {}, @@ -153,6 +157,7 @@ function baseConfig(provider = "bailian"): ResolvedProjectConfig { [environment.name]: { ...(environment.description ? { description: environment.description } : {}), config: environment.config, + metadata: { "agents.base": "true" }, }, } : {}, diff --git a/apps/webui/src/lib/domain/resource-center/resources.ts b/apps/webui/src/lib/domain/resource-center/resources.ts index ce84383..2f8808f 100644 --- a/apps/webui/src/lib/domain/resource-center/resources.ts +++ b/apps/webui/src/lib/domain/resource-center/resources.ts @@ -44,8 +44,8 @@ export function deriveEnvironments(environments: CloudEnvironment[]): { rows: Re /** * Project-scoped vault rows: mirrors deriveEnvironments. This project provisions exactly one — - * the managed base vault (display_name Agents/secrets + agents.vault stamp, via findBaseVault) - * holding the DASHSCOPE_API_KEY (server-injected) — so foreign org vaults are excluded entirely. + * the managed base vault (agents.vault stamp, via findBaseVault) holding the DASHSCOPE_API_KEY + * (server-injected) — so foreign org vaults are excluded entirely. */ export function deriveVaults(vaults: CloudVault[]): { rows: ResourceVaultRow[]; baseVaultId?: string } { const base = findBaseVault(vaults); diff --git a/apps/webui/src/lib/domain/vault.ts b/apps/webui/src/lib/domain/vault.ts index 5cea465..4558885 100644 --- a/apps/webui/src/lib/domain/vault.ts +++ b/apps/webui/src/lib/domain/vault.ts @@ -4,8 +4,9 @@ import { formatApiErrorMessage } from "../api/error-message"; import { resolveStampedResource } from "../resolve-stamped"; // Identity of the shared base vault — the credential store holding the user-supplied -// DASHSCOPE_API_KEY. Mirrors the base-environment scheme (Agents/ name prefix + a metadata -// stamp): a vault has no `name` field, so identity rides on `display_name` + the stamp. +// DASHSCOPE_API_KEY. Identity relies on the metadata stamp alone (not display_name) because +// the engine and the imperative warm path may create vaults with different display_name values +// ("Cli Secrets" vs "Agents/secrets"), but both always set the `agents.vault` stamp. const BASE_VAULT_NAME = "Agents/secrets"; const BASE_VAULT_METADATA_KEY = "agents.vault"; const BASE_VAULT_METADATA_VALUE = "true"; @@ -14,15 +15,15 @@ const BASE_VAULT_METADATA: Record = { }; /** - * Two-layer lookup of the managed base vault (mirrors `findBaseEnvironment`): layer 1 nets - * by `display_name === "Agents/secrets"`; layer 2 re-confirms our stamp `agents.vault === "true"`. - * A display-name match WITHOUT the stamp is a foreign vault and is ignored. On duplicates, - * the most recently updated stamped vault wins. + * Identity lookup of the managed base vault via the metadata stamp + * `agents.vault === "true"`. The stamp is set both by the engine (plan/apply) + * and by the legacy imperative create path, so it is the universal identity + * signal regardless of how the vault was provisioned. On duplicates the most + * recently updated stamped vault wins. */ export function findBaseVault(vaults: CloudVault[]): CloudVault | undefined { return resolveStampedResource(vaults, { - matches: (vault) => - vault.display_name === BASE_VAULT_NAME && vault.metadata?.[BASE_VAULT_METADATA_KEY] === BASE_VAULT_METADATA_VALUE, + matches: (vault) => vault.metadata?.[BASE_VAULT_METADATA_KEY] === BASE_VAULT_METADATA_VALUE, updatedAt: (vault) => vault.updated_at, }).winner; } diff --git a/apps/webui/src/lib/playbooks/index.ts b/apps/webui/src/lib/playbooks/index.ts index 0f2d4eb..547526a 100644 --- a/apps/webui/src/lib/playbooks/index.ts +++ b/apps/webui/src/lib/playbooks/index.ts @@ -1,48 +1,34 @@ import { BASE_PLAYBOOK_ID, - createPlaybookSessionRuntime, DEFAULT_PLAYBOOK_PROVIDER, getDefaultPlaybook, getEnvironmentProfile, getPlaybook, getPlaybookAppId, getPlaybookDisplayName, - getSeedPlaybookAgentName, getVaultProfile, listPlaybookCards, listPlaybooks, PLAYBOOK_AGENT_NAME_PREFIX, PLAYBOOK_APP_METADATA_KEY, - type PlaybookReadiness, - pickPlaybookAgent, - playbookIdentityMismatchMessage, - type RemotePlaybookAgent, - readinessFromPick, resolveSeedPlaybookSkills, - type StartPlaybookSessionInput, } from "@openagentpack/playbooks"; import showcaseJson from "@/data/showcase.json"; import { resolveActivePlaybookProvider } from "@/lib/domain/config-api"; import type { ShowcaseCard } from "@/lib/showcase-types"; import type { RoleCard } from "./types"; -export type { PlaybookReadiness, RemotePlaybookAgent, StartPlaybookSessionInput }; export { BASE_PLAYBOOK_ID, - createPlaybookSessionRuntime, DEFAULT_PLAYBOOK_PROVIDER, getEnvironmentProfile, getPlaybook, getPlaybookAppId, getPlaybookDisplayName, - getSeedPlaybookAgentName, getVaultProfile, listPlaybooks, PLAYBOOK_AGENT_NAME_PREFIX, PLAYBOOK_APP_METADATA_KEY, - pickPlaybookAgent, - playbookIdentityMismatchMessage, - readinessFromPick, resolveSeedPlaybookSkills, }; diff --git a/apps/webui/tests/scan-lifecycle-parity.test.ts b/apps/webui/tests/scan-lifecycle-parity.test.ts index 5ee9a64..d5fb6fc 100644 --- a/apps/webui/tests/scan-lifecycle-parity.test.ts +++ b/apps/webui/tests/scan-lifecycle-parity.test.ts @@ -1,46 +1,25 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; -import { - classifyFileScan, - classifySkillScan, - SKILL_STATUS_CODE, - skillStatusFromCode, - skillStatusFromString, -} from "@openagentpack/sdk/scan-lifecycle"; +import { classifyFileScan, classifySkillScan, skillStatusFromString } from "@openagentpack/sdk/scan-lifecycle"; // ───────────────────────────────────────────────────────────────────────────── // Scan-lifecycle drift guard. The file/skill content-audit state machine — -// timeouts, poll cadence, status buckets, and the numeric↔string status mapping — -// used to live as hand-copied constants and if-chains across several surfaces -// (SDK adapter, server warming, the ResourceCenter UI), kept aligned only by -// "Kept in sync" comments. They now share one module (@openagentpack/sdk/scan-lifecycle). -// This test turns the comment contracts into assertions: (1) the numeric and -// string status mappings agree on terminal buckets — the `2 / unsafe → rejected` -// security invariant — and (2) no consumer re-declares the shared constants, so -// a future edit that forks one copy goes red. +// timeouts, poll cadence, and status buckets — used to live as hand-copied +// constants and if-chains across several surfaces (SDK adapter, server warming, +// the ResourceCenter UI), kept aligned only by "Kept in sync" comments. They now +// share one module (@openagentpack/sdk/scan-lifecycle). This test turns the +// comment contracts into assertions so a future edit that forks one copy goes red. // ───────────────────────────────────────────────────────────────────────────── -describe("numeric ↔ string skill status normalization isomorphism", () => { - test("every console numeric code maps to the same scan bucket as its neutral string", () => { - for (const [name, code] of Object.entries(SKILL_STATUS_CODE)) { - const fromCode = skillStatusFromCode(code); - expect(fromCode).toBe(name as ReturnType); - // Both transports must agree on the terminal bucket for the same logical status. - expect(classifySkillScan(fromCode)).toBe(classifySkillScan(skillStatusFromString(name))); - } - }); - - test("the security-scan failure terminal (2 / unsafe / rejected) all bucket as failed", () => { - expect(classifySkillScan(skillStatusFromCode(SKILL_STATUS_CODE.rejected))).toBe("failed"); +describe("string skill status normalization", () => { + test("the security-scan failure terminal (unsafe / rejected) buckets as failed", () => { expect(classifySkillScan(skillStatusFromString("unsafe"))).toBe("failed"); expect(classifySkillScan(skillStatusFromString("rejected"))).toBe("failed"); }); - test("unknown codes/strings keep polling rather than reading as terminal", () => { - expect(skillStatusFromCode(999)).toBe("checking"); - expect(skillStatusFromCode(undefined)).toBe("checking"); + test("unknown strings keep polling rather than reading as terminal", () => { expect(skillStatusFromString("security_scanning")).toBe("checking"); - expect(classifySkillScan(skillStatusFromCode(999))).toBe("pending"); + expect(classifySkillScan(skillStatusFromString("security_scanning"))).toBe("pending"); }); }); @@ -98,7 +77,7 @@ describe("single-source constants (no consumer re-declares them)", () => { // The raw literals that used to be physically copied. They must appear exactly // once — in the shared module — and nowhere in the consumers. - const FORKABLE = ["360_000", "120_000", "{ checking: 0"]; + const FORKABLE = ["360_000", "120_000"]; test("the shared module declares the constants", async () => { const src = await Bun.file(sharedModule).text(); diff --git a/packages/playbooks/src/resolve.ts b/packages/playbooks/src/resolve.ts index 8928bf1..6da1947 100644 --- a/packages/playbooks/src/resolve.ts +++ b/packages/playbooks/src/resolve.ts @@ -24,7 +24,7 @@ export function getPlaybookAgentName(_bundle: PlaybookBundle, playbookId: string * Resolve the model id to create the agent with. Playbook templates author a bailian-native model, * so the target provider's default is substituted unless the caller overrides it for this run; * falls back to the template model when the provider has no registered default. Single source of - * truth for both transports (Mode A catalog provisioning and Mode B console createAgent). + * truth for both the catalog provisioning and programmatic createAgent paths. */ export function resolvePlaybookModel(resolved: ResolvedPlaybook, provider: string, modelOverride?: string): string { return modelOverride ?? PROVIDER_DEFAULTS[provider]?.model ?? resolved.agent.model; diff --git a/packages/playbooks/src/session-runtime.ts b/packages/playbooks/src/session-runtime.ts index 4dfc559..85f8d3a 100644 --- a/packages/playbooks/src/session-runtime.ts +++ b/packages/playbooks/src/session-runtime.ts @@ -124,8 +124,8 @@ export type PlaybookSessionRuntimeAdapters< onDuplicateAgent?: (input: { playbookId: string; winner: TAgent; duplicates: TAgent[] }) => void; }; -// Single source of the identity-mismatch copy, shared by the runtime error, the -// readiness mapping, and Mode B's resolver throw. +// Single source of the identity-mismatch copy, shared by the runtime error and +// the readiness mapping. export function playbookIdentityMismatchMessage(playbookId: string): string { return `玩法「${playbookId}」存在同应用同名 Agent,但 metadata.${PLAYBOOK_APP_METADATA_KEY}/${PLAYBOOK_METADATA_KEY} 未对上,疑似身份未盖章;请检查配置而非重复创建。`; } @@ -159,8 +159,8 @@ export function pickPlaybookAgent( return { agent, duplicates, identityMismatch: false }; } -// The single source of the pick→PlaybookReadiness mapping, shared by the per-id runtime -// path and Mode B's first-screen batch prime, so identity-drift handling lives in one place. +// The single source of the pick→PlaybookReadiness mapping, so identity-drift +// handling lives in one place. export function readinessFromPick( pick: PlaybookAgentPick, playbookId: string, diff --git a/packages/sdk/src/internal/core/agent-builder.ts b/packages/sdk/src/internal/core/agent-builder.ts index f854dc6..c64ddac 100644 --- a/packages/sdk/src/internal/core/agent-builder.ts +++ b/packages/sdk/src/internal/core/agent-builder.ts @@ -24,6 +24,7 @@ export interface AgentBuildInput { model?: AgentDecl["model"]; instructions?: string; environment?: string; + vault?: string; provider?: string; builtinTools?: string[]; skills?: AgentSkillBuildInput[]; @@ -82,6 +83,7 @@ export function buildAgentDecl(base: AgentDecl | undefined, input: AgentBuildInp model, instructions: input.instructions ?? base?.instructions ?? "", ...(input.environment ? { environment: input.environment } : {}), + ...(input.vault ? { vault: input.vault } : {}), provider: input.provider ?? base?.provider, tools: { ...(base?.tools ?? { builtin: [] }), diff --git a/packages/sdk/src/internal/core/session-event-sanitizer.ts b/packages/sdk/src/internal/core/session-event-sanitizer.ts index 4c46c9f..d21851f 100644 --- a/packages/sdk/src/internal/core/session-event-sanitizer.ts +++ b/packages/sdk/src/internal/core/session-event-sanitizer.ts @@ -1,6 +1,6 @@ // Pure ProviderSessionEvent → contract SessionEvent sanitizer. Type-only imports keep this // module free of any Node/runtime dependency, so it is safe to bundle into a browser via the -// `@openagentpack/sdk/session-events` subpath (consumed by the webui Mode B console-direct transport). +// `@openagentpack/sdk/session-events` subpath. import { redactSensitiveText } from "../../redaction.ts"; import type { SessionContentBlock, SessionEvent } from "../types/dto.ts"; @@ -24,8 +24,8 @@ export function sanitizeSessionEvent( // contract event, preserving the original Agents `type` via `raw_type` so no detail // is dropped. Transport concerns added here: secret redaction + truncation, // surfaced as `metadata.redacted` / `metadata.truncated` flags (the contract event - // has no dedicated fields for them, and Mode B trusts the base, so they live in the - // open metadata bag where the presentation layer can read them uniformly). + // has no dedicated fields for them, so they live in the open metadata bag where the + // presentation layer can read them uniformly). const isToolEvent = event.type === "tool_result" || event.type === "tool_use"; const textLimit = isToolEvent ? TOOL_TEXT_LIMIT : DEFAULT_TEXT_LIMIT; const primarySource = event.content ?? (event.type === "tool_use" ? event.tool_input : undefined); diff --git a/packages/sdk/src/internal/providers/bailian/event-mapper.ts b/packages/sdk/src/internal/providers/bailian/event-mapper.ts index 3637561..fcdad88 100644 --- a/packages/sdk/src/internal/providers/bailian/event-mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/event-mapper.ts @@ -1,6 +1,6 @@ // Pure Bailian raw-event → ProviderSessionEvent mapper. Type-only imports keep this module free // of any Node/runtime dependency, so it is safe to bundle into a browser via the -// `@openagentpack/sdk/session-events` subpath (consumed by the webui console-direct transport). +// `@openagentpack/sdk/session-events` subpath. import type { SessionEventType } from "../../types/dto.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; @@ -57,12 +57,12 @@ export function toSessionEvent(raw: Record): ProviderSessionEve event.content = (raw.message as string) ?? extractContentText(raw); } - // TODO(artifact-download, Mode B): bailian's `download_file` builtin flows through + // TODO(artifact-download): bailian's `download_file` builtin flows through // tool_call/tool_call_output above — there is NO structured artifact-delivered event in the // current wire, and we have no real bailian delivery sample to confirm one. When such a sample // exists, populate `event.artifact = { file_id, filename, content_type, size }` here (mirroring // qoder/mapper.ts) so the shared sanitizer surfaces it as metadata.artifact and the webui shows - // the same download card. Until then Mode B simply produces no artifact (graceful: no card). + // the same download card. return event; } diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index 4eb08ac..410e93a 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -112,8 +112,7 @@ export type AgentRecoveryAction = z.infer; // A raw cloud agent as listed from the provider (Bailian `/agents` list) — the actual // remote object, NOT a local config agent. This is the resource-center's source of truth: // it surfaces same-name duplicates and identity-stamp drift (metadata.playbook vs agents.*) that -// the local config view cannot. Wire fields are snake_case across both transports; Mode B -// (console RPC) normalizes its camelCase into this shape. tools/skills/mcp_servers stay +// the local config view cannot. Wire fields are snake_case. tools/skills/mcp_servers stay // loose (`unknown`) because shapes differ by provider and the resource center only inspects // metadata/name/timestamps for classification. export const CloudAgentSchema = z.object({ @@ -142,9 +141,8 @@ export type ListCloudAgentsResponse = z.infer; // ────────────────────────────────────────────────────────────────────────── -// Session-runtime contract — single source of truth. Wire fields are snake_case -// across every transport (Mode A REST and Mode B console RPC), so neither side -// remaps casing or renames fields. See openspec/specs/api-contract and -// openspec/specs/webui-console-direct. +// Session-runtime contract — single source of truth. Wire fields are snake_case. // ────────────────────────────────────────────────────────────────────────── // The Agents session-event `type` values. The contract keeps the full set for diff --git a/packages/sdk/src/redaction.ts b/packages/sdk/src/redaction.ts index ddff0e2..6c83611 100644 --- a/packages/sdk/src/redaction.ts +++ b/packages/sdk/src/redaction.ts @@ -1,5 +1,5 @@ // Pure secret-redaction helpers shared across transports (server-side sanitizer -// and the browser-side console-direct normalizer). Regex/URL only — no Node or zod deps, +// and browser-side normalizers). Regex/URL only — no Node or zod deps, // so this module is safe to import into a browser bundle via `@openagentpack/sdk/redaction`. const REDACTED = "[redacted]"; diff --git a/packages/sdk/src/scan-lifecycle.ts b/packages/sdk/src/scan-lifecycle.ts index 08bc950..38e67ac 100644 --- a/packages/sdk/src/scan-lifecycle.ts +++ b/packages/sdk/src/scan-lifecycle.ts @@ -13,7 +13,7 @@ export type ScanPhase = "pending" | "ready" | "failed"; // clear 5 min with margin. File audit clears faster (~15s–2min observed). export const SCAN_SKILL_TIMEOUT_MS = 360_000; export const SCAN_FILE_TIMEOUT_MS = 120_000; -// Warming / console poll cadence: one provider call every 8s keeps load light. Warming is off the +// Warm poll cadence: one provider call every 8s keeps load light. Warming is off the // user's critical path, so cadence matters more than latency. export const SCAN_POLL_INTERVAL_MS = 8000; @@ -26,25 +26,9 @@ export interface ScanBackoff { export const SKILL_SCAN_BACKOFF: ScanBackoff = { initial: 2000, factor: 2, max: 8000 }; export const FILE_SCAN_BACKOFF: ScanBackoff = { initial: 1000, factor: 1.5, max: 4000 }; -// Numeric ISkill(Version).status (console/provider internal) → neutral string. The wire enum is -// 0 checking / 1 active / 2 rejected / 100 deleted. -export const SKILL_STATUS_CODE = { checking: 0, active: 1, rejected: 2, deleted: 100 } as const; -const CODE_TO_SKILL_STATUS: Record = { - 0: "checking", - 1: "active", - 2: "rejected", - 100: "deleted", -}; - -/** Numeric console skill status → neutral SkillScanStatus (unknown codes read as still-checking). */ -export function skillStatusFromCode(code: number | undefined): SkillScanStatus { - return (code != null ? CODE_TO_SKILL_STATUS[code] : undefined) ?? "checking"; -} - // OpenAPI skill status string → neutral SkillScanStatus. Crucially `unsafe` is the security-scan // FAILURE terminal and must map to `rejected`, NOT `checking` — otherwise a failed skill displays -// as still-scanning forever. (Numeric 2 maps to the same `rejected` bucket via skillStatusFromCode, -// keeping both transports' status display in sync — now by shared code, not a comment contract.) +// as still-scanning forever. export function skillStatusFromString(raw: unknown): SkillScanStatus { switch (String(raw ?? "").toLowerCase()) { case "active": @@ -92,8 +76,7 @@ export interface PollUntilOptions { /** * Generic content-scan poll loop: poll → resolve on "ready", throw onFailed on "failed", throw - * onTimeout once the deadline passes, otherwise sleep and retry. Supersedes the four hand-rolled - * copies (SDK file/skill waits, server warm loops, Mode B waitForActive). + * onTimeout once the deadline passes, otherwise sleep and retry. */ export async function pollUntil(opts: PollUntilOptions): Promise { const start = Date.now(); diff --git a/packages/sdk/src/session-events.ts b/packages/sdk/src/session-events.ts index e4bea1d..7c77673 100644 --- a/packages/sdk/src/session-events.ts +++ b/packages/sdk/src/session-events.ts @@ -1,7 +1,7 @@ // Pure, browser-safe subpath barrel for the shared session-event mapping pipeline. -// Re-exports only type-only-importing modules so the webui (Mode B console-direct) -// can compose the same raw→ProviderSessionEvent→contract-SessionEvent pipeline that -// the server (Mode A) uses, WITHOUT pulling in the Node-only main SDK bundle. +// Re-exports only type-only-importing modules so browser consumers can compose the +// same raw→ProviderSessionEvent→contract-SessionEvent pipeline that the server uses, +// WITHOUT pulling in the Node-only main SDK bundle. export { sanitizeSessionEvent, sanitizeSessionEvents } from "./internal/core/session-event-sanitizer.ts"; export { toSessionEvent as toBailianSessionEvent } from "./internal/providers/bailian/event-mapper.ts"; diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index 9c35dd4..98f20d2 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -584,8 +584,7 @@ describe("BailianAdapter e2e", () => { // The OpenAPI skill status enum is security_scanning/active/unsafe/deleted (internal // 0/1/2/100), verified against the live provider API. `unsafe` is the scan-failure terminal and must - // surface as the neutral `rejected` — not `checking` — so it matches Mode B (internal 2 → - // rejected) and the UI shows 已拒绝 instead of a stuck 扫描中. + // surface as the neutral `rejected` — not `checking` — so the UI shows 已拒绝 instead of a stuck 扫描中. test("listSkills maps OpenAPI status enum to neutral status", async () => { const { restore } = mockFetch([ {