diff --git a/cli/package.json b/cli/package.json index 5610150789e..dc02695580c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -98,7 +98,6 @@ "devDependencies": { "@dify/tsconfig": "workspace:*", "@hono/node-server": "catalog:", - "@types/js-yaml": "catalog:", "@types/lockfile": "catalog:", "@types/node": "catalog:", "@typescript/native": "catalog:", diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts index a8ef958ce2f..254cd5778ae 100644 --- a/cli/src/api/app-meta.test.ts +++ b/cli/src/api/app-meta.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { startMock } from '@test/fixtures/dify-mock/server' import { testHttpClient } from '@test/fixtures/http-client' -import yaml from 'js-yaml' +import { dump, load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadAppInfoCache } from '@/cache/app-info' import { ENV_CACHE_DIR } from '@/store/dir' @@ -109,11 +109,11 @@ describe('AppMetaClient', () => { await new AppMetaClient({ apps, host: mock.url, cache: seed }).get('app-1', [FieldInfo]) // Reuse that serialized entry as a valid sibling; corrupt the app-1 slot. - const file = yaml.load(await readFile(path, 'utf8')) as { entries: Record } + const file = load(await readFile(path, 'utf8')) as { entries: Record } const validEntry = file.entries[`${mock.url}::app-1`] await writeFile( path, - yaml.dump({ entries: { + dump({ entries: { [`${mock.url}::app-1`]: 'corrupted-string', [`${mock.url}::sibling`]: validEntry, } }), diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts index 59d8e762020..9e007c004a7 100644 --- a/cli/src/cache/app-info.test.ts +++ b/cli/src/cache/app-info.test.ts @@ -2,7 +2,7 @@ import type { AppMeta } from '@/types/app-meta' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import yaml from 'js-yaml' +import { dump, load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager' @@ -112,9 +112,9 @@ describe('app-info disk cache', () => { await seed.set('h', 'app-2', metaInfoOnly('app-2')) // Inject a corrupt sibling alongside the real one. - const file = yaml.load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record } + const file = load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record } file.entries['h::app-1'] = 'corrupted-string-not-object' - await writeFile(appInfoPath(dir), yaml.dump(file), 'utf8') + await writeFile(appInfoPath(dir), dump(file), 'utf8') const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) expect(c.get('h', 'app-1')).toBeUndefined() @@ -122,7 +122,7 @@ describe('app-info disk cache', () => { }) it('treats a non-object entries map as empty', async () => { - await writeFile(appInfoPath(dir), yaml.dump({ entries: 'not-an-object' }), 'utf8') + await writeFile(appInfoPath(dir), dump({ entries: 'not-an-object' }), 'utf8') const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) }) expect(c.get('h', 'app-1')).toBeUndefined() }) @@ -137,7 +137,7 @@ describe('app-info disk cache', () => { } await c.set('h', 'app-1', slim) const raw = await readFile(appInfoPath(dir), 'utf8') - const parsed = yaml.load(raw) as { entries: Record } + const parsed = load(raw) as { entries: Record } expect(Object.keys(parsed.entries)).toHaveLength(1) }) }) diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts index 737235dabbd..a13a8d8100c 100644 --- a/cli/src/cache/nudge-store.test.ts +++ b/cli/src/cache/nudge-store.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import yaml from 'js-yaml' +import { load } from 'js-yaml' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ENV_CACHE_DIR } from '@/store/dir' import { CACHE_NUDGE, cachePath, getCache } from '@/store/manager' @@ -70,7 +70,7 @@ describe('NudgeStore', () => { const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t }) await store.markWarned(HOST) const raw = await readFile(nudgeStorePath(dir), 'utf8') - const parsed = yaml.load(raw) as Record + const parsed = load(raw) as Record expect((parsed.warned as Record)[HOST]).toBe(t.toISOString()) }) diff --git a/cli/src/framework/help.ts b/cli/src/framework/help.ts index 47af137d190..3803e7e2131 100644 --- a/cli/src/framework/help.ts +++ b/cli/src/framework/help.ts @@ -2,7 +2,7 @@ import type { CommandConstructor, CommandEffect } from './command' import type { CommandTree } from './registry' import type { ArgValueType, FlagDefinition } from './types' import type { HelpTopic } from '@/help/topics' -import yaml from 'js-yaml' +import { dump } from 'js-yaml' import { CONTRACT, GLOBAL_FLAG_HELP } from '@/help/contract' import { TOPICS } from '@/help/topics' import { collectCommands } from './registry' @@ -41,7 +41,7 @@ function isStructured(format: string): boolean { function serialize(value: unknown, format: string): string { if (format === 'yaml') - return yaml.dump(value, { indent: 2, lineWidth: -1 }) + return dump(value, { indent: 2, lineWidth: -1 }) return `${JSON.stringify(value, null, 2)}\n` } diff --git a/cli/src/framework/output.ts b/cli/src/framework/output.ts index fabf48caafe..6a5c32e02d8 100644 --- a/cli/src/framework/output.ts +++ b/cli/src/framework/output.ts @@ -1,4 +1,4 @@ -import yaml from 'js-yaml' +import { dump } from 'js-yaml' import { OutputFormatNotSupportedError } from './errors' export type RawOutput = { @@ -91,7 +91,7 @@ function stringifyFormattedOutput(output: FormattedOutput): case OutputFormat.JSON: return `${JSON.stringify(output.data.json(), null, 2)}\n` case OutputFormat.YAML: - return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 }) + return dump(output.data.json(), { indent: 2, lineWidth: -1 }) case OutputFormat.NAME: return `${toName(output.data)}\n` default: @@ -107,7 +107,7 @@ function stringifyTableOutput(output: TableOutput): string { case OutputFormat.JSON: return `${JSON.stringify(output.data.json(), null, 2)}\n` case OutputFormat.YAML: - return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 }) + return dump(output.data.json(), { indent: 2, lineWidth: -1 }) case OutputFormat.NAME: return `${toName(output.data)}\n` default: diff --git a/cli/src/store/errors.ts b/cli/src/store/errors.ts index c8cd10f4382..ae00a02ab7c 100644 --- a/cli/src/store/errors.ts +++ b/cli/src/store/errors.ts @@ -16,7 +16,7 @@ export class ConcurrentAccessError extends BaseError { type YamlMark = { line: number column: number - snippet?: string + snippet?: string | null } type YamlParseError = { diff --git a/cli/src/store/store.test.ts b/cli/src/store/store.test.ts index bc1d9242c7f..d0af00a7665 100644 --- a/cli/src/store/store.test.ts +++ b/cli/src/store/store.test.ts @@ -12,6 +12,18 @@ describe('YamlStore.doGet', () => { expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback') }) + it('returns default when content is empty', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('') + expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback') + }) + + it('returns default when content contains only comments', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('# empty configuration\n') + expect(store.doGet({ key: 'name', default: 'fallback' })).toBe('fallback') + }) + it('reads a flat key', () => { const store = new YamlStore('/irrelevant') store.setRawContent('name: alice\n') @@ -24,6 +36,51 @@ describe('YamlStore.doGet', () => { expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42) }) + it('resolves YAML merge keys', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('defaults: &defaults\n format: json\nselected:\n <<: *defaults\n') + expect(store.doGet({ key: 'selected.format', default: '' })).toBe('json') + }) + + it('parses timestamp scalars as dates', () => { + const store = new YamlStore('/irrelevant') + const timestamp = '2026-07-10T03:04:05.000Z' + store.setRawContent(`created_at: ${timestamp}\n`) + expect(store.doGet({ key: 'created_at', default: null })).toEqual(new Date(timestamp)) + }) + + it('rejects multiple YAML documents', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('name: alice\n---\nname: bob\n') + + expect(() => store.doGet({ key: 'name', default: '' })) + .toThrowError(/single document/) + }) + + it('rejects duplicate mapping keys', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('name: alice\nname: bob\n') + + expect(() => store.doGet({ key: 'name', default: '' })) + .toThrowError(/duplicated mapping key/) + }) + + it('rejects complex mapping keys', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('? [name, region]\n: deployment\n') + + expect(() => store.doGet({ key: 'name', default: '' })) + .toThrowError(/does not support complex keys/) + }) + + it('parses explicit YAML sets as JavaScript sets', () => { + const store = new YamlStore('/irrelevant') + store.setRawContent('features: !!set\n workflow:\n chat:\n') + + expect(store.doGet>({ key: 'features', default: new Set() })) + .toEqual(new Set(['workflow', 'chat'])) + }) + it('returns default for a missing flat key', () => { const store = new YamlStore('/irrelevant') store.setRawContent('name: alice\n') diff --git a/cli/src/store/store.ts b/cli/src/store/store.ts index 758c5a5b8b8..35b85888b0a 100644 --- a/cli/src/store/store.ts +++ b/cli/src/store/store.ts @@ -3,7 +3,18 @@ import type { Platform } from '@/sys' import { promises as fsp } from 'node:fs' import { dirname } from 'node:path' import { AsyncEntry } from '@napi-rs/keyring' -import yaml from 'js-yaml' +import { + binaryTag, + CORE_SCHEMA, + dump, + loadAll, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, + YAMLException, +} from 'js-yaml' import lockfile from 'lockfile' import { pid, resolvePlatform } from '@/sys' import { BadYamlFormatError, ConcurrentAccessError } from './errors' @@ -11,6 +22,7 @@ import { BadYamlFormatError, ConcurrentAccessError } from './errors' const FILE_PERM = 0o600 const DIR_PERM = 0o700 const LOCK_STALE_MS = 30_000 +const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag) function lockAsync(path: string, opts: LockOptions): Promise { return new Promise((resolve, reject) => { @@ -202,7 +214,7 @@ export class YamlStore extends FileBasedStore { async setTyped(data: T): Promise { await this.withLock(async () => { await this.load() - this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) + this.setRawContent(dump(data, { lineWidth: -1, noRefs: true })) await this.flush() }) } @@ -220,7 +232,7 @@ export class YamlStore extends FileBasedStore { current = current[part] as Record } current[lastKey] = value - this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) + this.setRawContent(dump(data, { lineWidth: -1, noRefs: true })) } doUnset(key: Key): void { @@ -239,7 +251,7 @@ export class YamlStore extends FileBasedStore { if (!(lastKey in current)) return delete current[lastKey] - this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true })) + this.setRawContent(dump(data, { lineWidth: -1, noRefs: true })) } } @@ -247,10 +259,13 @@ function loadYaml(raw: string | undefined, file_path: string): Record + const documents = loadAll(raw, { schema: YAML_LOAD_SCHEMA }) + if (documents.length > 1) + throw new YAMLException('expected a single document in the stream, but found more') + return (documents[0] ?? {}) as Record } catch (err) { - if (err instanceof yaml.YAMLException) + if (err instanceof YAMLException) throw new BadYamlFormatError(file_path, raw, err) throw err } diff --git a/cli/test/e2e/helpers/cli.ts b/cli/test/e2e/helpers/cli.ts index 18c6a213f34..f706383a85b 100644 --- a/cli/test/e2e/helpers/cli.ts +++ b/cli/test/e2e/helpers/cli.ts @@ -14,7 +14,7 @@ import { execSync, spawn } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import yaml from 'js-yaml' +import { dump } from 'js-yaml' /** Path to the dev entry point — no build required. */ export const BIN = resolve(__dirname, '../../../bin/dev.js') @@ -214,7 +214,7 @@ async function writeFileToken(configDir: string, host: string, email: string, be version: 1, tokens: { [host]: { [email]: bearer } }, } - await writeFile(join(configDir, 'tokens.yml'), yaml.dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 }) + await writeFile(join(configDir, 'tokens.yml'), dump(doc, { lineWidth: -1, noRefs: true }), { mode: 0o600 }) } /** diff --git a/packages/contracts/openapi-ts.enterprise.config.ts b/packages/contracts/openapi-ts.enterprise.config.ts index aae8dcb215b..15fe460e16f 100644 --- a/packages/contracts/openapi-ts.enterprise.config.ts +++ b/packages/contracts/openapi-ts.enterprise.config.ts @@ -2,7 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from '@hey-api/openapi-ts' -import yaml from 'js-yaml' +import { loadOpenApiYaml } from './openapi-yaml' type JsonObject = Record @@ -267,7 +267,7 @@ const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => { } const normalizeEnterpriseOpenApi = () => { - const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8')) + const openApi = loadOpenApiYaml(fs.readFileSync(enterpriseOpenApiPath, 'utf8')) if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi)) throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`) diff --git a/packages/contracts/openapi-yaml.test.ts b/packages/contracts/openapi-yaml.test.ts new file mode 100644 index 00000000000..5f255bba2a5 --- /dev/null +++ b/packages/contracts/openapi-yaml.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { loadOpenApiYaml } from './openapi-yaml' + +describe('loadOpenApiYaml', () => { + it('preserves merge keys and timestamps used by v4 documents', () => { + const document = loadOpenApiYaml(` +defaults: &defaults + version: 2026-07-10T03:04:05Z +info: + <<: *defaults +`) + + expect(document).toEqual({ + defaults: { version: new Date('2026-07-10T03:04:05Z') }, + info: { version: new Date('2026-07-10T03:04:05Z') }, + }) + }) + + it('rejects duplicate mapping keys', () => { + expect(() => loadOpenApiYaml('openapi: 3.0.0\nopenapi: 3.1.0\n')) + .toThrow(/duplicated mapping key/) + }) + + it('rejects multiple YAML documents', () => { + expect(() => loadOpenApiYaml('openapi: 3.0.0\n---\nopenapi: 3.1.0\n')) + .toThrow(/single document/) + }) + + it('uses v5 mapping and set semantics', () => { + expect(() => loadOpenApiYaml('? [name, region]\n: deployment\n')) + .toThrow(/does not support complex keys/) + expect(loadOpenApiYaml('features: !!set\n workflow:\n chat:\n')) + .toEqual({ features: new Set(['workflow', 'chat']) }) + }) +}) diff --git a/packages/contracts/openapi-yaml.ts b/packages/contracts/openapi-yaml.ts new file mode 100644 index 00000000000..b5755de5112 --- /dev/null +++ b/packages/contracts/openapi-yaml.ts @@ -0,0 +1,23 @@ +import { + binaryTag, + CORE_SCHEMA, + load, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, +} from 'js-yaml' + +const OPENAPI_YAML_SCHEMA = CORE_SCHEMA.withTags( + binaryTag, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, +) + +export function loadOpenApiYaml(input: string) { + return load(input, { schema: OPENAPI_YAML_SCHEMA }) +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 3eb36db6230..01dae1125dd 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -20,6 +20,7 @@ "scripts": { "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", + "test": "vp test openapi-yaml.test.ts", "type-check": "tsc" }, "dependencies": { @@ -29,12 +30,12 @@ "devDependencies": { "@dify/tsconfig": "workspace:*", "@hey-api/openapi-ts": "catalog:", - "@types/js-yaml": "catalog:", "@types/node": "catalog:", "@typescript/native": "catalog:", "eslint": "catalog:", "js-yaml": "catalog:", "typescript": "catalog:", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "vitest": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c8a30c1221..91980e6dd20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,9 +213,6 @@ catalogs: '@types/js-cookie': specifier: 3.0.6 version: 3.0.6 - '@types/js-yaml': - specifier: 4.0.9 - version: 4.0.9 '@types/lockfile': specifier: 1.0.4 version: 1.0.4 @@ -418,8 +415,8 @@ catalogs: specifier: 3.0.8 version: 3.0.8 js-yaml: - specifier: 4.3.0 - version: 4.3.0 + specifier: 5.2.1 + version: 5.2.1 jsonschema: specifier: 1.5.0 version: 1.5.0 @@ -706,7 +703,7 @@ importers: version: 3.1.0 js-yaml: specifier: 'catalog:' - version: 4.3.0 + version: 5.2.1 lockfile: specifier: 'catalog:' version: 1.0.4 @@ -735,9 +732,6 @@ importers: '@hono/node-server': specifier: 'catalog:' version: 2.0.8(hono@4.12.28) - '@types/js-yaml': - specifier: 'catalog:' - version: 4.0.9 '@types/lockfile': specifier: 'catalog:' version: 1.0.4 @@ -823,9 +817,6 @@ importers: '@hey-api/openapi-ts': specifier: 'catalog:' version: 0.98.2(@typescript/typescript6@6.0.2)(magicast@0.5.3) - '@types/js-yaml': - specifier: 'catalog:' - version: 4.0.9 '@types/node': specifier: 'catalog:' version: 25.9.5 @@ -837,13 +828,16 @@ importers: version: 10.6.0(jiti@2.7.0) js-yaml: specifier: 'catalog:' - version: 4.3.0 + version: 5.2.1 typescript: specifier: 'catalog:' version: '@typescript/typescript6@6.0.2' vite-plus: specifier: 'catalog:' version: 0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(esbuild@0.28.1)(happy-dom@20.10.6)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@25.9.5)(@vitest/browser-playwright@4.1.10)(@vitest/browser-preview@4.1.10)(@vitest/coverage-v8@4.1.10)(@voidzero-dev/vite-plus-core@0.2.4(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))(happy-dom@20.10.6) packages/dev-proxy: dependencies: @@ -1280,7 +1274,7 @@ importers: version: 3.0.8 js-yaml: specifier: 'catalog:' - version: 4.3.0 + version: 5.2.1 jsonschema: specifier: 'catalog:' version: 1.5.0 @@ -1519,9 +1513,6 @@ importers: '@types/js-cookie': specifier: 'catalog:' version: 3.0.6 - '@types/js-yaml': - specifier: 'catalog:' - version: 4.0.9 '@types/negotiator': specifier: 'catalog:' version: 0.6.4 @@ -4928,9 +4919,6 @@ packages: '@types/js-cookie@3.0.6': resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -7503,6 +7491,10 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + js-yaml@5.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} + hasBin: true + jsdoc-type-pratt-parser@7.1.1: resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} engines: {node: '>=20.0.0'} @@ -13323,8 +13315,6 @@ snapshots: '@types/js-cookie@3.0.6': {} - '@types/js-yaml@4.0.9': {} - '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} @@ -16289,6 +16279,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.2.1: + dependencies: + argparse: 2.0.1 + jsdoc-type-pratt-parser@7.1.1: {} jsdoc-type-pratt-parser@7.2.0: {} @@ -19703,7 +19697,6 @@ time: '@tsslint/compat-eslint@3.1.4': '2026-06-16T18:21:23.786Z' '@tsslint/config@3.1.4': '2026-06-16T18:21:26.545Z' '@types/js-cookie@3.0.6': '2023-11-07T08:41:16.889Z' - '@types/js-yaml@4.0.9': '2023-11-07T20:20:13.264Z' '@types/lockfile@1.0.4': '2023-11-07T20:23:21.070Z' '@types/negotiator@0.6.4': '2025-06-07T02:18:17.532Z' '@types/node@25.9.5': '2026-07-08T06:47:58.834Z' @@ -19772,7 +19765,7 @@ time: jotai@2.20.1: '2026-06-11T06:30:45.782Z' js-audio-recorder@1.0.7: '2021-01-09T10:20:49.923Z' js-cookie@3.0.8: '2026-05-29T10:51:39.065Z' - js-yaml@4.3.0: '2026-06-26T22:29:00.874Z' + js-yaml@5.2.1: '2026-07-02T00:55:21.714Z' jsonschema@1.5.0: '2025-01-07T15:09:11.287Z' katex@0.17.0: '2026-05-22T08:06:26.967Z' knip@6.25.0: '2026-07-07T08:47:13.416Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bbc1c856dbe..a3e82984eb9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -120,7 +120,6 @@ catalog: '@tsslint/compat-eslint': 3.1.4 '@tsslint/config': 3.1.4 '@types/js-cookie': 3.0.6 - '@types/js-yaml': 4.0.9 '@types/lockfile': 1.0.4 '@types/negotiator': 0.6.4 '@types/node': 25.9.5 @@ -188,7 +187,7 @@ catalog: jotai-tanstack-query: 0.11.0 js-audio-recorder: 1.0.7 js-cookie: 3.0.8 - js-yaml: 4.3.0 + js-yaml: 5.2.1 jsonschema: 1.5.0 katex: 0.17.0 knip: 6.25.0 diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts index ac1cf679704..176431a74d2 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -22,6 +22,20 @@ workflow: expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false) }) + it('should reject disallowed node types inherited through YAML merge keys', () => { + const content = ` +trigger: &trigger + type: trigger-webhook +workflow: + graph: + nodes: + - data: + <<: *trigger +` + + expect(validateDSLContent(content, AppModeEnum.ADVANCED_CHAT)).toBe(false) + }) + it('should reject malformed yaml and answer nodes in non-advanced mode', () => { expect(validateDSLContent('[', AppModeEnum.CHAT)).toBe(false) expect(validateDSLContent(` @@ -43,6 +57,18 @@ workflow: `, AppModeEnum.ADVANCED_CHAT)).toBe(true) }) + it('should accept empty yaml content', () => { + expect(validateDSLContent('', AppModeEnum.CHAT)).toBe(true) + }) + + it('should accept comment-only yaml content', () => { + expect(validateDSLContent('# No document', AppModeEnum.CHAT)).toBe(true) + }) + + it('should reject yaml streams with multiple documents', () => { + expect(validateDSLContent('---\n{}\n---\n{}', AppModeEnum.CHAT)).toBe(false) + }) + it('should expose the invalid node sets per mode', () => { expect(getInvalidNodeTypes(AppModeEnum.ADVANCED_CHAT)).toEqual( expect.arrayContaining([BlockEnum.End, BlockEnum.TriggerWebhook]), diff --git a/web/app/components/workflow/update-dsl-modal.helpers.ts b/web/app/components/workflow/update-dsl-modal.helpers.ts index 4d2c2adcda6..4c9401e37e7 100644 --- a/web/app/components/workflow/update-dsl-modal.helpers.ts +++ b/web/app/components/workflow/update-dsl-modal.helpers.ts @@ -1,8 +1,8 @@ import type { CommonNodeType, Node } from './types' -import { load as yamlLoad } from 'js-yaml' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { DSLImportStatus } from '@/models/app' import { AppModeEnum } from '@/types/app' +import { loadYaml } from '@/utils/yaml' import { BlockEnum, SupportUploadFileTypes } from './types' type ParsedDSL = { @@ -58,7 +58,7 @@ export const getInvalidNodeTypes = (mode?: AppModeEnum) => { export const validateDSLContent = (content: string, mode?: AppModeEnum) => { try { - const data = yamlLoad(content) as ParsedDSL + const data = loadYaml(content) as ParsedDSL | undefined const nodes = data?.workflow?.graph?.nodes ?? [] const invalidNodes = getInvalidNodeTypes(mode) return !nodes.some((node: Node) => invalidNodes.includes(node?.data?.type)) diff --git a/web/features/deployments/shared/domain/__tests__/dsl.spec.ts b/web/features/deployments/shared/domain/__tests__/dsl.spec.ts new file mode 100644 index 00000000000..6980e7028e7 --- /dev/null +++ b/web/features/deployments/shared/domain/__tests__/dsl.spec.ts @@ -0,0 +1,105 @@ +import { dslAppName, dslEnvVarSlots, encodeDslContent, isWorkflowDsl } from '../dsl' + +describe('deployment DSL domain', () => { + describe('content encoding', () => { + it('should preserve Unicode content through base64 encoding', () => { + const content = 'app:\n name: 部署 🚀' + + const decoded = new TextDecoder().decode(Uint8Array.from(atob(encodeDslContent(content)), character => character.charCodeAt(0))) + + expect(decoded).toBe(content) + }) + }) + + describe('app metadata', () => { + it('should trim the app name', () => { + expect(dslAppName('app:\n name: " Deployment app "')).toBe('Deployment app') + }) + + it('should return an empty app name for malformed content', () => { + expect(dslAppName('app: [')).toBe('') + }) + + it('should identify workflow apps', () => { + expect(isWorkflowDsl('app:\n mode: workflow')).toBe(true) + }) + + it('should reject non-workflow apps', () => { + expect(isWorkflowDsl('app:\n mode: advanced-chat')).toBe(false) + }) + }) + + describe('environment variable slots', () => { + it('should return no slots for malformed content', () => { + expect(dslEnvVarSlots('workflow: [')).toEqual([]) + }) + + it('should keep the first slot when names are duplicated', () => { + const content = ` +workflow: + environment_variables: + - name: REGION + value: first + - name: " REGION " + value: second +` + + expect(dslEnvVarSlots(content)).toEqual([{ + key: 'REGION', + defaultValue: 'first', + hasDefaultValue: true, + }]) + }) + + it('should omit masked secret defaults', () => { + const content = ` +workflow: + environment_variables: + - name: API_KEY + value: '[__HIDDEN__]' + value_type: secret +` + + expect(dslEnvVarSlots(content)).toEqual([{ + key: 'API_KEY', + valueType: 'secret', + }]) + }) + + it('should normalize unquoted timestamp defaults', () => { + const content = ` +workflow: + environment_variables: + - name: START_AT + value: 2026-07-10T12:34:56Z +` + + expect(dslEnvVarSlots(content)).toEqual([{ + key: 'START_AT', + defaultValue: '"2026-07-10T12:34:56.000Z"', + hasDefaultValue: true, + }]) + }) + + it('should apply values inherited through YAML merge keys', () => { + const content = ` +defaults: &defaults + description: Deployment region + value: ap-southeast-1 + value_type: string +workflow: + environment_variables: + - <<: *defaults + name: REGION +` + + expect(dslEnvVarSlots(content)).toEqual([{ + key: 'REGION', + description: 'Deployment region', + defaultValue: 'ap-southeast-1', + hasDefaultValue: true, + valueType: 'string', + }]) + }) + }) +}) diff --git a/web/features/deployments/shared/domain/dsl.ts b/web/features/deployments/shared/domain/dsl.ts index 5bfcd7c4178..1326ec72175 100644 --- a/web/features/deployments/shared/domain/dsl.ts +++ b/web/features/deployments/shared/domain/dsl.ts @@ -1,5 +1,5 @@ -import { load as yamlLoad } from 'js-yaml' import { AppModeEnum } from '@/types/app' +import { loadYaml } from '@/utils/yaml' export type DslEnvVarSlot = { key: string @@ -38,7 +38,7 @@ export function encodeDslContent(value: string) { function parseDsl(content: string) { try { - return yamlLoad(content) as DslMetadata | undefined + return loadYaml(content) as DslMetadata | undefined } catch { return undefined diff --git a/web/package.json b/web/package.json index 693a1f4be78..371a43649bb 100644 --- a/web/package.json +++ b/web/package.json @@ -195,7 +195,6 @@ "@tsslint/compat-eslint": "catalog:", "@tsslint/config": "catalog:", "@types/js-cookie": "catalog:", - "@types/js-yaml": "catalog:", "@types/negotiator": "catalog:", "@types/node": "catalog:", "@types/qs": "catalog:", diff --git a/web/utils/yaml.spec.ts b/web/utils/yaml.spec.ts new file mode 100644 index 00000000000..41ebf472f95 --- /dev/null +++ b/web/utils/yaml.spec.ts @@ -0,0 +1,18 @@ +import { loadYaml } from './yaml' + +describe('loadYaml', () => { + it('should reject duplicate mapping keys', () => { + expect(() => loadYaml('name: first\nname: second\n')) + .toThrow(/duplicated mapping key/) + }) + + it('should reject complex mapping keys', () => { + expect(() => loadYaml('? [name, region]\n: deployment\n')) + .toThrow(/does not support complex keys/) + }) + + it('should parse explicit YAML sets as JavaScript sets', () => { + expect(loadYaml('features: !!set\n workflow:\n chat:\n')) + .toEqual({ features: new Set(['workflow', 'chat']) }) + }) +}) diff --git a/web/utils/yaml.ts b/web/utils/yaml.ts new file mode 100644 index 00000000000..92de446e09b --- /dev/null +++ b/web/utils/yaml.ts @@ -0,0 +1,21 @@ +import { + binaryTag, + CORE_SCHEMA, + loadAll, + mergeTag, + omapTag, + pairsTag, + setTag, + timestampTag, + YAMLException, +} from 'js-yaml' + +const YAML_LOAD_SCHEMA = CORE_SCHEMA.withTags(binaryTag, mergeTag, omapTag, pairsTag, setTag, timestampTag) + +export function loadYaml(input: string) { + const documents = loadAll(input, { schema: YAML_LOAD_SCHEMA }) + if (documents.length > 1) + throw new YAMLException('expected a single document in the stream, but found more') + + return documents[0] +}